Merge branch 'main' of git.arirex.me:iris/ai-app

# Conflicts:
#	AGENTS.md
#	PLAN.md
#	app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt
#	app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt
#	server/src/config.rs
#	server/src/main.rs
#	server/src/routes.rs
#	server/src/session/echo.rs
#	server/src/session/llama.rs
#	server/src/session/transport.rs
#	server/src/ssh.rs
#	server/src/usage.rs
This commit is contained in:
iris committed 2026-09-04 17:56:50 -04:00
commit 3c0214ece8
94 files changed
+8298 -9453

No files matched your search

+490 -913
View File
File diff suppressed because it is too large. Load diff
+225 -378
View File
@@ -1,256 +1,212 @@
# The file explorer # The file explorer
Asked for by Bryan on 2026-09-03: replace the session screen's debug Asked for by Bryan on 2026-09-03 and built the same day: browse a machine's
button with a folder icon that opens a file and directory viewer for the directories, open files with the existing syntax highlighting and line
machine the session runs on. Browse directories, open files with the numbers, edit behind a pencil, create through a modal, work over ssh, and
existing syntax highlighting, line numbers, no wrapping; edit a file behind open at the session's working directory.
a pencil icon; create files through a modal like the ones the app already
has; work over ssh; open at the session's working directory.
Built on 2026-09-03. This is the design, decision by decision with the This is the design, decision by decision with the reason and what was
reason and what was rejected, so that when one changes it is changed here rejected, so that when one changes it is changed here rather than re-argued.
rather than re-argued. The operational half -- how to run it, what to press, The operational half how to run it and what to produce on purpose — is in
what to produce on purpose -- is in AGENTS.md, where the rest of this AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` /
project's working notes are. `FileViewer.kt` / `FileEditor.kt` / `FileLines.kt` are the app.
## What it is, in one paragraph ## What it is, in one paragraph
A machine's filesystem, seen from the phone through the backend. The A machine's filesystem, seen from the phone through the backend. The explorer
explorer belongs to a **setup** (a machine), not to a session: a session belongs to a **setup** (a machine), not to a session: a session only says
only says where to start. Every operation -- list, read, write, create -- where to start. Every operation list, read, write, create — is one shell
is one shell script run through `Transport`, exactly the way the import script run through `Transport`, exactly the way the import listing and the
listing and the usage fetch already work, so the local and the ssh case usage fetch already work, so the local and the ssh case are one
are one implementation and a machine the backend cannot reach fails with implementation and a machine the backend cannot reach fails with ssh's own
ssh's own message. The phone draws what came back: a listing, a file with message. The phone draws what came back.
its lines coloured by the scanner in `Highlighter.kt`, or an editor over
the same text.
## Decisions ## Decisions
### 1. Keyed on the machine, opened from the session ### 1. Keyed on the machine, opened from the session
Routes live under `/setups/{id}/…`, beside `importable`, because a Routes live under `/setups/{id}/…`, beside `importable`, because a filesystem
filesystem is a property of a machine. The session screen's folder button is a property of a machine. The session screen's folder button opens the
opens the explorer with the session's setup and its `cwd` as the starting explorer with the session's setup and its `cwd`; a session with no `cwd`
directory; a session with no `cwd` opens at the machine's home, which the opens at the machine's home, which the **machine** resolves (`cd` with no
machine resolves (`cd` with no argument and `pwd -P`), never a path the argument and `pwd -P`), never a path the phone guessed. Nothing in the
phone guessed. Nothing in the explorer knows what a session is, so a later explorer knows what a session is, so a later entry point from the setups tab
entry point from the setups tab is one more caller and no new code. is one more caller and no new code.
Rejected: routes under `/sessions/{id}/`. The session would be a detour to Rejected: routes under `/sessions/{id}/`. The session would be a detour to
find the setup, and "browse this machine" from anywhere but a session would find the setup, and "browse this machine" from anywhere else would need a
need a session to exist first. session to exist first.
### 2. One shell script per operation, over `Transport`, on both transports ### 2. One shell script per operation, over `Transport`, on both transports
Each operation is a small POSIX shell script handed to `sh -c script sh Each operation is a small POSIX script handed to `sh -c script sh "$path" …`
"$path" …` through `Transport::capture` (or the stdin-carrying variant through `Transport::capture` (or `capture_with_input`). The path and every
below). The path and every other value cross as **positional arguments**, other value cross as **positional arguments**, never interpolated into the
never interpolated into the script -- the same rule `import::find` follows script the same rule `import::find` follows and the same reason
with `"$1"`, and the same reason `ssh::quote` exists: a path is `ssh::quote` exists: a path is attacker-adjacent input in a server whose job
attacker-adjacent input in a server whose job is running commands. A `~` is running commands. `PATH_PRELUDE` is the one line that gives a leading `~`
prefix is handled by the same `quote_path`/`expand_home` pair every other its meaning, since a shell expands a tilde in text and not in an argument.
path goes through; nothing new is invented for it.
The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`, The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`,
`sha256sum`, `chmod --reference`). That is already what `import.rs` `sha256sum`, `chmod --reference`) already what `import.rs` assumes, and
assumes (`stat -c`, `/proc`), and both machines that exist are Linux. A both machines that exist are Linux. A machine without them fails with that
machine without them fails with that tool's own message, which names what tool's own message, which names what is missing.
is missing.
Rejected: `std::fs` for the local transport and scripts for ssh. Two Rejected: `std::fs` for the local transport and scripts for ssh. Two
implementations of "list a directory" drift -- the ordering of entries, implementations of "list a directory" drift the ordering of entries, what a
what a symlink reports, how a permission error reads -- and the local one symlink reports, how a permission error reads and the local one is the one
is the one that gets tested, so the remote one ships broken. The transport that gets tested, so the remote one ships broken. The cost is an `sh` process
design exists so that a driver never learns which machine it got; the per operation locally, which is under a millisecond.
explorer is held to the same rule. The cost is a `sh` process per
operation locally, which is under a millisecond.
Rejected: a Rust SSH or SFTP library. PLAN.md rule 23 -- the system `ssh` Rejected: a Rust SSH or SFTP library. The system `ssh` inherits
inherits `~/.ssh/config`, agents and jump hosts, and there is one place to `~/.ssh/config`, agents and jump hosts, and there is one place to configure a
configure a connection. SFTP would need a second one. connection; SFTP would need a second.
### 3. The token can now name a path, and that is written down ### 3. The token can now name a path, and that is written down
AGENTS.md says of the import route: "the phone picks an **id**, never a Elsewhere the phone picks an **id** and the server resolves which file it
path: the server resolves which file that is, so an enrolled token cannot names, so an enrolled token cannot become "read me an arbitrary file". The
become 'read me an arbitrary file'." The explorer's whole purpose is the explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's
path, so it takes one. This is recorded in PLAN.md's Security section as a Security section in these terms: the token already gates spawning a
change to the threat model paragraph, in these terms: the token already bypass-permissions agent in any directory on any machine a setup names, and
gates spawning a bypass-permissions agent in any directory on any machine that agent can already read and write every file its user can. The explorer
a setup names, and that agent can already read and write every file its is a shorter path to authority the token already holds, not new authority.
user can. The explorer is a shorter path to authority the token already The import rule stands where it is, because there a path was unnecessary and
holds, not new authority. The import route's rule stands where it is, refusing it cost nothing.
because there a path was unnecessary and refusing it cost nothing.
What is *not* changed: no route accepts a command. Listing, reading and What is *not* changed: **no route accepts a command.** Listing, reading and
writing are fixed scripts; the phone chooses only the path and the bytes. writing are fixed scripts; the phone chooses only the path and the bytes.
### 4. Paths are absolute or `~`-prefixed, and the machine answers with the real one ### 4. Paths are absolute or `~`-prefixed, and the machine answers with the real one
Same rule as `POST /sessions/{id}/cwd`: a relative path is refused with Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where
the same wording, because where it would be depends on where nothing the a relative path would be depends on something the reader cannot see. Every
reader can see. Every listing answers with `pwd -P` of the directory it listing answers with `pwd -P` of the directory it listed, so the phone
listed, so the phone navigates on a resolved absolute path -- the parent navigates on a resolved absolute path the parent is a string operation on
of `/home/bob/repos/ai-app` is a string operation on that, and a `~` the that, and a `~` the session was spawned with is shown as what it turned out
session was spawned with is shown as what it turned out to be. The phone to be. The phone never resolves `..` itself.
never resolves `..` itself.
### 5. A read is capped and typed, and every state it can be in has a word ### 5. A read is capped and typed, and every state it can be in has a word
`GET /setups/{id}/file` answers with one of: `GET /setups/{id}/file` answers with one of `text` (content, size, mtime,
sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over
- `text` -- the content, with its size, mtime and sha256. `FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
- `binary` -- the content is not UTF-8. Size reported, nothing shown. at), or the machine's own error.
- `tooBig` -- over `FILE_LIMIT` (1 MiB to start; see "Numbers to
measure"). Size reported so the reader knows what they are looking at.
- an error -- no such file, permission denied, machine unreachable --
carrying the machine's message.
Four outcomes rather than content-or-error, because a binary file drawn as Four outcomes rather than content-or-error, because a binary file drawn as
text and a big file cut off silently are both wrong in ways the reader text and a big file cut off silently are both wrong in ways the reader cannot
cannot see, and "couldn't read it" must not look like "it is empty". An see, and "couldn't read it" must not look like "it is empty". An empty file
empty file is `text` with empty content and is drawn as one empty line is `text` with empty content, drawn as one empty line numbered 1, which is
numbered 1, which is what it is. what it is.
Not in the first cut: showing images (the phone has `isImageRef` and a
viewer already; the route would serve bytes). Listed under "later".
### 6. A write is conditional on what the reader saw ### 6. A write is conditional on what the reader saw
`PUT /setups/{id}/file` carries the sha256 the read reported. The script `PUT /setups/{id}/file` carries the sha256 the read reported. The script
compares it against the file as it is now and refuses with a distinct exit compares it against the file as it is now and exits distinctly if it differs;
code if it differs; the server answers **409** with "changed on the machine the server answers **409**. Agents edit files while people read them; this is
since you opened it". Agents edit files while people read them; this is the common case, not the exotic one, and silently overwriting an agent's edit
the common case, not the exotic one, and silently overwriting an agent's with a stale copy is the worst available outcome. The phone offers three ways
edit with a stale copy is the worst available outcome. The phone offers out and says what each costs: **Overwrite** (theirs is lost), **Reload**
three ways out and says what each costs: **Overwrite** (theirs is lost), (yours is lost), **Cancel** (keep editing).
**Reload** (yours is lost), **Cancel** (keep editing, decide later).
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1" The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1" … && mv -f`,
"$1.ai-app-tmp" && mv -f -- "$1.ai-app-tmp" "$1"`, with the bytes on with the bytes on stdin: a temp file and a rename, so a connection dropped
stdin. A temp file and a rename, so a connection dropped mid-write leaves mid-write leaves the old file whole rather than truncated, and
the old file whole rather than a truncated one; `chmod --reference` keeps `chmod --reference` keeps the mode a fresh file would lose (an executable
the mode, which a fresh file would otherwise lose (an executable script script would stop being one). What this trades away is the inode, so a hard
would stop being one). What this trades away: the inode changes, so a hard link elsewhere stops being the same file — accepted; editors do the same. The
link elsewhere stops being the same file. Accepted; editors do the same. check-then-write is not atomic against a writer landing between the two, a
The check-then-write is not atomic against a writer landing between the window of microseconds on the same machine; accepted, and noted at the
two -- a window of microseconds on the same machine -- and that is accepted script. The response carries the new size, mtime and sha256, so the editor's
too, and noted at the script.
The response carries the new size, mtime and sha256, so the editor's
precondition is fresh without a second read. precondition is fresh without a second read.
### 7. Create refuses to overwrite ### 7. Create refuses to overwrite
`POST /setups/{id}/file {path}` runs under `set -C` (noclobber) and `POST /setups/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a
`: > "$1"`, so a name that exists fails with the shell's own message rather name that exists fails with the shell's own message rather than truncating
than truncating somebody's file. `POST /setups/{id}/dir {path}` is `mkdir somebody's file; `POST /setups/{id}/dir` is `mkdir --` with the same
--` with the same property. The modal names one thing in the current property. The modal names one thing in the current directory and has a switch
directory and has a switch for "directory"; a created file opens straight for "directory"; a created file opens straight into edit mode, because an
into edit mode, because an empty file is not something to look at. empty file is not something to look at.
Rejected: create-with-content in one request. The editor is the place Rejected: create-with-content in one request. The editor is where content is
content is typed, and a modal with a text area is a second editor. typed, and a modal with a text area is a second editor.
### 8. The viewer is a list of lines, coloured once ### 8. The viewer is a list of lines, coloured once
The file is scanned once, off the main thread, by `scan` in The file is scanned once, **off the main thread**, by `scan` in
`Highlighter.kt` with `rulesOf(language)`; the spans are bucketed per line `Highlighter.kt`; the spans are bucketed per line in one pass and each line's
in one pass, and each line's `AnnotatedString` is built when that line is `AnnotatedString` is built when that line is composed. A `LazyColumn` of
composed. A `LazyColumn` of lines, not one `Text`: text layout is linear lines, not one `Text`: text layout is linear in the text, so a 20,000-line
in the text, and a 20,000-line file in one `Text` measures all of it to file in one `Text` measures all of it to draw a screenful.
draw a screenful. Lines are drawn with `softWrap = false` inside one
shared `horizontalScroll` state, so the whole file scrolls sideways as a
block and a line never wraps.
**Sharing that state is not enough on its own, and this is where it was **Every row is given the same width**, and that is what makes the shared
wrong.** `horizontalScroll` is a node per row, and each one coerces the horizontal scroll work. `horizontalScroll` is a node per row, and each one
shared offset into *its own* range -- content width less viewport -- so coerces the shared offset into *its own* range content width less viewport
with rows at their natural widths a short line's range is zero and it does — so with rows at their natural widths a short line's range is zero and it
not move at all while the long line beside it does. Each row also writes does not move at all while the long line beside it does. Each row also writes
`maxValue` as it measures, so how far the file could be dragged was decided `maxValue` as it measures, so how far the file could be dragged was decided
by whichever row measured last, and changed as the list scrolled. Both go by whichever row measured last and changed as the list scrolled. The width is
away once **every row is given the same width**: the longest line in the longest line in columns times one character's advance, which is
columns times one character's advance, which is arithmetic rather than arithmetic rather than twenty thousand measurements because the face is
twenty thousand measurements because the face is monospace. A tab counts as monospace. A tab counts as eight columns and deliberately upwards —
eight columns and deliberately upwards -- over-estimating leaves a little over-estimating leaves a little empty space past the longest line,
empty space past the longest line, under-estimating puts the end of that under-estimating puts the end of that line out of reach — and the width is
line out of reach -- and the width is capped well under what `Constraints` capped well under what `Constraints` can carry, so a minified file is a
can carry, so a minified file is a scroll that stops early rather than a scroll that stops early rather than a crash. Reported by Iris on 2026-09-04
crash. Reported by Iris on 2026-09-04 as "it seems to affect different rows as "it seems to affect different rows differently", which is precisely what a
differently", which is precisely what a per-row range looks like. per-row range looks like.
**The stretch at the ends is one effect too**, shared by every row and **The stretch at the ends is one effect too**, shared by every row and
rendered once on the box around the list -- `horizontalScroll` makes its rendered once on the box around the list `horizontalScroll` makes its own
own per node otherwise, so only the line under the finger bent and the per node otherwise, so only the line under the finger bent while the rest of
rest of the file sat still beside it. That is the same complaint one layer the file sat still. It cannot be seen from this VM: the emulator's
further out, and it is only fixable now that every row agrees where the screenshots come back with no stretch in them at all, for any scrollable, so
end is. It cannot be seen from this VM: the emulator's screenshots come that one is checked on the phone.
back with no stretch in them at all, for any scrollable, so this one is
checked on the phone.
**The numbers sit outside that box**, so they neither travel with the text **The numbers sit outside that box**, so they neither travel with the text
nor bend with it. The rows leave a spacer where the numbers go and a nor bend with it. The rows leave a spacer where the numbers go and a
`SubcomposeLayout` beside the list draws them. That is the one arrangement `SubcomposeLayout` beside the list draws them. That is the one arrangement
that keeps them level: which numbers exist *and* where each goes both come that keeps them level: which numbers exist *and* where each goes both come
from the list's own `layoutInfo`, read in the measure block, and from the list's own `layoutInfo`, read in the measure block, and
subcomposition happens during measurement -- so it composes from the answer subcomposition happens during measurement so it composes from the answer
the list has just produced rather than from one it read a frame ago. A the list has just produced rather than one it read a frame ago. A column
column translated by the scroll position could not, since the translation translated by the scroll position could not, since the translation would be
would be current while the set of numbers was a composition behind, and current while the set of numbers was a composition behind, and during a fling
during a fling the numbers would slide against their lines. Checked at the numbers would slide against their lines. Checked at about 1kHz through a
about 1kHz through a fling: 23,520 row observations over 552 frames, every fling: 23,520 row observations over 552 frames, every one with its number at
one of them with its number at exactly its own top. exactly its own top. A consequence worth having: the numbers are outside the
`SelectionContainer`, so copying part of a file gives the code rather than
the code with a number in front of every line.
A consequence worth having: the numbers are no longer inside the The gutter is right-aligned, its width taken from the digit count of the line
`SelectionContainer`, so selecting part of a file and copying it gives the count in the same monospace style, so a 9-line file and a 12,000-line file
code rather than the code with a number in front of every line. each get exactly the width they need and nothing is measured by hand. Because
nothing wraps, a logical line is one visual line and the gutter cannot drift
Line numbers are a gutter in each row, right-aligned, with the gutter from the text it numbers. Numbers take `onSurfaceVariant`; the text takes the
width taken from the digit count of the line count in the same monospace
style -- so a 9-line file and a 12,000-line file each get exactly the
width they need and nothing is measured by hand. Because nothing wraps, a
logical line is one visual line, and the gutter cannot drift from the text
it numbers. Gutter numbers take `onSurfaceVariant`; the text takes the
scanner's palette on `rawSurface`, the surface every verbatim thing in the scanner's palette on `rawSurface`, the surface every verbatim thing in the
app already sits on. app already sits on.
The language comes from the file's extension through the same table The language comes from the file's extension through the same table
`fenceLanguage` reads (`FENCE_LANGUAGES` already keys on `kt`, `rs`, `fenceLanguage` reads — one table, not two, so a language added for fences is
`py`, …). One function, `fileLanguage(name)`, takes the part after the added for files. A file with no entry is drawn plain.
last dot and asks that table; it is one table, not two, so a language
added for fences is added for files. A file with no entry is drawn plain,
for the reason the table's comment gives.
Selection: the lines sit inside one `SelectionContainer`, as the
transcript does, so a selection can run across lines.
### 9. The editor is the legacy text field with a highlighting transformation ### 9. The editor is the legacy text field with a highlighting transformation
Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the same
same monospace style, inside the same horizontal scroll so it does not monospace style, inside the same horizontal scroll so it does not wrap, with
wrap, with a `VisualTransformation` that returns the text unchanged and a `VisualTransformation` that returns the text unchanged and the scanner's
the scanner's spans as styles (`OffsetMapping.Identity`, since no spans as styles (`OffsetMapping.Identity`, since no character moves). This is
character moves). This is the one Compose API that colours a field's text the one Compose API that colours a field's text without replacing the field;
without replacing the field; the newer `TextFieldState` API has no hook the newer `TextFieldState` API has no hook for styles. The gutter is one
for styles. The gutter is one `Text` of `1\n2\n…` in the same style beside `Text` of `1\n2\n…` beside the field, aligned for the same reason as the
the field, aligned for the same reason as the viewer: no wrap, one line viewer.
each.
Save is a glyph in the header, **disabled** until the text differs from Save is a glyph in the header, **disabled** until the text differs from what
what was loaded (never hidden -- a control that comes and goes makes its was loaded never hidden, since a control that comes and goes makes its own
own absence the signal), and a `GlyphSpinner` while the write is out. absence the signal. Back with unsaved changes asks, and says the edits will
Back with unsaved changes asks; the question says the edits will be lost. be lost. The explorer draws over the session, which deliberately has no
The keyboard: the explorer draws over the session, which deliberately has `imePadding`, so the explorer's own box adds it.
no `imePadding` (see `SessionScreen`'s layout note), so the explorer's own
box adds it.
Re-scanning on every keystroke is the cost to watch. For a file under
`FILE_LIMIT` it is expected to be a few milliseconds (the scanner replaced
a library that took 174ms on 200 lines; ours has not been measured on a
1 MiB file). Measure before deciding whether edit mode needs a size below
which highlighting is on -- see "Numbers to measure".
### 10. The explorer draws over the session, and back closes it first ### 10. The explorer draws over the session, and back closes it first
@@ -258,190 +214,85 @@ which highlighting is on -- see "Numbers to measure".
`FilesScreen` is composed **on top of** the session in the same `Box`, and `FilesScreen` is composed **on top of** the session in the same `Box`, and
the session stays composed under it: its event stream keeps flowing, its the session stays composed under it: its event stream keeps flowing, its
scroll position and draft stay where they were, and returning from a file scroll position and draft stay where they were, and returning from a file
costs nothing. Back -- the button and the platform gesture -- costs nothing. Back the button and the platform gesture — clears `files`
clears `files` when it is set and goes to the list otherwise. Inside the when set and goes to the list otherwise. Inside the explorer the same back
explorer the same back steps one level: editor → viewer (with the unsaved steps one level: editor → viewer (with the unsaved question) → listing →
question), viewer → listing, listing → parent directory it came from, and parent directory, and only from the starting directory does it close. "Back
only from the starting directory does it close. "Back returns; it does not returns; it does not exit."
exit."
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a
a leaf screen goes to Main today, and a session disposed and re-created on leaf screen goes to Main today, and a session disposed and re-created on each
each return refetches its transcript over the tunnel -- exactly the flip return refetches its transcript over the tunnel exactly the flip between
between "what did it change" and "what is it saying" this feature is for. "what did it change" and "what is it saying" this feature is for. The image
The image viewer already made the same choice for the same reason. viewer already made the same choice for the same reason.
### 11. The listing is drawn as it came, sorted at display time ### 11. The listing is drawn as it came, sorted at display time
Entries carry name, kind (`directory`, `file`, `other`), size, mtime, and Entries carry name, kind (`directory`, `file`, `other`), size, mtime, and
whether the entry is a symlink (with the kind being the *target's*, from whether the entry is a symlink with the kind being the *target's*, from
`find -printf '%Y'`, so a link to a directory navigates). Sorted on the `find -printf '%Y'`, so a link to a directory navigates. Sorted on the phone,
phone, stably: directories first, then case-insensitive name. Dotfiles are stably: directories first, then case-insensitive name. Dotfiles are shown; in
shown -- in a repository they are half of what matters. A row is the a repository they are half of what matters. Each directory's entries are kept
glyph, the name, and the size for a file; tapping a directory descends, for as long as the explorer is open, keyed by path, so returning to one does
tapping a file opens it. Each directory's entries are kept for as long as not refetch it; the header's refresh glyph refetches the current one on
the explorer is open, keyed by path, so returning to one does not refetch purpose, and a create refetches the directory it created into, since that is
it; the header's refresh glyph refetches the current one on purpose, and a what the operation changed.
create refetches the directory it created into, since that is what the
operation changed.
An empty directory says "Nothing here". A listing that failed says why, An empty directory says "Nothing here". A listing that failed says why, in
in the machine's words, where the rows would be -- never an empty list. the machine's words, where the rows would be never an empty list.
Entries are separated by `\0` in the script's output and by `\t` within a Entries are separated by `\0` in the script's output and by `\t` within a
line (`find -printf '%y\t%Y\t%s\t%T@\t%f\0'`), so a filename with a line, so a filename with a newline or a tab in it survives; `parse_entries`
newline or a tab in it survives; `parse_entries` is a unit test with is a unit test with exactly those names in it.
exactly those names in it.
### 12. Icons ### 12. Icons
Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script rerun
rerun and its output committed (it needs network): and its output committed: `md-folder` U+F024B (the header button and
directory rows), `md-plus` U+F0415, `md-pencil` U+F03EB,
`md-content_save` U+F0193, `md-file_outline` U+F0224. The folder and the plus
are the same codepoints dev-updater uses and must not drift from it, as the
cog and the refresh arrow already must not. All five were looked up in Nerd
Fonts' own `glyphnames.json` rather than copied from memory, which is the
check that a codepoint means the glyph its comment names.
- `md-folder` U+F024B -- the header button, and directory rows. The same **The folder button sits between the usage chart and the cog**, so the header
codepoint dev-updater uses, and it must not drift from it, as the cog reads widest scope to narrowest and the cog stays at the end where every
and the refresh arrow already must not. other screen keeps it. Asked for in that order by Iris on 2026-09-03.
- `md-plus` U+F0415 -- create. Also dev-updater's.
- `md-pencil` U+F03EB -- edit.
- `md-content_save` U+F0193 -- save.
- `md-file_outline` U+F0224 -- file rows.
All five were looked up in Nerd Fonts' own `glyphnames.json` rather than ### 13. The render report moved, and the benches moved with it
copied from memory, which is the check that a codepoint means the glyph its
comment names.
**Where the folder button sits**: between the usage chart and the cog, so The speedometer went; the report is a "Copy render timings" row in
the header reads widest scope to narrowest and the cog stays at the end `SessionSettingsDialog`, where the session's other about-the-session controls
where every other screen in this app keeps it. Asked for in that order by already are. **Moving it is where the no-coordinate-taps rule got enforced**
Iris on 2026-09-03. (Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
### 13. The render report moves, and the benches move with it
The speedometer goes. The report it copies is the standard measurement
`transcript-bench.sh` and `stream-bench.sh` read from logcat, so it stays
reachable: a "Copy render timings" row in `SessionSettingsDialog`, which
is where the session's other about-the-session controls already are.
**No script that drives the UI taps by coordinate, and moving this
button is where that rule gets enforced** (Bryan, 2026-09-03). Both bench
scripts press the button today as `ui-trace record --do 'tap 723 205'`, a
position measured once by hand. Anything that moves the header -- this
change, a font size, a density, another emulator -- makes that tap land on
whatever now sits there, and the script then reports a number that was
never measured, which reads exactly like a result. A control is found by
the name it already carries for assistive technology (`GlyphButton`'s
`label`, a row's text) and pressed at the bounds the screen reports at
that moment.
That belongs in the tool, not in each script: `ui-trace` in
`~/repos/emulator-tools` gains a tap-by-label action (`tap 'Session
settings'`, resolving the element's box from the same uiautomator tree
`elements` already reads, at the moment of the gesture), and both benches
move onto it in the same commit as the button -- cog, then "Copy render
timings" -- so the measurement is never unavailable and never wrong
quietly. `grep -n "tap [0-9]" app/*.sh` is the check that no coordinate
tap is left, and it goes in the emulator-tools README beside the action.
Once the action exists, this rule applies to every script that presses
something on an Android screen, not only these two.
## HTTP surface ## HTTP surface
Added to the table in `routes.rs`'s module doc: In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields`
like every other body here; paths in the query string are URL-encoded by
```text `Api.kt`'s existing helper.
GET /setups/{id}/dir?path=P entries of directory P, and P resolved
GET /setups/{id}/file?path=P content of file P, or why not
PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
(409 when the file no longer matches ifSha256)
POST /setups/{id}/file {path} create empty; refused if it exists
POST /setups/{id}/dir {path} create; refused if it exists
```
Bodies use `deny_unknown_fields` like every other body here. Paths in the
query string are URL-encoded by `Api.kt`'s existing helper.
```json ```json
GET dir -> {"path":"/home/bob/repos/ai-app", GET dir -> {"path":"/home/bob/repos/ai-app",
"entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false}, "entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false}]}
{"name":"README.md","kind":"file","size":1234,"modified":1756900000,"link":false}]}
GET file -> {"path":"/…/x.rs","kind":"text","size":1234,"modified":,"sha256":"…","content":"…"} GET file -> {"path":"/…/x.rs","kind":"text","size":1234,"modified":,"sha256":"…","content":"…"}
| {"path":"/…/a.png","kind":"binary","size":45678,"modified":} | {"path":"/…/a.png","kind":"binary","size":45678,"modified":}
| {"path":"/…/big.log","kind":"tooBig","size":12345678,"modified":} | {"path":"/…/big.log","kind":"tooBig","size":12345678,"modified":}
PUT file -> {"size":1240,"modified":,"sha256":"…"} PUT file -> {"size":1240,"modified":,"sha256":"…"}
``` ```
Errors: `BadRequest` with the machine's message for a path that is not Errors: `BadRequest` with the machine's message for a path that is not there,
there, not allowed or not absolute; the existing 409 variant for the not allowed or not absolute; 409 for the precondition; `Internal` only for
precondition; `Internal` only for the server's own faults. The message is the server's own faults. The message is what the phone shows, in place, so it
what the phone shows, in place, so it is written to be read there. is written to be read there.
## Server work (`server/src/files.rs`)
One module, with the same shape as `setups.rs`: the scripts as constants,
one `pub async fn` per operation taking `&Transport`, and the parsing as
pure functions with tests.
1. `Transport::capture_with_input(launch, stdin)` -- `capture` with bytes
on stdin. `ship_attachment` in `routes.rs` builds this by hand today
(an `ssh::command`, a `File` on stdin, `output().await`); it moves onto
the new helper in the same change, so there is one description of
"run this there with this on stdin" rather than two.
2. `list(transport, path) -> Listing`: `cd -- "$1" && pwd -P && find .
-mindepth 1 -maxdepth 1 -printf '%y\t%Y\t%s\t%T@\t%f\0'`. First line is
the resolved path; the rest is entries. `parse_entries` tested with
names containing a tab, a newline, a leading dash and a `'`.
3. `read(transport, path) -> Read`: `stat -c '%s %Y' -- "$1"`, refuse
above `FILE_LIMIT` before `cat` so a 2 GB log never crosses the
tunnel, then `sha256sum -- "$1"` and `cat -- "$1"`, header lines then
bytes; the server splits at the header and decides `text`/`binary` by
`String::from_utf8`.
4. `write(transport, path, expected_sha256, bytes) -> Written`: the
script in decision 6, with a distinct exit code for the precondition
(`exit 3`) that the route maps to 409; anything else is the machine's
stderr.
5. `create_file`, `create_dir`: decision 7.
6. Routes in `routes.rs`, each resolving the setup with `setup_by_id` and
`Transport::for_setup` as `set_cwd` does. The path check (absolute or
`~`) is one function shared with `set_cwd`, which has it inline today.
7. Tests: the parsers; the quoting (a path that tries to close the quote
ends up as one absurd argument -- `ssh.rs` has the pattern); and an
integration test running each script through `Transport::Here`
against a `tempfile` tree, which is cheap because `sh` is there
wherever `cargo test` runs. The precondition test writes the file
between the read and the write and asserts the 409 path.
8. PLAN.md: the Security paragraph from decision 3, and an "Explorer"
section pointing here. AGENTS.md: the layout bullet for `files.rs`.
## App work
1. `Api.kt`: `fetchDir`, `fetchFile`, `writeFile`, `createFile`,
`createDir`, and the three data classes (`DirEntry`, `FileContent`
as a sealed class with the four kinds, `Written`).
2. `NerdIcons.kt` + `build-icon-font.sh`: decision 12.
3. `Languages.kt` (or `CodeFence.kt`, wherever `FENCE_LANGUAGES` sits):
`fileLanguage(name)`.
4. `FileLines.kt`: the pure half of the viewer -- spans bucketed per line,
`lineOf(index) -> AnnotatedString` -- so it has a JVM unit test beside
`HighlighterTest`, the app's one existing test suite, covering a block
comment that spans lines and a file with no trailing newline.
5. `FilesScreen.kt`: the listing, the navigation stack, the per-directory
cache, the create dialog (modelled on `AddSetupDialog`: fields, a busy
state, the failure shown inside the dialog beside the button that
caused it), and the header. `LoadState` for the listing.
6. `FileViewer.kt`: decision 8. `FileEditor.kt`: decision 9, including
the conflict dialog.
7. `AppRoot.kt`: decision 10. `SessionScreen.kt`: the folder glyph where
the speedometer was, `onFiles(setup, cwd)` out to the root.
8. `SessionSettingsDialog.kt`: the render-report row. In
`~/repos/emulator-tools`, `ui-trace`'s tap-by-label action; then the
two bench scripts onto it, with no coordinate tap left in `app/*.sh`.
## What the measurements said (2026-09-04) ## What the measurements said (2026-09-04)
Taken on the emulator in a **debug** build, which runs Compose at a Taken on the emulator in a **debug** build, which runs Compose at a fraction
fraction of release speed and renders in software -- so these rank of release speed and renders in software so these rank correctly against
correctly against each other and are pessimistic in absolute terms. each other and are pessimistic in absolute terms. Generated Rust, through the
Generated Rust, through the app's own render report. app's own render report.
| file | lines | scan + cut | scan per keystroke | worst frame record | | file | lines | scan + cut | scan per keystroke | worst frame record |
|--------|--------|------------|--------------------|--------------------| |--------|--------|------------|--------------------|--------------------|
@@ -451,32 +302,29 @@ Generated Rust, through the app's own render report.
Three things followed. Three things followed.
**The viewer's scan had to leave the main thread.** Decision 8 said "off **The viewer's scan had to leave the main thread.** Decision 8 said "off the
the main thread" and the first version did it in a `remember` inside the main thread" and the first version did it in a `remember` inside the
composition, which is not that: 460ms of frozen screen at the size the composition, which is not that: 460ms of frozen screen at the size the server
server is willing to send, long enough that the accessibility tree cannot is willing to send, long enough that the accessibility tree cannot be read —
be read -- which is exactly what "the app has stopped" looks like from which is exactly what "the app has stopped" looks like from outside.
outside. It now runs on `Dispatchers.Default` with a spinner where the file
will be.
**`FILE_LIMIT` at 1 MiB is right for reading.** Time to first line for a **`FILE_LIMIT` at 1 MiB is right for reading.** Time to first line for a
1 MiB file, tap to text on screen, was **2.4s** against the sandbox -- 1 MiB file, tap to text on screen, was **2.4s** against the sandbox — 1.2s of
1.2s of which is that server's deliberate `--delay`, and 460ms the scan. which is that server's deliberate `--delay`, and 460ms the scan. The transfer
The transfer is not what dominates, so the route gains nothing from is not what dominates, so the route gains nothing from streaming.
streaming.
**Edit mode needed a cap, and not the one that was expected.** The plan **Edit mode needed a cap, and not the one that was expected.** The plan
expected to be deciding a size below which highlighting stays on. That is expected to be deciding a size below which highlighting stays on. That is not
not the cost that matters: highlighting 128 kB costs 40ms a keystroke, the cost that matters: highlighting 128 kB costs 40ms a keystroke, which is
which is survivable, while laying the same text out in one survivable, while laying the same text out in one `BasicTextField` costs two
`BasicTextField` costs two seconds -- characters typed into it were seconds characters typed into it were dropped, and a 1 MiB file stopped the
dropped, and a 1 MiB file stopped the app responding altogether. Since app responding altogether. Since every arrangement of a single text field
every arrangement of a single text field pays that, switching highlighting pays that, switching highlighting off would have saved nothing. So
off would have saved nothing. So `EDIT_LIMIT` is **32 kB**, the largest `EDIT_LIMIT` is **32 kB**, the largest size measured as usable, and above it
size measured as usable, and above it the pencil is disabled with the the pencil is disabled with the reason said in words beside it — a disabled
reason said in words beside it -- a disabled control teaches what the thing control teaches what the thing can do but cannot say why it is off, and a
can do but cannot say why it is off, and a reader who cannot edit a file reader who cannot edit a file they can plainly read would otherwise conclude
they can plainly read would otherwise conclude the app is broken. the app is broken.
Reading is unaffected: the viewer opens and scrolls the 1 MiB file fine, Reading is unaffected: the viewer opens and scrolls the 1 MiB file fine,
because it is a `LazyColumn` of lines rather than one text object. That because it is a `LazyColumn` of lines rather than one text object. That
@@ -484,21 +332,20 @@ difference is the whole of decision 8.
## Later, deliberately not now ## Later, deliberately not now
- Delete, rename and move. Destructive controls belong here eventually, - Delete, rename and move. Destructive controls belong here eventually, shown
shown and confirmed rather than hidden, but none of them is needed to and confirmed rather than hidden, but none is needed to read or change a
read or change a file. file.
- Images in the viewer, through the existing `SessionImageViewer`. - Images in the viewer, through the existing `SessionImageViewer`.
- Following an agent's edits live: a file open in the viewer refreshing - Following an agent's edits live: a file open in the viewer refreshing when
when a `Write`/`Edit` tool call on the same path lands in the a `Write`/`Edit` tool call on the same path lands in the transcript. The
transcript. The transcript already knows the path. transcript already knows the path.
- Remembering the last directory per session. - Remembering the last directory per session.
- Uploading from the phone into a directory. Attachments already do the - Uploading from the phone into a directory. Attachments already do the
upload half; this would be the same route with a chosen destination. upload half.
- Search within a file, and find-in-files. - Search within a file, and find-in-files.
- **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The - **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The viewer
viewer already draws a file as rows and stays fast on a megabyte; an already draws a file as rows and stays fast on a megabyte; an editor built
editor built the same way -- a field per line, or a field over the lines the same way a field per line, or a field over the lines on screen —
on screen -- would not pay Compose's cost of laying out one enormous would not pay Compose's cost of laying out one enormous text. It is a good
text. It is a good deal more than this feature needed, and 32 kB covers deal more than this feature needed, and 32 kB covers the config files,
the config files, notes and ordinary source files anybody edits from a notes and ordinary source files anybody edits from a phone.
phone.
+739 -1093
View File
File diff suppressed because it is too large. Load diff
+427
View File
@@ -0,0 +1,427 @@
# The transcript cache
Asked for by Iris on 2026-09-04 and built the same day: keep the transcripts
of recently visited sessions on the phone, so reopening one does not download
it again. It has to save data over the tunnel, must not disturb a reply that
is streaming when the screen is reopened, must never skip an event, and needs
a manual reload for when the file on the machine has changed under it.
Like EXPLORER.md this records each decision with its reason and what was
rejected, so that when one changes it is changed here rather than re-argued.
"What building it changed" at the foot says which of them moved while it was
being built. How to exercise it, and what has bitten, are in AGENTS.md.
## What it is, in one paragraph
A per-session file on the phone holding the exact JSON lines the server has
already sent, in transcript order, with a record of which sequence numbers
each run of lines covers. Everything the session screen fetches — the opening
window, the pages it scrolls back through, the span an anchor restore reaches
for — is asked of the cache first and of the server only for what the cache
does not hold, and everything that arrives from the server is written into
it. The live stream then resumes from the newest cached event, exactly as it
resumes from the newest event on screen, so the server sends only what
happened since. One tiny request checks that the cached tail is still what
the server has before the stream is opened from it, and a button in session
settings throws the cache away and rebuilds the screen as a cold open for the
cases that check cannot see.
## The invariants
When a decision below looks arbitrary, it is one of these forcing it.
1. **What is on screen is what the server's transcript says, in order, with
nothing missing, for every sequence number the screen claims to show.**
The cache is a copy of server output and is never inferred, folded, or
edited on the phone. Where the copy cannot be shown to be current, it is
thrown away, not patched.
2. **A cached line is never ahead of the live cursor, and the live cursor is
never ahead of the cache.** The stream resumes from the newest cached
event, so a reply that was mid-stream when the screen closed picks up at
its next delta and folds into the same row.
3. **The cache is never load-bearing.** A missing, evicted, corrupt or
unwritable cache degrades to a cold open, never to a blank or wrong
screen. Every path that reads it has a network path beside it producing
the same result.
4. **Data crosses the tunnel once.** A line already on the phone is not
fetched again unless the reader asks (the reload button) or the check in
decision 3 says it must be.
## Decisions
### 1. Raw server lines, on the phone, keyed by server and session
The cache stores the server's own JSON, one event per line, byte-for-byte as
it arrived: the elements of the `/transcript` array and the `data:` payload
of each SSE frame. Reading the cache runs the same `parseSeqEvent` the
network path runs, so a cached transcript and a fetched one cannot draw
differently, and an event type this build does not know
(`SessionEvent.Unknown`) survives on disk for the build that will.
It lives under `context.cacheDir`, which is exactly what that directory is
for: bytes the phone can regenerate from the server, which Android may delete
under storage pressure without asking. Keyed by the server's host and port,
because two servers can hold a session with the same id (the sandbox and the
real server, or a re-enrolment) and a line from one shown against the other
is invariant 1 broken. The `v1` segment is the format version: any change to
the layout below bumps it, and a directory of another version is deleted on
first use.
Rejected: a database (Room, SQLite). The access pattern is "the newest N
lines" and "the lines before seq X", on files of tens of megabytes at most,
and a JSONL file per contiguous run answers both by reading from its end. A
database would be a new dependency for an index the file layout provides.
Rejected: caching folded `TranscriptItem` rows instead of events. Rows are a
*rendering* of events, and their shape changes when the fold changes; the
cache would need invalidating on every app update that touched `foldEvent`,
and would still have to keep raw seqs for the stream cursor. Events are the
server's contract and the only thing that is stable.
### 2. Chunks with explicit coverage; one contiguous run behind the cursor
A page from the server is a set of lines *and a claim about what they cover*,
and the two are not the same thing. A coalesced page joins each run of
`assistantText` deltas into one event carrying the seq of its *oldest* delta,
so a page whose newest event has seq 1,200 may in fact cover every line up to
the `before` it was asked with, say 1,650. Nothing in the lines themselves
says so. So each stored chunk records its coverage as a half-open range
`[first, end)`, where `end` is the `before` the request was made with — or,
for a raw chunk, its newest seq plus one.
Chunks are files named by their coverage:
<first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
<first>-<end>.raw.jsonl an uncoalesced page or a closed live run
<first>-open.raw.jsonl the live run: appended to by the stream
Two chunks are **adjacent** when one's `end` equals the other's `first`. The
cache serves only the contiguous run of adjacent chunks that ends at the
newest raw chunk (the **suffix**); chunks behind a gap are kept on disk,
because the gap is usually filled (decision 4), but are never served across
it.
**The newest chunk is always raw.** That is what makes the stream cursor and
the probe well defined: a raw chunk's last line is a real event at a real
seq, and the server never coalesces the newest window. It holds by
construction — the opening window is fetched with no `before`, stream frames
are raw, and a `reset` window is raw — and is *checked* on read: a `.rows`
chunk found newest (which can only happen if the app died between closing one
live run and appending to the next) purges the session's cache.
There is at most one open chunk. A stream event whose seq is not the open
chunk's `end` — which is what a `reset` looks like from here — closes it by
renaming it with its real end and starts a new one. An event whose seq is
below the open chunk's `end` is already covered and is not written; the SSE
contract is `seq > after`, so that is a guard rather than a path.
Rejected: one file per session, rewritten to prepend older pages. A 20 MB
transcript would be rewritten on every page scrolled back to. The chunk
directory costs a directory listing per open instead.
Rejected: trimming chunks to resolve overlaps. A coalesced event cannot be
split at a seq inside its run, so an overlap between a coalesced page and an
existing chunk has no clean cut. The cache therefore **never stores a page
that overlaps an existing chunk**; decision 4 makes sure such a page is never
fetched, and one that arrives anyway is used for display and not stored.
### 3. The cached tail is checked against the server before the stream opens from it
The transcript file is append-only in ordinary use, but it can be replaced or
truncated — a sandbox re-seeded with the same ids, a backup restored, a
session deleted and re-imported — and `catch_up` on such a file would hand
the phone a continuation of a *different* conversation, spliced onto the
cached one with no seam. That is the worst thing this feature can do, and it
is caught with one request.
**The probe** is `GET /sessions/{id}/transcript?before=<cursor+1>&limit=1`,
where `cursor` is the seq of the cache's newest line. `read_window` with that
`before` returns the single newest event with seq ≤ cursor, which is the
event *at* the cursor when it exists. It passes when that response, parsed
with `parseSeqEvent`, is `==` to the cached line parsed the same way — over
seq, ts, and the whole event. It fails when the response is empty, is a
different seq, or differs in any field.
That equality rested on an assumption this plan stated and did not check:
that the two ways the server hands out a line agree bit for bit. **They did
not**, and the server was fixed — see AGENTS.md's entry on `float_roundtrip`.
Comparing everything *except* `ts` was the other option and was rejected: a
re-seeded fixture is identical in content and differs only in when it
happened, which is exactly the case the probe exists for.
A failed probe **purges the session's cache and proceeds as a cold open**. A
probe that cannot be made leaves the cached transcript on screen, shows the
error on the stream banner where a connection failure shows today, and is
retried on the stream loop's schedule; the stream is never opened until a
probe has passed once for this screen instance.
What the probe does *not* catch: a line changed in the middle of the file
with the tail intact, or a file rewritten so that the event at the cursor
happens to be identical. Those are what the reload button is for, and the
button's caption says so.
Cost: one request of a few hundred bytes, in the slot where the opening
page's request would be — so the round trips before the stream is live are
unchanged at two, and the bytes fall from a page to a line. The cached rows
are drawn *before* the probe returns, which is the whole point; a failed
probe replaces them, with the same appearance as a `reset`.
Rejected: a server-side check on the stream, answered with a distinct frame
when the event at N is not what the phone thinks. Strictly better coverage —
it would run on every reconnect — and no extra round trip. Not chosen because
it puts a cache's validation into a protocol that otherwise knows nothing
about caching, and because the reset frame already has to keep meaning "you
are behind, your history is fine". Worth revisiting if the probe's round trip
is ever measured as the thing making reopen slow.
Rejected: trusting the cache and relying on the reload button. Invariant 1 is
not something a button restores after the fact.
Rejected: fetching the newest page as before and using it to validate the
overlap. Zero saving on the opening page, which is the request paid on every
open.
### 4. Pages ask the server only for the gap: `after` on `/transcript`
After a reader has been away, the cache holds `[a, b)` and the screen holds
the newest window `[W, …)` with a gap between `b` and `W`. Paging back from
`W` asks for a coalesced page before `W`, and that page may reach back past
`b` — a single reply is hundreds of lines, so forty rows can be thousands of
seqs — producing exactly the overlap decision 2 refuses to store. Left like
that, every cached chunk would be dropped in turn as the reader paged back
through the gap, and the cache would save nothing for the sessions it exists
for.
So the transcript route takes a lower bound, `after`, named to match the SSE
route's (exclusive, `seq > after`). `read_window` starts the walk at
`first_at_or_after(after + 1)` instead of at `end - limit`. A delta run cut
at the start is emitted as the partial it is, exactly as one cut by `limit`
already is, and `healSplitMessage` welds it on the phone — no new mechanism.
The phone passes `after = b - 1` where `b` is the `end` of the nearest chunk
whose `end ≤ before`, and nothing when there is none. A page that comes back
with `first == b` is adjacent, and the suffix now runs through the old
chunks: the gap is closed with exactly the bytes it was wide, and the history
behind it is served locally from then on.
Rejected: fetching the gap raw in one request, which is what the anchor
restore does. Exact, but a gap of ten thousand lines is several megabytes
downloaded to save re-downloading history the reader may never scroll to.
Rejected: dropping the cached run whenever a gap opens. Being more than
`CATCH_UP_LIMIT` (200) events behind is the *ordinary* state of an active
session revisited — 200 raw events is one reply — so this would empty the
cache for exactly the sessions that are opened most.
### 5. A page is served locally in rows, mirroring the server's count
`loadOlderPage` asks for `HISTORY_PAGE` (40) **rows** when coalescing and for
a number of **events** otherwise (the anchor restore). Served from the cache,
the events branch is the `limit` lines before `before`. The rows branch walks
back counting rows the way `parse_coalesced` does — every event that is not
an `assistantText` is a row, and each maximal run of `assistantText` lines is
one row — stopping only between rows. It does not join the deltas; the fold
does that, and the joined row keeps the seq of its first delta either way, so
anchors and the next `before` land where they do on the network path.
A cached page is allowed to be **short**: a walk that reaches the suffix's
oldest chunk returns what it found. The caller already treats a short page as
a page; only an *empty* page means "start of the conversation", and the cache
never returns one — it returns `null` (a miss) and the network is asked.
A miss is `before` **outside what the suffix covers continuously** — above
its newest `end`, or at or below its oldest `first`. This plan first said a
miss was "no chunk of the suffix ends at `before`", which is wrong in the
commonest case there is: a warm open draws the newest eighty lines of the
live run, so the cursor the reader then scrolls back from is in the *middle*
of a chunk. Under the narrower rule every warm open sent its first backwards
page to the server, and that page overlapped what the phone already held and
could not be stored, so the same history was fetched again on every visit.
The feature would have saved the opening window and nothing else.
The row rule is a copy of the server's, and copies drift. It is short, it is
pure, and it is under a JVM unit test with the same fixture as the server's
`coalescing_counts_rows_and_joins_delta_runs` — a run cut by the limit, a
`usageDelta` inside a run (the server flushes the run there, so it is two
rows), and a page that is all one run.
### 6. What a `reset` means for the cache: behind, not wrong
The server sends `reset` when the cursor is more than `CATCH_UP_LIMIT` events
behind, then the newest 200 raw events. For the cache that means **the
history is intact and there is a gap**: the probe passed, the file is
append-only, and the window's first seq is above the open chunk's end. The
store learns this from the first window event's seq and needs no signal from
the screen; the gap is filled by paging.
The reset handler also clears `queued` and `waitingCommands`, which it did
not originally. Both are folded from events, and a `messageQueued` whose
resolving `userMessage` fell in the gap would otherwise draw a waiting bubble
for a message the session has long since read. That was a latent bug made
likely by the cache, because a cached tail is older than a fetched one.
`contextTokens` needs no clearing: `UsageDelta.context` is absolute, so the
window's first one corrects it.
### 7. Session state that is not the transcript comes from the list, not the cache
`apply` derives `status`, `model`, `permissionMode` and `compactingSince`
from `Status` and `Settings` events. Replayed from a fetched page those are
current; replayed from the cache they are as old as the last visit, while the
list row the reader just tapped was fetched moments ago. So the cache replay
runs through `apply` for the transcript's sake and then **reassigns those
four from `summary`**, which is the newer of the two measurements; the
stream's catch-up then makes them current. Without this a session that
finished an hour ago would open saying "working" until the stream connected,
which is a status row lying for a round trip.
### 8. Reload, in session settings
A row under the working directory showing what the button discards:
[ Transcript ] 2.3 MB cached [ Reload ]
The size is the unknown state made visible — `null` while the directory is
being measured (spinner, as the notifications switch does), "nothing cached"
when the directory is absent or empty, else the size. The caption is in the
style of Move's, because the button costs something the reader cannot see:
*"Reload throws away this phone's copy and fetches the transcript from the
server again. Use it when what is shown here disagrees with the file on the
machine."*
Pressing it purges the session's cache directory, closes the dialog, and
rebuilds the screen as a cold open, with the reader put back where they were.
The mechanism is an `epoch` counter in the key of the opening effect and the
stream effect; incrementing it cancels both and relaunches them. `savedAnchor`
is keyed on the epoch too, so the restore reads the anchor saved at the
reader's *current* position. The button is enabled whether or not anything is
cached: "what I see disagrees with the machine" is a state an empty cache can
also be in, and a control that comes and goes makes its own presence the
signal.
Nothing is announced on success — the transcript shows the opening spinner
and then the rows, which is what the screen already says about a reload. A
failure is the opening fetch's, and lands on the stream banner.
Rejected: a global "clear transcript cache" in the app's settings. Not asked
for; eviction bounds the total, and the per-session button is where the
reader is when they notice a problem. Easy to add as one more caller of
`purgeAll`.
### 9. Budget, eviction, pruning
Bounded three ways, each with its path out written beside the path in:
- **Budget.** `CACHE_BUDGET_BYTES` is 256 MB across all sessions of one
server. Each open touches the session directory's mtime; after the opening
replay, on `Dispatchers.IO`, the store sums the server's directories and
deletes least-recently-touched ones (never the one on screen) until under
budget. 256 MB is a dozen of the largest transcripts seen in this VM
(21 MB for 24,000 events) and a small fraction of a phone; it is a number
to revisit against real use, not a measurement.
- **Deleted sessions.** The list screen's delete purges after `deleteSession`
succeeds, and every successful list fetch calls `retainOnly(ids)`, so a
session deleted from another device is pruned on the next visit to the
list. `Drafts.kt` chose not to prune because its residue is bytes; here it
is megabytes.
- **Android.** `cacheDir` may be emptied at any moment, including while a
screen is open. Every read tolerates a missing directory and every write
failure is swallowed once.
### 10. The cache never breaks the screen
Every store operation that touches the disk catches `IOException` and answers
as if the cache were empty: `null` from a read, no-op from a write, logged
once. After a write failure the instance stops writing, so a full disk costs
one log line rather than one per delta. A line at the end of an open chunk
that does not parse — the app died mid-write — is dropped and the file
truncated to the last good line before anything is served from it; a line
that does not parse anywhere else purges the session's cache, since that file
was not written by this code. None of this is reported on screen: none of it
changes what the screen shows, and the reader has nothing to do about it.
## Layout on disk
<cacheDir>/transcripts/
v1/
10.0.2.2_8443/ one directory per server (host_port)
3f2c…/ one per session id
1-1650.rows.jsonl coalesced page: covers seqs 1..1649
1650-2001.rows.jsonl
2001-2400.raw.jsonl a closed live run
2600-open.raw.jsonl the live run
Here 2400..2599 is a gap: the reader was away for two hundred events and the
stream reset. The suffix is the single chunk `2600-open`; the first backwards
page asks the server for `before=2600&after=2399&coalesce=true`, and once a
page comes back with `first == 2400` the suffix runs to seq 1.
Each `.jsonl` is one JSON object per line, oldest first, exactly as the
server sent it. No header, no index: coverage is in the name, order is the
file's, and the seq is in every line.
## What building it changed
Each of these contradicted the plan, and each was found by running it rather
than by reading it. The decisions above are amended in place; this is what
moved, so a reader who remembers the first version knows what to re-read.
- **The probe's equality had a false premise** (decision 3). The server did
not hand out the same line twice the same way. Fixed on the server.
- **A cached page starts anywhere inside the run** (decision 5). Requiring a
chunk boundary would have made the cache save the opening window and
nothing else.
- **The opening window is stored by `append`, not by `storePage`.** The
sketch had `storePage` grow a special case for "this page is the new open
chunk", decided by an implicit condition a raw history page also satisfies.
Appending each line instead is the mechanism that already exists, and the
open chunk stays the one thing that grows.
- **Chunks are read backwards, in blocks, and never whole.** Every question
the cache is asked is about the newest end, and a live run reaches the size
of the conversation — so reading a chunk to answer with eighty lines of it
is the cost the server's own reader was rewritten to stop paying, arriving
on the phone. Damage is therefore noticed when a read reaches it rather
than up front, which is the better time: what is not read cannot be wrong.
- **The stream waits for the opening effect's probe.** The screen lifts
`ready` before the probe returns — that is the point of the cache — so
`ready` stopped being the whole gate, and the stream loop asked the same
question a second time and raced its own answer. Two probes per warm open,
visible in the server's log.
- **`SessionCache` is synchronized.** The stream appends live events from one
IO thread while a reader scrolling back reads pages from another; the open
chunk's name, its end and its writer must never be seen half-rotated.
## What it cost, measured
On the emulator against `app/ui-sandbox.sh`, 2026-09-04, on a session of 505
events (three short exchanges and two 300-delta replies):
- **Reopening it: one request, for one event.** The probe, and nothing else —
including scrolling the whole conversation back to its first line. A cold
open of the same session is two requests and 100 events.
- **A reset after falling 300 events behind costs the gap and no more.** The
window arrived at seq 306, the phone held up to 202, and the first
backwards page asked `before=306&after=201` and came back with **four
coalesced rows** covering 202..305 — against the 104 raw events an
unbounded page would have re-fetched and thrown away.
- **Every chunk is exactly what the server says for the range its name
claims**, checked line by line against `/transcript` for each chunk's own
`before`/`after`/`coalesce`, across a reset and a gap-fill.
- **Nothing about drawing changed**, which is what a cache must not do:
`transcript-bench.sh` before and after, same viewport content and gestures,
p50 16.9ms both times and the transcript's own draw accounting at 0.33ms
against 0.32ms.
Still to measure, in real use rather than here: the size the cache reaches
against `CACHE_BUDGET_BYTES`, and whether the probe's round trip is ever what
a reader waits on.
## Open questions
- **The probe on every reconnect, not only on open?** A file replaced *while*
the screen is open is not made worse than it was, but the server-side check
decision 3 rejects would close it. Decide after measuring how often the
probe's round trip is what the reader waits on.
- **Images.** `SessionImage` fetches bytes from the files route on draw; they
are not part of this cache and are re-downloaded per view. A separate,
simpler cache (a directory of refs, no ordering) if the measurement above
says the images are where the data goes.
@@ -13,9 +13,8 @@ import androidx.compose.ui.text.style.TextDecoration
* *
* Its own palette rather than the syntax one: a program that prints in red has chosen red, where a * Its own palette rather than the syntax one: a program that prints in red has chosen red, where a
* highlighter's colours are this app's reading of somebody else's code. They come out of the same * highlighter's colours are this app's reading of somebody else's code. They come out of the same
* Catppuccin values (see `ansiPalette` in `Theme.kt`) so nothing on screen is a colour from * Catppuccin values so nothing on screen is a colour from somewhere else, but the two are not one
* somewhere else, but the two are not one table and must not become one -- adding a syntax role to * table -- adding a syntax role to this list would silently move `ls`'s directory blue.
* this list would silently move `ls`'s directory blue.
*/ */
data class AnsiPalette( data class AnsiPalette(
/** Indexes 0-7, then 8-15 bright, in the terminal's own order. */ /** Indexes 0-7, then 8-15 bright, in the terminal's own order. */
@@ -30,19 +29,18 @@ data class AnsiPalette(
* What a tool printed, with its terminal styling applied and everything else taken out. * What a tool printed, with its terminal styling applied and everything else taken out.
* *
* Bash output arrives exactly as the program wrote it, escape sequences included, and drawn * Bash output arrives exactly as the program wrote it, escape sequences included, and drawn
* verbatim those are line noise in the middle of the thing being read: `ESC[0;32m` in front of * verbatim those are line noise in the middle of the thing being read. Stripping them all would be
* every green word. Stripping them all would be the other half-answer -- colour is often the whole * the other half-answer -- colour is often the whole of what a diff or a test run is saying.
* of what a diff, a test run or a linter is saying.
* *
* So the sequences that decide how text *looks* become spans, and every other one is dropped. * So the sequences that decide how text *looks* become spans, and every other one is dropped rather
* Dropped rather than shown, because the rest move a cursor around a grid this is not: a transcript * than shown: the rest move a cursor around a grid this is not, and "go to column 40" has no
* is a scrolling document, and "go to column 40" has no meaning here that is better than nothing. * meaning in a scrolling document.
* *
* A carriage return is honoured the way a terminal honours it: what was written since the last line * A carriage return is honoured the way a terminal honours it: what was written since the last line
* break is thrown away and the line starts again. That is what makes a progress bar show its final * break is thrown away and the line starts again. That is what makes a progress bar show its final
* state rather than every state it passed through, which was tens of lines run together. * state rather than every state it passed through.
* *
* Not a composable, and the palette is a parameter: this can then be remembered against the text it * Not a composable, and the palette is a parameter, so this can be remembered against the text it
* parsed rather than re-run on every recomposition of the card holding it. * parsed rather than re-run on every recomposition of the card holding it.
*/ */
fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString { fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
@@ -71,10 +69,9 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
if (final == 'm') sgr = sgr.apply(params, palette) if (final == 'm') sgr = sgr.apply(params, palette)
} }
} }
// A bare carriage return rewrites the line. One before a newline is the other half // A bare carriage return rewrites the line. One before a newline is the other half of a
// of a Windows line ending: it rewrites nothing, and it is dropped rather than kept, // Windows line ending: it rewrites nothing, and it is dropped rather than kept, since
// since that pair is one line break and the return itself would draw as a stray // that pair is one line break.
// control character.
c == '\r' && text.getOrNull(at + 1) != '\n' -> { c == '\r' && text.getOrNull(at + 1) != '\n' -> {
flush() flush()
dropLine(runs) dropLine(runs)
@@ -82,8 +79,8 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
} }
c == '\r' -> at++ c == '\r' -> at++
// Everything printable, plus the two control characters that are layout rather than // Everything printable, plus the two control characters that are layout rather than
// terminal commands. A stray bell or backspace goes for the same reason a cursor // terminal commands. A stray bell or backspace goes for the same reason a cursor move
// move does. // does.
c >= ' ' || c == '\n' || c == '\t' -> { c >= ' ' || c == '\n' || c == '\t' -> {
plain.append(c) plain.append(c)
at++ at++
@@ -129,9 +126,8 @@ private const val BELL = '\u0007'
* Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte. * Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte.
* *
* One reader for every kind, because the point is to *leave* them all behind: a sequence this did * One reader for every kind, because the point is to *leave* them all behind: a sequence this did
* not recognise would otherwise have its body printed as ordinary text, which is worse than the * not recognise would otherwise have its body printed as ordinary text. Three shapes -- the CSI
* escape it was meant to remove. Three shapes -- the CSI (`ESC [ … letter`), the string escapes * (`ESC [ … letter`), the string escapes which run to a terminator, and the two-character ones.
* (OSC, DCS, APC, PM) which run to a terminator, and the two-character ones.
*/ */
private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int { private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int {
val next = text.getOrNull(at + 1) ?: return at + 1 val next = text.getOrNull(at + 1) ?: return at + 1
@@ -140,9 +136,9 @@ private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Un
var end = at + 2 var end = at + 2
while (end < text.length && text[end] !in CSI_FINAL) end++ while (end < text.length && text[end] !in CSI_FINAL) end++
if (end >= text.length) { if (end >= text.length) {
// Cut off mid-sequence, which is what a stream that has not finished arriving // Cut off mid-sequence, which is what a stream that has not finished arriving looks
// looks like: drop the fragment rather than printing it, and the whole sequence // like: drop the fragment rather than printing it, and the whole sequence arrives
// arrives with the next delta. // with the next delta.
text.length text.length
} else { } else {
onCsi(text.substring(at + 2, end), text[end]) onCsi(text.substring(at + 2, end), text[end])
@@ -6,26 +6,23 @@ import java.net.URL
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
// The REST half of the backend's surface (see server/src/routes.rs for the // The REST half of the backend's surface (see server/src/routes.rs for the table); the SSE half is
// table); the SSE half is EventStream.kt. All blocking network calls -- // EventStream.kt. All blocking network calls -- invoke from a background dispatcher. Each throws
// invoke from a background dispatcher. Each throws ApiException on failure, // ApiException carrying the server's own explanation where it sent one, since those messages are
// carrying the server's own explanation where it sent one, since those // written to be read on this screen.
// messages are written to be read on this screen.
// Shared with EventStream.kt, which connects the same way but then reads // Shared with EventStream.kt, which connects the same way but then reads without a deadline.
// without a deadline.
const val CONNECT_TIMEOUT_MS = 5000 const val CONNECT_TIMEOUT_MS = 5000
private const val READ_TIMEOUT_MS = 5000 private const val READ_TIMEOUT_MS = 5000
/** /**
* A request that did not produce what it asked for, carrying the server's own wording where it sent * A request that did not produce what it asked for, carrying the server's own wording where it sent
* some -- those messages are written to be read on the screen that made the call. * some.
* *
* [status] is the HTTP status where there was a response at all, and null where the server was * [status] is the HTTP status where there was a response at all, and null where the server was
* never reached. Callers that need it need it because the *same* failure is two different things to * never reached. Callers that need it need it because the *same* failure is two different things to
* do: a 409 from a write is "somebody else changed this, here are three ways out", where every * do: a 409 from a write is "somebody else changed this, here are three ways out". Nothing should
* other refusal is a message to show. Nothing should branch on it to decide what to *say* -- the * branch on it to decide what to *say* -- the message is what says that.
* message is what says that.
*/ */
class ApiException(message: String, val status: Int? = null, cause: Throwable? = null) : class ApiException(message: String, val status: Int? = null, cause: Throwable? = null) :
Exception(message, cause) Exception(message, cause)
@@ -33,10 +30,10 @@ class ApiException(message: String, val status: Int? = null, cause: Throwable? =
/** /**
* Runs one request against the backend, with the pinned TLS setup, the bearer token, and the * Runs one request against the backend, with the pinned TLS setup, the bearer token, and the
* failure translation every call needs. [readBody] gets the connected, already-status-checked * failure translation every call needs. [readBody] gets the connected, already-status-checked
* connection to read from. * connection.
* *
* @param readTimeoutMs how long to wait on the response body. The SSE stream doesn't come through * @param readTimeoutMs how long to wait on the response body. The SSE stream doesn't come through
* here -- an event stream has no bounded read time (see EventStream.kt). * here -- an event stream has no bounded read time.
*/ */
fun <T> requestFromServer( fun <T> requestFromServer(
settings: ServerSettings, settings: ServerSettings,
@@ -44,9 +41,9 @@ fun <T> requestFromServer(
method: String = "GET", method: String = "GET",
jsonBody: String? = null, jsonBody: String? = null,
/** /**
* A request body written as it is produced -- the upload path. Content type, and a writer * A request body written as it is produced -- the upload path. Sent chunked, since what a
* handed the connection's stream. Sent chunked, since what a writer will produce is not known * writer will produce is not known up front and the point is that a file never sits whole in
* up front and the point is that a file never sits whole in memory on this side. * memory.
*/ */
streamBody: Pair<String, (java.io.OutputStream) -> Unit>? = null, streamBody: Pair<String, (java.io.OutputStream) -> Unit>? = null,
readTimeoutMs: Int = READ_TIMEOUT_MS, readTimeoutMs: Int = READ_TIMEOUT_MS,
@@ -87,9 +84,8 @@ fun <T> requestFromServer(
} catch (e: ApiException) { } catch (e: ApiException) {
throw e throw e
} catch (e: IOException) { } catch (e: IOException) {
// Surfacing the real exception (rather than one canned message for // Surfacing the real exception rather than one canned message for every failure mode is
// every failure mode) is what lets this be diagnosed on a device // what lets this be diagnosed on a device with no logcat access.
// with no logcat access.
throw ApiException( throw ApiException(
"Couldn't reach the server at ${settings.baseUrl} " + "Couldn't reach the server at ${settings.baseUrl} " +
"(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " + "(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
@@ -107,11 +103,9 @@ fun <T> requestFromServer(
} }
} }
/** The response body as one JSON object. */
private fun HttpURLConnection.jsonObject(): JSONObject = private fun HttpURLConnection.jsonObject(): JSONObject =
JSONObject(inputStream.bufferedReader().readText()) JSONObject(inputStream.bufferedReader().readText())
/** The response body as a JSON array of objects, each mapped through [parse]. */
private fun <T> HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List<T> = private fun <T> HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List<T> =
JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse) JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse)
@@ -120,21 +114,16 @@ private fun <T> JSONArray.mapObjects(parse: (JSONObject) -> T): List<T> =
private fun JSONArray.strings(): List<String> = (0 until length()).map { getString(it) } private fun JSONArray.strings(): List<String> = (0 until length()).map { getString(it) }
/** Percent-encodes a value going into a query string. */
private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name()) private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name())
// One row of GET /sessions. A session names the machine it runs on and // One row of GET /sessions. A session names the machine it runs on and which of that machine's
// which of that machine's providers it runs. // providers it runs.
data class SessionSummary( data class SessionSummary(
val id: String, val id: String,
/** /**
* Id of the machine this session runs on. Only ever used to *address* that machine -- to pick * Id of the machine this session runs on. Only ever used to *address* that machine -- to pick
* this session's row out of the per-machine usage snapshots for the header's five-hour bar. * this session's row out of the per-machine usage snapshots. Never shown; [setupName] is what a
* * reader sees, and holding both invites showing the wrong one.
* It was deliberately left out until 2026-08-29, on the grounds that nothing here addressed a
* setup and holding both the id and the name invited showing the wrong one, which had already
* happened once. Something addresses one now, so the reason lapsed rather than being overruled.
* The guard that replaces it is the rule below: never show this.
*/ */
val setup: String, val setup: String,
/** The machine's current label. This is the one to display; [setup] is never shown. */ /** The machine's current label. This is the one to display; [setup] is never shown. */
@@ -147,9 +136,8 @@ data class SessionSummary(
* provider's kind rather than here from its name. * provider's kind rather than here from its name.
* *
* What it licenses is narrow, and the delete dialog is worded to match: the driver keeps its * What it licenses is narrow, and the delete dialog is worded to match: the driver keeps its
* own record of the conversation somewhere this app's delete does not reach. It is not a * own record somewhere this app's delete does not reach. It is not a promise that the file is
* promise that the file is still there, and re-importing is not a restore -- this app's * still there, and re-importing is not a restore.
* transcript holds things that record does not.
*/ */
val keepsOwnTranscript: Boolean, val keepsOwnTranscript: Boolean,
/** How much the session asks before acting; null when it was never set. */ /** How much the session asks before acting; null when it was never set. */
@@ -162,35 +150,32 @@ data class SessionSummary(
* Whether this session announces itself when it wants attention. * Whether this session announces itself when it wants attention.
* *
* Reported rather than assumed, for the same reason [permissionMode] is: a switch that draws * Reported rather than assumed, for the same reason [permissionMode] is: a switch that draws
* itself from a default is one you can turn off while believing you are reading it. Defaults to * itself from a default is one you can turn off while believing you are reading it.
* on when a backend is too old to say, which matches what that backend actually does.
*/ */
val notify: Boolean, val notify: Boolean,
/** /**
* The directory the session works in, or null where it was never given one. * The directory the session works in, or null where it was never given one.
* *
* Null is not "the home directory": it is the session never having been told, and what the * Null is not "the home directory": it is the session never having been told. Shown as unset
* process then starts in belongs to whatever launches it. Shown as unset rather than filled in * rather than filled in with a guess, so a reader changing it is choosing rather than
* with a guess, so a reader changing it is choosing rather than confirming. * confirming.
*/ */
val cwd: String?, val cwd: String?,
/** /**
* How much context this session is holding, as the server last measured it -- see * How much context this session is holding, as the server last measured it.
* `SessionEvent.UsageDelta`.
* *
* Null where nothing has been measured: a session that has not run a turn, a provider that does * Null where nothing has been measured: a session that has not run a turn, a provider that does
* not report usage, or a clear nobody has run a turn since. That is not zero, and the status * not report usage, or a clear nobody has run a turn since. That is not zero, and the status
* row says so in words rather than drawing an empty context for a conversation that may be * row says so in words rather than drawing an empty context for a conversation that may be
* nearly full. * full.
*/ */
val contextTokens: Long?, val contextTokens: Long?,
/** /**
* The longest edge an image should have when it reaches this session, or null where the * The longest edge an image should have when it reaches this session, or null where the
* provider has no limit. * provider has no limit.
* *
* Null and "a big number" are different answers, and only the first stays true: a provider that * Null and "a big number" are different answers, and only the first stays true. Decided by the
* does not care about size should not be given a threshold this app invented. Decided by the * server because that is where a provider's kind is known.
* server because that is where a provider's kind is known -- see `uploadPickedImage`.
*/ */
val maxImageEdge: Int?, val maxImageEdge: Int?,
/** /**
@@ -237,18 +222,16 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
* *
* For screens whose controls are *set to* something rather than merely showing it. A screen opened * For screens whose controls are *set to* something rather than merely showing it. A screen opened
* from a list row carries the row the list last fetched, which is a snapshot: fine for a title, * from a list row carries the row the list last fetched, which is a snapshot: fine for a title,
* wrong for a switch, since a switch drawn from a stale row shows a position that may have been * wrong for a switch, since a stale row shows a position that may have been changed since.
* changed since -- here or on another device -- and nothing on screen says which.
*/ */
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary = fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) } requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
// What the server offers, so the spawn screen has no hardcoded lists: a // What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
// setup added to the server's config.ron appears here with no app rebuild. // config.ron appears here with no app rebuild.
// //
// One list rather than two. A provider only exists on a machine that has // One list rather than two. A provider only exists on a machine that has it installed, so offering
// it installed, so offering machines and providers as independent choices // machines and providers as independent choices would offer pairs that cannot work.
// would offer pairs that cannot work.
data class Provider(val name: String, val kind: String, val models: List<String>) data class Provider(val name: String, val kind: String, val models: List<String>)
/** /**
@@ -287,7 +270,7 @@ fun fetchSetups(settings: ServerSettings): List<Setup> =
* A Claude Code session already on a machine, which can be continued here. * A Claude Code session already on a machine, which can be continued here.
* *
* Identified by [id] and never by a path. The server resolves which file that is, so this app has * Identified by [id] and never by a path. The server resolves which file that is, so this app has
* no way to ask it to read one -- the same rule that keeps a provider's command out of this client. * no way to ask it to read one.
*/ */
data class Importable( data class Importable(
val id: String, val id: String,
@@ -296,19 +279,15 @@ data class Importable(
val modified: Double, val modified: Double,
val lines: Int, val lines: Int,
/** /**
* Size of the session file in bytes. * Size of the session file in bytes. Worth a place on the row because it is the only thing
* * there that predicts what continuing the session costs, and the line count does not: these
* Worth a place on the row because it is the only thing there that predicts what continuing the * transcripts embed screenshots as base64, so a single line can be a megabyte.
* session costs, and the line count does not: these transcripts embed screenshots as base64, so
* a single line can be a megabyte.
*/ */
val bytes: Long, val bytes: Long,
/** /**
* Tokens the model was holding at the last turn, or null if no turn has recorded any. * Tokens the model was holding at the last turn, or null if no turn has recorded any. It
* * disagrees with [bytes] in the direction that matters: most of a large transcript is usually
* The number that predicts what continuing this session costs. It disagrees with [bytes] in the * history from before a compaction, which the model is no longer given.
* direction that matters: most of a large transcript is usually history from before a
* compaction, which the model is no longer given.
*/ */
val contextTokens: Long?, val contextTokens: Long?,
/** Whether [title] is a name somebody chose rather than the last thing said in the session. */ /** Whether [title] is a name somebody chose rather than the last thing said in the session. */
@@ -317,17 +296,15 @@ data class Importable(
* Whether a Claude Code is running this session right now. * Whether a Claude Code is running this session right now.
* *
* "unknown" is a third answer and not a synonym for "no": the machine may keep no record of * "unknown" is a third answer and not a synonym for "no": the machine may keep no record of
* what is running, and a session that cannot be checked is not a session that is free. The * what is running. The server refuses an import of a "yes"; the row says so before you press
* server refuses an import of a "yes"; the row says so before you press it. * it.
*/ */
val inUse: String, val inUse: String,
/** /**
* What this server is doing to the session right now -- "importing" or "deleting" -- or null * What this server is doing to the session right now -- "importing" or "deleting" -- or null.
* when nothing is.
* *
* The server's answer rather than the phone's, because the work outlives the screen that asked * The server's answer rather than the phone's, because the work outlives the screen that asked
* for it: leaving the import list and coming back has to show what is still running, and a * for it: a phone that was asleep never saw the events that said so.
* phone that was asleep or out of range never saw the events that said so.
*/ */
val pending: String?, val pending: String?,
/** /**
@@ -340,7 +317,7 @@ data class Importable(
/** /**
* One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing. * One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing.
* *
* [operation] is only set by a start, and [message] only by a failure -- the three states are every * [operation] is only set by a start and [message] only by a failure -- the three states are every
* way an operation can be, and each carries exactly what that state knows. * way an operation can be, and each carries exactly what that state knows.
*/ */
data class ImportableChange( data class ImportableChange(
@@ -360,20 +337,18 @@ fun parseImportableChange(payload: String): ImportableChange? =
message = frame.optString("message").takeIf { it.isNotEmpty() }, message = frame.optString("message").takeIf { it.isNotEmpty() },
) )
} catch (_: org.json.JSONException) { } catch (_: org.json.JSONException) {
// A frame this build does not understand is not a reason to drop the stream: the listing // A frame this build does not understand is not a reason to drop the stream: the listing is
// is the truth and will say what happened whatever this missed. // the truth and will say what happened whatever this missed.
null null
} }
/** /**
* What a machine has that could be continued. * What a machine has that could be continued.
* *
* The slowest call this app makes, and it was the only expensive one left on the 5 second default * The slowest call this app makes, and it was the only expensive one left on the 5 second default
* which is how it came to time out against a server that was answering perfectly well. Listing * -- which is how it came to time out against a server answering perfectly well. Listing means
* means reading every transcript Claude Code has ever written: about four seconds against a * reading every transcript Claude Code has ever written: about four seconds against a gigabyte of
* gigabyte of them before the tunnel adds anything, and that figure grows with every session * them before the tunnel adds anything. A timeout is for a server that has stopped answering.
* anybody has. A timeout is for a server that has stopped answering, so it is set well clear of how
* long the work takes rather than just above it.
*/ */
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> = fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) { requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) {
@@ -385,13 +360,11 @@ fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
modified = session.optDouble("modified", 0.0), modified = session.optDouble("modified", 0.0),
lines = session.optInt("lines", 0), lines = session.optInt("lines", 0),
bytes = session.optLong("bytes", 0L), bytes = session.optLong("bytes", 0L),
// Absent means nothing has been measured -- which is not a context of zero, so // Absent means nothing has been measured, which is not a context of zero.
// it stays null and the row simply does not claim a figure.
contextTokens = contextTokens =
if (session.isNull("contextTokens")) null if (session.isNull("contextTokens")) null
else session.optLong("contextTokens").takeIf { it > 0L }, else session.optLong("contextTokens").takeIf { it > 0L },
// Absent means an older backend that cannot answer, which is exactly what // Absent means an older backend that cannot answer, which is what "unknown" says.
// "unknown" says -- so the default is the honest one rather than "no".
inUse = session.optString("inUse", "unknown"), inUse = session.optString("inUse", "unknown"),
named = session.optBoolean("named", false), named = session.optBoolean("named", false),
pending = session.optString("pending").takeIf { it.isNotEmpty() }, pending = session.optString("pending").takeIf { it.isNotEmpty() },
@@ -547,8 +520,7 @@ fun sendMessage(
* *
* Throws rather than returning an outcome, because both ways of failing are things the reader has * Throws rather than returning an outcome, because both ways of failing are things the reader has
* to be told: 409 means the session was already given it, and 404 means nothing is waiting under * to be told: 409 means the session was already given it, and 404 means nothing is waiting under
* that id. The bubble disappearing is the success case and it arrives on the event stream, not from * that id. The bubble disappearing arrives on the event stream, so every device drops it.
* here -- every device drops it, not only the one that tapped.
*/ */
fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) { fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) {
requestFromServer( requestFromServer(
@@ -562,13 +534,11 @@ fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: Strin
/** /**
* Moves a session to a different working directory. * Moves a session to a different working directory.
* *
* The server checks the directory is there on that machine and refuses if it is not -- a mistyped * The server checks the directory is there and refuses if it is not -- a mistyped path accepted
* path accepted here would surface much later, as a session that would not start, with nothing * here would surface much later, as a session that would not start.
* pointing at the typo.
* *
* Its process is **stopped**, because a working directory is settled when the process is spawned. * Its process is **stopped**, because a working directory is settled when the process is spawned.
* The next thing said to the session starts it again in the new one, which is this app's rule for a * The next thing said to the session starts it again in the new one.
* session with no process everywhere else.
*/ */
fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) { fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) {
requestFromServer( requestFromServer(
@@ -612,8 +582,8 @@ fun uploadAttachment(
write(out) write(out)
out.write(tail) out.write(tail)
}, },
// Long: a trace is hundreds of megabytes, and the server copies it on to a remote // Long: a trace is hundreds of megabytes, and the server copies it on to a remote machine
// machine before answering. // before answering.
readTimeoutMs = 600000, readTimeoutMs = 600000,
) { connection -> ) { connection ->
connection.jsonObject().getString("id") connection.jsonObject().getString("id")
@@ -624,8 +594,7 @@ fun uploadAttachment(
* One entry of a directory on the machine a setup names. * One entry of a directory on the machine a setup names.
* *
* [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link] * [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link]
* still says it is one. Neither is worked out here -- the machine answers both, because it is the * still says it is one. Neither is worked out here -- the machine answers both.
* only thing that can.
*/ */
data class DirEntry( data class DirEntry(
val name: String, val name: String,
@@ -644,10 +613,10 @@ data class Listing(val path: String, val entries: List<DirEntry>)
/** /**
* What reading a file produced. * What reading a file produced.
* *
* Four cases, because they are four different things to draw and none of them is an error the * Four cases, because they are four different things to draw and none is an error the screen can
* screen can shrug off: content, something that is not text, something too big to have sent, and * shrug off: content, something that is not text, something too big to have sent, and (as
* (as [ApiException], not a case here) the machine's own refusal. A file with nothing in it is * [ApiException]) the machine's own refusal. A file with nothing in it is [FileContent.Text] with
* [FileContent.Text] with an empty string -- which is what it is, and not the same as any of these. * an empty string, which is what it is.
*/ */
sealed class FileContent { sealed class FileContent {
abstract val path: String abstract val path: String
@@ -707,9 +676,8 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten
requestFromServer( requestFromServer(
settings, settings,
"/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}", "/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}",
// A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before // A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before any
// any of it moves. Well clear of that rather than just above it -- a timeout is for a // of it moves. Well clear of that rather than just above it.
// server that has stopped answering.
readTimeoutMs = 60000, readTimeoutMs = 60000,
) { connection -> ) { connection ->
val body = connection.jsonObject() val body = connection.jsonObject()
@@ -728,8 +696,7 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten
"binary" -> FileContent.Binary(at, size, modified) "binary" -> FileContent.Binary(at, size, modified)
"tooBig" -> FileContent.TooBig(at, size, modified) "tooBig" -> FileContent.TooBig(at, size, modified)
// A backend that has learned a fifth answer. Reported rather than guessed at: picking // A backend that has learned a fifth answer. Reported rather than guessed at: picking
// the nearest of the four would draw something confident about a state this app has // the nearest of the four would draw something confident about a state never seen.
// never seen.
else -> else ->
throw ApiException( throw ApiException(
"The server described this file as \"$kind\", which this app does not know how to show." "The server described this file as \"$kind\", which this app does not know how to show."
@@ -740,9 +707,8 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten
/** /**
* Replaces a file's contents, but only while it still hashes to [ifSha256]. * Replaces a file's contents, but only while it still hashes to [ifSha256].
* *
* The refusal is a 409 and arrives as an [ApiException] carrying the server's wording, which is * The refusal is a 409 carrying the server's wording, which is what the conflict dialog shows -- an
* what the conflict dialog shows -- an agent editing the same file while somebody reads it is the * agent editing the same file while somebody reads it is the ordinary case here.
* ordinary case here, not the exotic one.
*/ */
fun writeFile( fun writeFile(
settings: ServerSettings, settings: ServerSettings,
@@ -789,7 +755,6 @@ fun createDir(settings: ServerSettings, setup: String, path: String) {
) {} ) {}
} }
/** Fetches an image the transcript references (produced or uploaded). */
fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray = fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray =
requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) { requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) {
it.inputStream.readBytes() it.inputStream.readBytes()
@@ -798,10 +763,9 @@ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String):
// One rate-limit window, rendered as a labeled bar on the usage screen. // One rate-limit window, rendered as a labeled bar on the usage screen.
data class UsageWindow( data class UsageWindow(
/** /**
* The API's own word for which window this is -- "session" for the five-hour one. * The API's own word for which window this is -- "session" for the five-hour one. The label
* * beside it is written for a person to read, so matching on it would select nothing the day its
* How to find a particular window. The label beside it is written for a person to read, so * wording changes.
* matching on it would select nothing the day its wording changes.
*/ */
val kind: String, val kind: String,
val label: String, val label: String,
@@ -820,8 +784,8 @@ data class UsageSnapshot(
* What came back: "ok", "notLoggedIn", "unreachable" or "failed". * What came back: "ok", "notLoggedIn", "unreachable" or "failed".
* *
* Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a * Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a
* machine somebody chose not to put an account on -- a fact, not a fault -- while the other two * machine somebody chose not to put an account on -- a fact, not a fault. Collapsing them made
* are faults worth chasing. Collapsing them made a healthy setup read as broken. * a healthy setup read as broken.
*/ */
val state: String, val state: String,
/** Why, for the two states that are faults. Absent otherwise. */ /** Why, for the two states that are faults. Absent otherwise. */
@@ -837,8 +801,8 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
provider = snapshot.getString("provider"), provider = snapshot.getString("provider"),
setup = snapshot.optString("setup"), setup = snapshot.optString("setup"),
setupName = snapshot.optString("setupName"), setupName = snapshot.optString("setupName"),
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok" // Unknown to an older backend, and unknown is not "fine": defaulting to "ok" would
// would draw an empty card as a healthy one. // draw an empty card as a healthy one.
state = snapshot.optString("state").ifEmpty { "failed" }, state = snapshot.optString("state").ifEmpty { "failed" },
detail = snapshot.optString("detail").ifEmpty { null }, detail = snapshot.optString("detail").ifEmpty { null },
windows = windows =
@@ -859,8 +823,7 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
* Answers one question with everything that was chosen. * Answers one question with everything that was chosen.
* *
* A list even when one thing was picked, because that is the shape of the answer rather than a * A list even when one thing was picked, because that is the shape of the answer rather than a
* special case of it. What a provider makes of several answers is its own business and is decided * special case of it. What a provider makes of several answers is decided on the server.
* on the server; nothing here joins, splits or reformats them for one.
*/ */
fun answerQuestion( fun answerQuestion(
settings: ServerSettings, settings: ServerSettings,
@@ -887,9 +850,8 @@ fun interruptSession(settings: ServerSettings, sessionId: String) {
/** /**
* Ends the process behind a session, leaving the session and its transcript. * Ends the process behind a session, leaving the session and its transcript.
* *
* Not a delete and not an interrupt: the conversation stays exactly where it is and [startSession] * Not a delete and not an interrupt: the conversation stays where it is and [startSession] picks it
* picks it back up. The server reports what it could not do -- there was nothing running, or the * back up. The server reports what it could not do rather than answering the same way either way.
* machine would not say whether there was -- rather than answering the same way either way.
*/ */
fun stopSession(settings: ServerSettings, sessionId: String) { fun stopSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {} requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {}
@@ -907,12 +869,10 @@ fun startSession(settings: ServerSettings, sessionId: String) {
* caller confirms first; see ImportScreen. * caller confirms first; see ImportScreen.
* *
* The work runs on the server, so this returning is not the same as it being done -- what says that * The work runs on the server, so this returning is not the same as it being done -- what says that
* is each row's own state, through [fetchImportable] and the change stream. That is the point: * is each row's own state. That is the point: leaving the screen used to cancel the delete.
* leaving the screen used to cancel the delete it had started.
* *
* One request for the whole batch, which is what makes a handover all-or-nothing. Sending one per * One request for the whole batch, which is what makes a handover all-or-nothing. One per row meant
* row meant a batch could half-arrive -- four deleted, two never asked for -- and the two that were * a batch could half-arrive, and the rows that were missed looked exactly like rows not picked.
* missed looked exactly like two that had not been picked.
*/ */
fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) { fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) {
requestFromServer( requestFromServer(
@@ -929,8 +889,7 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<S
* Separate from [spawnSession] because the two are asked different questions. That one means "start * Separate from [spawnSession] because the two are asked different questions. That one means "start
* this and take me to it", so it waits and answers with the session. This is the import list's * this and take me to it", so it waits and answers with the session. This is the import list's
* batch: several at once, nobody waiting on any particular one, and the result arrives as a row * batch: several at once, nobody waiting on any particular one, and the result arrives as a row
* changing rather than as a reply -- which is what lets the screen be left. One request for all of * changing -- which is what lets the screen be left.
* them, for the reason [deleteImportable] gives.
*/ */
fun startImport( fun startImport(
settings: ServerSettings, settings: ServerSettings,
@@ -960,7 +919,7 @@ fun startImport(
* *
* One request instead of one stream frame per event. The SSE stream is the right shape for live * One request instead of one stream frame per event. The SSE stream is the right shape for live
* events and the wrong one for a backlog: opening an imported session replayed hundreds of frames * events and the wrong one for a backlog: opening an imported session replayed hundreds of frames
* before anything was readable, which looked exactly like the app loading top-down, because it was. * before anything was readable, which looked exactly like the app loading top-down.
* *
* [before] pages backwards for history somebody scrolls to; absent means the newest page. * [before] pages backwards for history somebody scrolls to; absent means the newest page.
*/ */
@@ -969,21 +928,30 @@ fun fetchTranscript(
sessionId: String, sessionId: String,
before: Long? = null, before: Long? = null,
limit: Int = 80, limit: Int = 80,
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page // Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
// of a delta-heavy conversation is a page of the screen rather than a fraction of one message. // a delta-heavy conversation is a page of the screen rather than a fraction of one message. The
// The scroll-back pager wants this; the anchor restore does not (it counts events to a known // scroll-back pager wants this; the anchor restore does not. Ignored by the server for the
// seq). Ignored by the server for the newest window, where the live cursor needs real seqs. // newest window, where the live cursor needs real seqs.
// See the server's `read_window`.
coalesce: Boolean = false, coalesce: Boolean = false,
): List<SeqEvent> { // Return nothing at or below this seq, stopping the page here instead of at [limit]. The phone
// passes the end of the run it already holds cached, so a page never overlaps that copy -- an
// overlap it cannot store, since a coalesced event cannot be cut inside its own delta run.
after: Long? = null,
): List<Pair<String, SeqEvent>> {
val query = buildString { val query = buildString {
append("?limit=").append(limit) append("?limit=").append(limit)
if (before != null) append("&before=").append(before) if (before != null) append("&before=").append(before)
if (coalesce) append("&coalesce=true") if (coalesce) append("&coalesce=true")
if (after != null) append("&after=").append(after)
} }
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection -> return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
val body = JSONArray(connection.inputStream.bufferedReader().readText()) val body = JSONArray(connection.inputStream.bufferedReader().readText())
(0 until body.length()).map { parseSeqEvent(body.getJSONObject(it).toString()) } // The text as well as the event: the transcript cache stores the one and the fold needs the
// other, and they have to be the same line.
(0 until body.length()).map {
val line = body.getJSONObject(it).toString()
line to parseSeqEvent(line)
}
} }
} }
@@ -993,7 +961,7 @@ fun fetchTranscript(
* The name is the backend's own -- it is what the list shows and it exists before any process does * The name is the backend's own -- it is what the list shows and it exists before any process does
* -- so this settles it rather than asking. Where the thing running the session has a name of its * -- so this settles it rather than asking. Where the thing running the session has a name of its
* own, the backend passes it on, which is what makes a session the same session in Claude Code's * own, the backend passes it on, which is what makes a session the same session in Claude Code's
* picker and to any other agent that lists it. * picker.
*/ */
fun renameSession(settings: ServerSettings, sessionId: String, title: String) { fun renameSession(settings: ServerSettings, sessionId: String, title: String) {
requestFromServer( requestFromServer(
@@ -1085,10 +1053,9 @@ fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Bo
requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {} requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {}
} }
// Models: what this backend has downloaded, what it is downloading, and // Models: what this backend has downloaded, what it is downloading, and what HuggingFace offers.
// what HuggingFace offers. Browsing is proxied by the server rather than // Browsing is proxied by the server rather than done here, because this app trusts exactly one
// done here, because this app trusts exactly one certificate and has no // certificate and has no general internet trust to spend on huggingface.co.
// general internet trust to spend on huggingface.co.
data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long) data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long)
@@ -29,11 +29,9 @@ import kotlinx.coroutines.withContext
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root * One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
* and the back button the only other way between them. * and the back button the only other way between them.
* *
* Import, models and setups are not here any more. They are tabs inside [MainScreen] -- four views * Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
* of the same backend, none of them a step down from another -- and what is left in this `when` is * them a step down from another -- and what is left here is only what genuinely is a step down: one
* only what genuinely is a step down: one session, spawning one, and settings. A session's own * session, spawning one, and settings.
* settings are not among them: they are a dialog over the session, which is where the thing they
* change is.
*/ */
private sealed class Screen { private sealed class Screen {
data object Main : Screen() data object Main : Screen()
@@ -43,10 +41,8 @@ private sealed class Screen {
* *
* The explorer is a layer on this screen rather than a screen of its own, so the session under * The explorer is a layer on this screen rather than a screen of its own, so the session under
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put, * it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and * and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re-
* re-created on every return, refetching the transcript over the tunnel -- which is exactly the * created on every return, refetching the transcript over the tunnel.
* flip between "what did it change" and "what is it saying" that this feature exists for. The
* image viewer already made the same choice for the same reason.
*/ */
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen() data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
@@ -60,7 +56,7 @@ private sealed class Screen {
* *
* The notification names an id and nothing else, so opening it means fetching the session first. * The notification names an id and nothing else, so opening it means fetching the session first.
* [serial] tells two taps on the same session's notification apart, since they are two requests and * [serial] tells two taps on the same session's notification apart, since they are two requests and
* would otherwise compare equal -- see MainActivity, which counts them. * would otherwise compare equal.
*/ */
data class SessionOpenRequest(val sessionId: String, val serial: Int) data class SessionOpenRequest(val sessionId: String, val serial: Int)
@@ -68,8 +64,8 @@ data class SessionOpenRequest(val sessionId: String, val serial: Int)
private data class FailedOpen(val request: SessionOpenRequest, val message: String) private data class FailedOpen(val request: SessionOpenRequest, val message: String)
/** /**
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), * [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), re-
* re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null. * reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
* *
* [openRequest] is the session a notification tap asked for, likewise from MainActivity. * [openRequest] is the session a notification tap asked for, likewise from MainActivity.
* *
@@ -89,8 +85,8 @@ fun AppRoot(
// A notification tap this could not follow, and why. Null both before one is asked for and // A notification tap this could not follow, and why. Null both before one is asked for and
// after one succeeds, since success is a screen rather than a message. // after one succeeds, since success is a screen rather than a message.
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) } var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
// Bumped whenever another screen changes something the list shows, so // Bumped whenever another screen changes something the list shows, so returning to it
// returning to it refetches instead of showing a stale list. // refetches.
var reloadToken by remember { mutableIntStateOf(0) } var reloadToken by remember { mutableIntStateOf(0) }
// Cleared by the session screen that attached it, not when a newer request arrives: a share // Cleared by the session screen that attached it, not when a newer request arrives: a share
// must be attached exactly once, and only the screen that did it knows that it has. // must be attached exactly once, and only the screen that did it knows that it has.
@@ -104,9 +100,8 @@ fun AppRoot(
} }
} }
// A standing condition rather than a per-request failure, so it is // A standing condition rather than a per-request failure, so it is stated once here instead of
// stated once here instead of appended to every error that might be // appended to every error it might cause. Without this the app is simply unreachable and every
// caused by it. Without this the app is simply unreachable and every
// screen blames the server or the tunnel for it. // screen blames the server or the tunnel for it.
if (!localNetworkAllowed(context)) { if (!localNetworkAllowed(context)) {
Text( Text(
@@ -121,8 +116,8 @@ fun AppRoot(
val current = settings val current = settings
if (current == null) { if (current == null) {
// Not enrolled yet: settings is the only usable screen. The QR // Not enrolled yet: settings is the only usable screen. The QR path lands in MainActivity
// path lands in MainActivity and recomposes from the top. // and recomposes from the top.
Box(Modifier.imePadding()) { Box(Modifier.imePadding()) {
SettingsScreen( SettingsScreen(
existing = null, existing = null,
@@ -136,10 +131,9 @@ fun AppRoot(
return return
} }
// The one way back, whichever screen is showing and whether it was // The one way back, whichever screen is showing and whether it was reached by the system back
// reached by the system back gesture or a screen's own Back button. // gesture or a screen's own Back button. Every leaf screen can have changed something the list
// Every leaf screen can have changed something the list shows, so it // shows, so it always refetches.
// always refetches.
val goToMain = { val goToMain = {
reloadToken++ reloadToken++
screen = Screen.Main screen = Screen.Main
@@ -149,8 +143,8 @@ fun AppRoot(
} }
// Turning a notification into the screen it points at. The id has to be resolved to a session // Turning a notification into the screen it points at. The id has to be resolved to a session
// first, because that is what SessionScreen is given -- and unlike a list row, which is a // first, because that is what SessionScreen is given -- and unlike a list row, there is nothing
// snapshot the list already fetched, there is nothing here to seed it from. // here to seed it from.
// //
// A failure is reported rather than swallowed: somebody deliberately tapped a notification, so // A failure is reported rather than swallowed: somebody deliberately tapped a notification, so
// an app that opens to the session list with no explanation looks like the tap missed. // an app that opens to the session list with no explanation looks like the tap missed.
@@ -180,10 +174,9 @@ fun AppRoot(
) )
} }
// Every screen but the session takes the keyboard as bottom padding here. The session // Every screen but the session takes the keyboard as bottom padding here. The session screen
// screen deliberately does not: resizing a whole screen on every frame of the keyboard // deliberately does not: resizing a whole screen on every frame of the keyboard animation is
// animation is the cost that made it lag, so it moves only its composer and transcript -- // the cost that made it lag, so it moves only its composer and transcript.
// see the layout note in SessionScreen.
when (val here = screen) { when (val here = screen) {
is Screen.Main -> is Screen.Main ->
Box(Modifier.imePadding()) { Box(Modifier.imePadding()) {
@@ -202,15 +195,14 @@ fun AppRoot(
} }
is Screen.Session -> is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this // Keyed on the id, because a different session is a different screen rather than this
// one showing other rows. SessionScreen remembers a transcript, an open event stream, a // one showing other rows. SessionScreen remembers a transcript, an open stream, a draft
// draft and a scroll position, and without the key Compose keeps all of it across the // and a scroll position, and without the key Compose keeps all of it across the change
// change and merges two conversations -- which crashes the list on the first duplicate // and merges two conversations -- which crashes the list on the first duplicate row
// row key. Only reachable since a notification can move straight from one session to // key. Only reachable since a notification can move straight from one session to
// another; every other way here passes through [Screen.Main], which disposes it anyway. // another.
key(here.summary.id) { key(here.summary.id) {
// A Box so the explorer can be drawn *over* the session rather than instead of // A Box so the explorer can be drawn *over* the session rather than instead of it.
// it; the session stays composed underneath. No imePadding here, for the reason // No imePadding here, for the reason above -- the explorer adds its own.
// above -- the explorer adds its own, since it has a text field.
Box { Box {
SessionScreen( SessionScreen(
settings = current, settings = current,
@@ -257,8 +249,7 @@ fun AppRoot(
// Last, so it draws over the screen above rather than under it: these are stacked in the Box // Last, so it draws over the screen above rather than under it: these are stacked in the Box
// the activity puts around this, and that Box paints in the order it was given. A session // the activity puts around this, and that Box paints in the order it was given. A session
// wanting attention is not a fact about the page somebody happens to be on, so it is not the // wanting attention is not a fact about the page somebody happens to be on. Tapping one is the
// page's job to leave room for it. Tapping one is the same act as tapping a notification, so // same act as tapping a notification, so it goes through the same `open`.
// it goes through the same `open`, failure dialog included.
SessionAlerts(onOpen = { request -> scope.launch { open(request) } }) SessionAlerts(onOpen = { request -> scope.launch { open(request) } })
} }
@@ -41,13 +41,12 @@ data class QuestionAnswer(val questionId: String, val answers: List<String>)
* What the reader has settled on for one question, before any of it is sent. * What the reader has settled on for one question, before any of it is sent.
* *
* Held here rather than inferred from the transcript, which is what made picking an option feel * Held here rather than inferred from the transcript, which is what made picking an option feel
* broken: the mark used to appear only when the answer had crossed the tunnel, been recorded and * broken: the mark used to appear only when the answer had crossed the tunnel and come back as an
* come back as an event, so on a phone the card sat unchanged for most of a second after a tap and * event, so the card sat unchanged for most of a second after a tap.
* the natural response was to tap again.
* *
* Picked options and typed words are one field each because they are alternatives rather than * Picked options and typed words are one field each because they are alternatives rather than
* parts: answering in the reader's own words is the case no option covers, so typing puts the picks * parts: typing puts the picks away and picking puts the words away, so there is never a draft that
* away and picking puts the words away, and there is never a draft that means two things. * means two things.
*/ */
data class Draft(val picked: Set<String> = emptySet(), val other: String = "") { data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
val settled: Boolean val settled: Boolean
@@ -65,29 +64,26 @@ data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
/** /**
* Every question one tool call is waiting on, one at a time. * Every question one tool call is waiting on, one at a time.
* *
* All of it comes from the question events themselves -- what each option means, what picking it * All of it comes from the question events themselves. None of it is read out of the call's own
* would produce, whether several may be picked at once. None of it is read out of the call's own
* input, which is one provider's JSON: parsing that here would put that provider's schema in the * input, which is one provider's JSON: parsing that here would put that provider's schema in the
* app, where no other provider can reach it and where it drifts the first time the schema moves. * app, where no other provider can reach it and where it drifts the first time the schema moves.
* *
* One question on screen with arrows to the others, rather than all of them stacked. A card asking * One question on screen with arrows to the others, rather than all of them stacked. A card asking
* three questions with four options and a description each is several screens tall, so the reader * three questions with four options and a description each is several screens tall, so the reader
* scrolls past the question they are answering to reach the button that sends it, and never sees * scrolls past the question they are answering to reach the button that sends it. Paged, each
* the whole of any one of them. Paged, each question is a screen and the count says how many are * question is a screen and the count says how many are left.
* left -- which is also what makes "not all of them are answered" something the reader can act on
* rather than something to go hunting for.
* *
* Nothing is sent until Submit. Answering is one act even when it is several questions: the tool * Nothing is sent until Submit. Answering is one act even when it is several questions: the tool
* asked them together and is waiting on all of them, and sending each as it was tapped meant the * asked them together, and sending each as it was tapped meant the reader could not change their
* reader could not change their mind about the first after reading the third. * mind about the first after reading the third.
*/ */
@Composable @Composable
fun AskUserQuestionBody( fun AskUserQuestionBody(
asks: List<TranscriptItem.QuestionCard>, asks: List<TranscriptItem.QuestionCard>,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit, onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
) { ) {
// Seeded from what was already answered, so a card the reader comes back to shows their // Seeded from what was already answered, so a card the reader comes back to shows their answers
// answers rather than an empty draft over them. // rather than an empty draft over them.
var drafts by var drafts by
remember(asks.map { it.id }) { remember(asks.map { it.id }) {
mutableStateOf( mutableStateOf(
@@ -125,7 +121,7 @@ fun AskUserQuestionBody(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
// Disabled at the ends rather than absent, so the pair keeps its place and the // Disabled at the ends rather than absent, so the pair keeps its place and the
// reader can see that there is nothing further that way. // reader can see there is nothing further that way.
MarkButton("Previous question", { at-- }, enabled = at > 0) { MarkButton("Previous question", { at-- }, enabled = at > 0) {
Chevron(Pointing.Left, colour = LocalContentColor.current) Chevron(Pointing.Left, colour = LocalContentColor.current)
} }
@@ -143,7 +139,7 @@ fun AskUserQuestionBody(
if (outstanding.isNotEmpty()) { if (outstanding.isNotEmpty()) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// Greyed until every question has an answer, because the tool is waiting on all of // Greyed until every question has an answer, because the tool is waiting on all of
// them: a submit that sent two of three would leave the third one asked and the card // them: a submit that sent two of three would leave the third asked and the card
// looking dealt with. // looking dealt with.
val ready = outstanding.all { drafts[it.id]?.settled == true } val ready = outstanding.all { drafts[it.id]?.settled == true }
Button( Button(
@@ -155,8 +151,8 @@ fun AskUserQuestionBody(
} }
) { ) {
// Back to a button whatever happened. A refusal is reported by the screen // Back to a button whatever happened. A refusal is reported by the screen
// around this, and the draft is still here to send again -- a spinner // around this, and the draft is still here to send again -- a spinner that
// that never stops would be the only sign of a failure this card cannot // never stops would be the only sign of a failure this card cannot
// describe. // describe.
sending = false sending = false
} }
@@ -165,8 +161,8 @@ fun AskUserQuestionBody(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
if (sending) { if (sending) {
// In the button rather than beside it, so the row does not change height at // In the button rather than beside it, so the row does not change height at the
// the moment it is pressed. // moment it is pressed.
CircularProgressIndicator( CircularProgressIndicator(
Modifier.height(18.dp).width(18.dp), Modifier.height(18.dp).width(18.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
@@ -186,8 +182,7 @@ fun AskUserQuestionBody(
* One question: what is being asked, what can be answered, and what was. * One question: what is being asked, what can be answered, and what was.
* *
* The same body wherever a question appears -- on the call that asked it, or as a card of its own * The same body wherever a question appears -- on the call that asked it, or as a card of its own
* when nothing did. A question is the same thing either way, and two renderings of it would be two * when nothing did. Two renderings of it would be two places for an answer to go missing.
* places for an answer to go missing.
* *
* [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here * [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here
* sends anything. An answered question ignores both and draws what was answered. * sends anything. An answered question ignores both and draws what was answered.
@@ -214,10 +209,10 @@ fun AskedQuestion(
// replacing them with a line repeating it. The options are what the question *was*, and // replacing them with a line repeating it. The options are what the question *was*, and
// dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says // dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says
// very little without the three it was chosen over. Marked in the same purple that says // very little without the three it was chosen over. Marked in the same purple that says
// "picked" while the question is still open, so it is one appearance learned once. // "picked" while the question is open, so it is one appearance learned once.
val answered = ask.answers.isNotEmpty() val answered = ask.answers.isNotEmpty()
// What is marked: what was answered once there is an answer, and what the finger has // What is marked: what was answered once there is an answer, and what the finger has chosen
// chosen until then. // until then.
val marked = if (answered) ask.answers.toSet() else draft.picked val marked = if (answered) ask.answers.toSet() else draft.picked
// Null once the question is answered: the options stay and stop being pressable. // Null once the question is answered: the options stay and stop being pressable.
val onPick: ((String) -> Unit)? = val onPick: ((String) -> Unit)? =
@@ -233,9 +228,9 @@ fun AskedQuestion(
} }
} }
} }
// What was answered in the reader's own words, which no option can mark -- see // What was answered in the reader's own words, which no option can mark. Only ever the
// [OtherAnswer]. Only ever the answers that match nothing offered, so a question answered // answers that match nothing offered, so a question answered by picking says it by the
// by picking says it by the mark alone. // mark.
val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } } val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } }
if (inWords.isNotEmpty()) { if (inWords.isNotEmpty()) {
Text( Text(
@@ -252,10 +247,8 @@ fun AskedQuestion(
} }
/** /**
* [label] added to, or taken out of, what [draft] has picked. * [label] added to, or taken out of, what [draft] has picked. A single-answer question replaces
* * rather than accumulates, and either way picking puts any typed words away -- see [Draft].
* A single-answer question replaces rather than accumulates, and either way picking puts any typed
* words away -- see [Draft].
*/ */
private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft = private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
when { when {
@@ -269,8 +262,7 @@ private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
* *
* Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was * Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was
* indistinguishable from the card behind it -- three paragraphs of text where three things to press * indistinguishable from the card behind it -- three paragraphs of text where three things to press
* should have been, which is the failure a tint step routinely produces on a dark theme. A border * should have been. A border is one cue and it is unambiguous.
* is one cue and it is unambiguous.
*/ */
@Composable @Composable
private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) { private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) {
@@ -324,8 +316,8 @@ private fun Preview(preview: String) {
preview, preview,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines // Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines of
// of the thing being previewed. // the thing being previewed.
softWrap = false, softWrap = false,
modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()), modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()),
) )
@@ -336,8 +328,7 @@ private fun Preview(preview: String) {
* The choice the asker always leaves open, and the app has to as well. * The choice the asker always leaves open, and the app has to as well.
* *
* Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words * Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words
* rather than pick. Leaving it out narrows a question that was never that narrow, and the reader * rather than pick. Leaving it out narrows a question that was never that narrow.
* cannot tell that it was ever open.
*/ */
@Composable @Composable
private fun OtherAnswer(text: String, onText: (String) -> Unit) { private fun OtherAnswer(text: String, onText: (String) -> Unit) {
@@ -358,8 +349,7 @@ private fun OtherAnswer(text: String, onText: (String) -> Unit) {
* *
* A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question * A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question
* with four options showed the first one or two and dropped the rest off the side of the screen. * with four options showed the first one or two and dropped the rest off the side of the screen.
* That does not read as a bug: it reads as those having been the only choices, which is the worst * That reads as those having been the only choices.
* way for a list of choices to be wrong.
*/ */
@Composable @Composable
fun AnswerOptions( fun AnswerOptions(
@@ -379,8 +369,8 @@ fun AnswerOptions(
OutlinedButton( OutlinedButton(
onClick = { onPick?.invoke(option.label) }, onClick = { onPick?.invoke(option.label) },
// Disabled rather than removed, so an answered question still shows what it // Disabled rather than removed, so an answered question still shows what it
// offered. Material dims a disabled button's own border and label, which would // offered. Material dims a disabled button's own border and label, which would take
// take the mark with it -- both are stated here instead. // the mark with it -- both are stated here instead.
enabled = onPick != null, enabled = onPick != null,
border = border =
BorderStroke( BorderStroke(
@@ -28,8 +28,8 @@ fun attachmentName(ref: String): String = ref.substringAfter('-', ref)
/** /**
* One attachment on a sent message, drawn as what it is: an image inline, a file as its name. A * One attachment on a sent message, drawn as what it is: an image inline, a file as its name. A
* file is not fetched -- there is nothing on this phone to open a trace or a log with -- so the * file is not fetched -- there is nothing on this phone to open a trace with -- so the name is all
* name is the whole of it. * of it.
*/ */
@Composable @Composable
fun Attachment( fun Attachment(
@@ -50,8 +50,7 @@ fun Attachment(
/** /**
* A file's name, one line, in the face names are read in. Overlong names lose their middle: a name * A file's name, one line, in the face names are read in. Overlong names lose their middle: a name
* is identified by both ends -- what it is at the front, what kind at the back -- and either * is identified by both ends -- what it is at the front, what kind at the back.
* ellipsis alone takes away one of them.
*/ */
@Composable @Composable
fun FileName(name: String, modifier: Modifier = Modifier) { fun FileName(name: String, modifier: Modifier = Modifier) {
@@ -20,10 +20,8 @@ import kotlin.math.max
* to be either thrown away or rejected -- which is what "sending an image is broken" was. * to be either thrown away or rejected -- which is what "sending an image is broken" was.
* *
* Shrunk here rather than on the backend, so the bytes that never mattered are never sent: the * Shrunk here rather than on the backend, so the bytes that never mattered are never sent: the
* expensive part of this on a phone is the upload, not the decode. What the limit *is* comes from * expensive part on a phone is the upload, not the decode. What the limit *is* comes from the
* the server, per session -- see `DriverKind::max_image_edge` -- because that is where a provider's * server, per session, because that is where a provider's requirements are known.
* requirements are known, and a phone that carried its own copy of them would be a second place to
* update when one changes.
*/ */
suspend fun uploadPickedImage( suspend fun uploadPickedImage(
context: Context, context: Context,
@@ -53,17 +51,16 @@ suspend fun uploadPicked(
if (mime != null && mime.startsWith("image/")) { if (mime != null && mime.startsWith("image/")) {
return uploadPickedImage(context, settings, sessionId, uri, maxEdge) return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
} }
// Opened before the request starts, so a provider that refuses says so here and not from // Opened before the request starts, so a provider that refuses says so here and not from inside
// inside the connection; then streamed, since a trace or a log is bigger than this process // the connection; then streamed, since a trace is bigger than this process should hold at once.
// should hold at once.
val source = openSource(resolver, uri) val source = openSource(resolver, uri)
val name = displayName(resolver, uri) val name = displayName(resolver, uri)
return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out -> return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out ->
try { try {
source.use { it.copyTo(out, COPY_BUFFER) } source.use { it.copyTo(out, COPY_BUFFER) }
} catch (e: java.io.IOException) { } catch (e: java.io.IOException) {
// Either side of the copy can fail; the message names the file, which is the // Either side of the copy can fail; the message names the file, which is the part the
// part the reader can do something about. // reader can do something about.
throw ApiException("couldn't send $name: ${e.message}", cause = e) throw ApiException("couldn't send $name: ${e.message}", cause = e)
} }
} }
@@ -76,7 +73,7 @@ private const val COPY_BUFFER = 64 * 1024
* *
* A share arrives with whatever access the other app granted, and a provider that refuses says so * A share arrives with whatever access the other app granted, and a provider that refuses says so
* with a `SecurityException`; a file gone between the pick and the read is an `IOException`. Both * with a `SecurityException`; a file gone between the pick and the read is an `IOException`. Both
* are things the reader can act on, so neither is left to end the process. * are things the reader can act on.
*/ */
private fun openSource(resolver: ContentResolver, uri: Uri): java.io.InputStream = private fun openSource(resolver: ContentResolver, uri: Uri): java.io.InputStream =
try { try {
@@ -110,9 +107,9 @@ private fun displayName(resolver: ContentResolver, uri: Uri): String {
/** /**
* The bytes to upload and what they are, scaled down only if they need to be. * The bytes to upload and what they are, scaled down only if they need to be.
* *
* An image already inside the limit is uploaded exactly as it came, rather than decoded and * An image already inside the limit is uploaded exactly as it came, rather than decoded and re-
* re-encoded to the same size: a round trip through JPEG loses a little every time, and there is * encoded to the same size: a round trip through JPEG loses a little every time. This is also the
* nothing to gain from it. This is also the path a provider with no limit always takes. * path a provider with no limit always takes.
*/ */
private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteArray, String> { private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteArray, String> {
val resolver = context.contentResolver val resolver = context.contentResolver
@@ -128,9 +125,9 @@ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteA
// decision it has no business making. // decision it has no business making.
if (longest <= 0 || longest <= maxEdge) return original to mime if (longest <= 0 || longest <= maxEdge) return original to mime
// Powers of two first, which is all the decoder can do, and then the exact scale. Decoding // Powers of two first, which is all the decoder can do, and then the exact scale. Decoding the
// the full twelve megapixels only to shrink it is how this runs out of memory on the images // full twelve megapixels only to shrink it is how this runs out of memory on the images it most
// it most needs to handle. // needs to handle.
val decode = val decode =
BitmapFactory.Options().apply { BitmapFactory.Options().apply {
inSampleSize = Integer.highestOneBit(max(1, longest / maxEdge)) inSampleSize = Integer.highestOneBit(max(1, longest / maxEdge))
@@ -141,9 +138,8 @@ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteA
val matrix = Matrix() val matrix = Matrix()
if (scale < 1f) matrix.postScale(scale, scale) if (scale < 1f) matrix.postScale(scale, scale)
// The camera writes which way up the picture is into EXIF rather than rotating the pixels, and // The camera writes which way up the picture is into EXIF rather than rotating the pixels, and
// re-encoding drops the tag -- so a portrait photo would arrive at the model on its side, with // re-encoding drops the tag -- so a portrait photo would arrive at the model on its side.
// nothing anywhere saying so. Applied to the same matrix as the scale, so it costs no second // Applied to the same matrix as the scale, so it costs no second copy of the bitmap.
// copy of the bitmap.
matrix.postRotate(exifRotation(original)) matrix.postRotate(exifRotation(original))
val scaled = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true) val scaled = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
val out = ByteArrayOutputStream() val out = ByteArrayOutputStream()
@@ -166,8 +162,8 @@ private fun exifRotation(bytes: ByteArray): Float =
else -> 0f else -> 0f
} }
} catch (_: java.io.IOException) { } catch (_: java.io.IOException) {
// No EXIF, or none this can read. Upright is the assumption every // No EXIF, or none this can read. Upright is the assumption every image without the tag is
// image without the tag is displayed under anyway. // displayed under anyway.
0f 0f
} }
@@ -8,18 +8,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
// The composer's row of settings and pickers, and the menus they open. One file because the // The composer's row of settings and pickers, and the menus they open. One file because the outline
// outline and the corner are one appearance: a control shaped like this opens a surface shaped // and the corner are one appearance: a control shaped like this opens a surface shaped like this.
// like this, and a reader learns the pair once.
/** /**
* A bordered pill: a control that can be seen without being pressed. * A bordered pill: a control that can be seen without being pressed.
* *
* The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at * The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at
* all until they are touched. Three bare words sitting under the message field read as a caption * all until they are touched. Three bare words under the message field read as a caption about the
* about the field rather than as three things to press, and the only way to find out otherwise was * field rather than as three things to press. The outline says "control" without the weight of a
* to press one. The outline says "control" without the weight of a filled button, which is reserved * filled button, which is reserved for the two that act on the session.
* here for the two that act on the session (send, and start/stop).
*/ */
@Composable @Composable
fun BubbleButton( fun BubbleButton(
@@ -32,8 +30,8 @@ fun BubbleButton(
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
shape = BubbleShape, shape = BubbleShape,
// A text button's padding rather than a filled button's 24dp: these sit three across // A text button's padding rather than a filled button's 24dp: these sit three across under
// under the message field, and the wider padding is what decides whether the row fits. // the message field, and the wider padding is what decides whether the row fits.
contentPadding = ButtonDefaults.TextButtonContentPadding, contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = modifier, modifier = modifier,
) { ) {
@@ -48,7 +46,6 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50)
* The corner on a menu one of these opens. * The corner on a menu one of these opens.
* *
* A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding * A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding
* ends that tall would bow its sides. This is the roundest corner that still leaves a straight edge * ends that tall would bow its sides.
* beside a one-line option, which is the shortest menu here.
*/ */
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp) val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
@@ -26,20 +26,16 @@ import androidx.compose.ui.unit.dp
* of the operation over it. * of the operation over it.
* *
* One composable rather than a pattern each list repeats, because "this row is busy" has to look * One composable rather than a pattern each list repeats, because "this row is busy" has to look
* the same in the import list and the session list or the appearance becomes a per-screen dialect * the same in the import list and the session list or the appearance becomes a per-screen dialect.
* rather than something the reader learns once.
* *
* [label] names the operation and `null` means none is running. One parameter rather than a boolean * [label] names the operation and `null` means none is running. One parameter rather than a boolean
* beside a string, which can disagree: there is no such thing as busy with nothing happening. It is * beside a string, which can disagree. It is a *word* because a spinner alone cannot say which
* a *word* because a spinner alone cannot say which operation this is deleting and importing are * operation this is -- deleting and importing are different in kind.
* different in kind, and losing a session to the wrong one is not recoverable by waiting.
* *
* It does **not** make the row inert; the caller disables its own click handling while it passes a * It does **not** make the row inert; the caller disables its own click handling while it passes a
* label. That was the other way round at first an overlay consuming pointer events, so no caller * label. That was the other way round at first -- an overlay consuming pointer events -- and it
* had to remember and it swallowed the drag along with the tap, which meant a list could not be * swallowed the drag along with the tap, so a list could not be scrolled while anything in it was
* scrolled while anything in it was busy. Consuming taps but not drags means re-deciding what a * busy.
* gesture is above the components that already decide it; disabling the click is the platform's own
* answer and leaves the scroll where it belongs.
*/ */
@Composable @Composable
fun BusyItem(label: String?, content: @Composable () -> Unit) { fun BusyItem(label: String?, content: @Composable () -> Unit) {
@@ -71,14 +67,12 @@ fun BusyItem(label: String?, content: @Composable () -> Unit) {
/** /**
* How an item looks while it is being acted on: darker, and nearly grey. * How an item looks while it is being acted on: darker, and nearly grey.
* *
* Both, rather than either alone. Dimming by itself is what this app already used for a row on its * Both, rather than either alone. Dimming by itself is the same cue as a disabled control, so a
* way out, and it is the same cue as a disabled control, so a busy row read as one more thing that * busy row read as one more thing that could not be tapped. Draining the colour is what says the
* could not be tapped. Draining the colour is what says the row is *suspended* the status word, * row is *suspended* -- the status word and everything else that means something by its colour stop
* the accent on a warning and everything else that means something by its colour stop meaning it * meaning it for as long as the operation runs, which is exactly true.
* for as long as the operation runs, which is exactly true: none of them is being kept up to date.
* *
* Not all the way to grey. A row with no colour left is hard to find again in a list, and the * Not all the way to grey: a row with no colour left is hard to find again in a list.
* reader is watching this one.
*/ */
private fun Modifier.busy(busy: Boolean): Modifier = private fun Modifier.busy(busy: Boolean): Modifier =
if (!busy) this if (!busy) this
@@ -27,13 +27,10 @@ enum class Pointing {
* *
* One composable for all four directions rather than one per axis that differ by which coordinate * One composable for all four directions rather than one per axis that differ by which coordinate
* gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one * gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one
* direction. The shape is written once in its own coordinates, where x runs across the opening and * direction. The shape is written once in its own coordinates, and [Pointing] is only a table of
* y runs from the open side to the tip, and [Pointing] is only a table of how those two map onto * how those map onto the box.
* the box.
* *
* It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole * It draws no label of its own, so every caller owes it a `contentDescription`.
* of what assistive technology has to go on, and it is also the answer to "what was that arrow for"
* six months from now.
*/ */
@Composable @Composable
fun Chevron( fun Chevron(
@@ -30,14 +30,12 @@ import org.intellij.markdown.ast.getTextInNode
* sits on, scrolling sideways rather than wrapping. * sits on, scrolling sideways rather than wrapping.
* *
* The renderer's own fence drew the same block in plain text. The scanner that colours a tool * The renderer's own fence drew the same block in plain text. The scanner that colours a tool
* call's command colours a reply's code the same way, through [highlighted] and one palette, so a * call's command colours a reply's code the same way, so a `kotlin` fence and the Kotlin a tool
* `kotlin` fence and the Kotlin a tool wrote are the same colours. A fence in a language [scan] has * wrote are the same colours. A fence in a language [scan] has no rules for is plain rather than
* no rules for is plain rather than wrongly coloured: [fenceLanguage] answers null for those, and * wrongly coloured.
* plain is what the reader would have seen before.
* *
* Finding the code is still the library's: which children of the node are the fence markers, the * Finding the code is still the library's: which children of the node are the fence markers, the
* language word and the code between them is its knowledge of the parser, and [MarkdownCodeFence] * language word and the code between them is its knowledge of the parser.
* hands out the code and the language and leaves the drawing to the block it is given.
*/ */
@Composable @Composable
fun CodeFence( fun CodeFence(
@@ -67,14 +65,12 @@ fun CodeBlock(
/** /**
* The code inside a fence or indented block, and the highlighter's language for its info word. * The code inside a fence or indented block, and the highlighter's language for its info word.
* *
* Which children of the node are the fence markers, the language word and the code between them is * Copied from the library's `MarkdownCodeFence` rather than called: that one is a composable, and
* the library's knowledge of the parser, copied from its `MarkdownCodeFence` rather than called: * the whole point here is that [warm] can run this on a background thread and highlight the same
* that one is a composable, and the whole point of this function is that [warm] can run it on a * string the drawing will ask for. Two extractions would be two keys, and the warmed answer would
* background thread and highlight the same string the drawing will ask for. Two extractions would * be silently missed at every fence.
* be two keys, and the warmed answer would be silently missed at every fence.
* *
* Null for a fence too short to hold anything -- an unterminated one still arriving, which the * Null for a fence too short to hold anything -- an unterminated one still arriving.
* library skips as invalid.
*/ */
fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? { fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
val word = val word =
@@ -97,7 +93,6 @@ fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
* *
* The renderer's own block, less what nothing here needs: the same background, corner, padding and * The renderer's own block, less what nothing here needs: the same background, corner, padding and
* sideways scroll, without the shadow, the border and the empty pointer handler it also carried. * sideways scroll, without the shadow, the border and the empty pointer handler it also carried.
* The vertical margin is the renderer's too, kept so a reply's fences sit where they always have.
*/ */
@Composable @Composable
private fun CodeBlockText( private fun CodeBlockText(
@@ -117,8 +112,7 @@ private fun CodeBlockText(
.semantics { isTraversalGroup = true } .semantics { isTraversalGroup = true }
) { ) {
BasicText( BasicText(
// No language while the block is still being written, which is what draws it plain; // No language while the block is still being written, which is what draws it plain.
// see [MarkdownRoot]'s `streaming`.
replies.highlighted(code, language.takeUnless { streaming }), replies.highlighted(code, language.takeUnless { streaming }),
style = style, style = style,
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock), modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
@@ -141,14 +135,12 @@ fun fenceLanguage(name: String?): Language? =
* The highlighter's language for a *file*, from its name. * The highlighter's language for a *file*, from its name.
* *
* The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people * The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people
* write after the backticks -- `kt`, `rs`, `py` -- because the extension is as often what gets * write after the backticks. One table rather than two, so a language added for fences is a
* written there as the language's name. One table rather than two, so a language added for fences * language added for files and neither can be the one somebody forgot.
* is a language added for files and neither can be the one somebody forgot.
* *
* The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin and * The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin. A
* `Cargo.toml` TOML. A leading dot is not one: `.bashrc` has no extension, it has a name that * leading dot is not one: `.bashrc` has no extension, it has a name that starts with a dot. A name
* starts with a dot, and reading `bashrc` as an extension would look up a word no table has. A name * with no dot at all -- `Makefile` -- is likewise null, and null is drawn plain.
* with no dot at all -- `Makefile`, `LICENSE` -- is likewise null, and null is drawn plain.
*/ */
fun fileLanguage(name: String): Language? { fun fileLanguage(name: String): Language? {
val dot = name.lastIndexOf('.') val dot = name.lastIndexOf('.')
@@ -206,10 +198,9 @@ private val FENCE_LANGUAGES: Map<String, Language> =
) )
/** /**
* Every fence in [parse], as the code and language [highlight] will be asked for. * Every fence in [parse], as the code and language [highlight] will be asked for. Walks the whole
* * tree rather than the top level: a fence inside a list item or a quote is drawn the same way and
* Walks the whole tree rather than the top level: a fence inside a list item or a quote is drawn * costs the same to lex.
* the same way and costs the same to lex.
*/ */
fun fences(parse: State): List<Pair<String, Language?>> { fun fences(parse: State): List<Pair<String, Language?>> {
val success = parse as? State.Success ?: return emptyList() val success = parse as? State.Success ?: return emptyList()
@@ -25,8 +25,7 @@ import androidx.compose.ui.unit.dp
* These are the two this app understands, and understanding them is what lets it show them: a * These are the two this app understands, and understanding them is what lets it show them: a
* suggestion while one is being typed, a name in the settings screen that sends one, and a bubble * suggestion while one is being typed, a name in the settings screen that sends one, and a bubble
* that stays up while the session is too busy to run it. Anything else beginning with "/" is passed * that stays up while the session is too busy to run it. Anything else beginning with "/" is passed
* through to whatever runs the session, because a dialect's own vocabulary is its own and grows * through, because a dialect's own vocabulary grows without this list.
* without this list -- it just arrives unannounced and unexplained.
*/ */
data class SessionCommand( data class SessionCommand(
/** With the slash, as it is typed and as it is sent. */ /** With the slash, as it is typed and as it is sent. */
@@ -90,8 +89,8 @@ fun CommandSuggestions(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
// The command in the colour commands are, so the suggestion and the // The command in the colour commands are, so the suggestion and the bubble
// bubble it becomes are visibly the same thing. // it becomes are visibly the same thing.
if (command.argument == null) command.name if (command.argument == null) command.name
else "${command.name} <${command.argument}>", else "${command.name} <${command.argument}>",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
@@ -117,8 +116,7 @@ fun CommandSuggestions(
* anything appearing here. * anything appearing here.
* *
* [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a * [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a
* reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes * reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes.
* and reads as having been missed.
*/ */
@Composable @Composable
fun CommandBubble(text: String, waiting: Boolean = false) { fun CommandBubble(text: String, waiting: Boolean = false) {
@@ -128,8 +126,8 @@ fun CommandBubble(text: String, waiting: Boolean = false) {
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp), modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
) { ) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
// Stated beside the fill rather than inherited: a semantic colour has to carry // Stated beside the fill rather than inherited: a semantic colour has to carry its
// its own contrast, because the surface under it will not change to rescue it. // own contrast, because the surface under it will not change to rescue it.
Text(text, color = MaterialTheme.colorScheme.inverseOnSurface) Text(text, color = MaterialTheme.colorScheme.inverseOnSurface)
if (waiting) { if (waiting) {
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
@@ -7,12 +7,10 @@ import androidx.compose.ui.Modifier
* The mark a compaction leaves in the transcript. * The mark a compaction leaves in the transcript.
* *
* A divider rather than something anybody said: everything above it is out of the session's context * A divider rather than something anybody said: everything above it is out of the session's context
* now, and that is a fact about the conversation, not a turn in it. It has no collapsed form -- it * now, and that is a fact about the conversation, not a turn in it. Drawn by [TranscriptDivider],
* is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which * which a clear also uses, so the two marks cannot drift apart.
* a clear also uses, so the two marks cannot drift apart.
* *
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it, * Blue is [commandColor]: the session acting on itself rather than working on what was asked of it.
* which is the same thing the status line says while the compaction runs.
*/ */
@Composable @Composable
fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) { fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) {
@@ -23,9 +21,8 @@ fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifi
* What to say about a compaction: the two sizes, and nothing else. * What to say about a compaction: the two sizes, and nothing else.
* *
* The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer * The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer
* to why the wait was worth it -- and they are all this says, because a divider is read in passing. * to why the wait was worth it. When they were not reported this says only that a compaction
* When they were not reported this says only that a compaction happened, rather than filling in a * happened, rather than filling in a plausible number.
* plausible number or explaining at length what was missing.
*/ */
fun compactionSummary(item: TranscriptItem.CompactedNote): String { fun compactionSummary(item: TranscriptItem.CompactedNote): String {
val pre = item.preTokens val pre = item.preTokens
@@ -41,8 +38,8 @@ fun compactionSummary(item: TranscriptItem.CompactedNote): String {
* A token count as a reader reads one. * A token count as a reader reads one.
* *
* Shared with the status row rather than formatted at each: the divider and the row report the same * Shared with the status row rather than formatted at each: the divider and the row report the same
* quantity about the same moment, and one of them grouping its thousands while the other did not * quantity about the same moment, and one grouping its thousands while the other did not read as
* read as two different measurements. * two different measurements.
*/ */
fun tokens(count: Long): String = "%,d".format(count) fun tokens(count: Long): String = "%,d".format(count)
@@ -50,15 +47,12 @@ fun tokens(count: Long): String = "%,d".format(count)
* What the working indicator says while a compaction is running. * What the working indicator says while a compaction is running.
* *
* Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a * Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a
* compaction has begun and then says nothing until it has finished, so any bar, percentage or * compaction has begun and then says nothing until it has finished, so any bar or estimate here
* estimate here would be this screen's guess wearing a measurement's clothes. Knowing it has been * would be this screen's guess wearing a measurement's clothes.
* going forty seconds is what a reader actually wants -- it is the difference between waiting and
* going to look at why.
* *
* [seconds] is null when this device did not see the compaction start, which is what opening a * [seconds] is null when this device did not see the compaction start, which is what opening a
* session that is already compacting looks like. That case says only "compacting": no number is the * session that is already compacting looks like. That case says only "compacting": a number counted
* honest answer, and a number counted from the moment the screen opened would be wrong in the * from the moment the screen opened would be wrong in the direction that matters.
* direction that matters, since a compaction somebody is asking about is a long one.
*/ */
fun compactingLabel(seconds: Long?): String = fun compactingLabel(seconds: Long?): String =
when { when {
@@ -12,10 +12,9 @@ import java.util.Locale
* The last crash, kept so the debug button can hand it over. * The last crash, kept so the debug button can hand it over.
* *
* The alternative is asking somebody to reproduce a crash with the phone plugged into a computer * The alternative is asking somebody to reproduce a crash with the phone plugged into a computer
* and `logcat` running, which is the one thing nobody has set up at the moment it happens -- and a * and `logcat` running, which is the one thing nobody has set up at the moment it happens. This
* crash report that arrives a day later, without the stack, is a guess. This costs one file write * costs one file write on a process that is already dying, and it turns "it crashes when I open
* on a process that is already dying, and it turns "it crashes when I open that chat" into the * that chat" into the frame it crashed in.
* frame it crashed in.
* *
* Kept until it is read rather than cleared on the next launch: the app restarts before anybody can * Kept until it is read rather than cleared on the next launch: the app restarts before anybody can
* ask about it, so a log that lives for one session is a log that is never read. * ask about it, so a log that lives for one session is a log that is never read.
@@ -26,8 +25,7 @@ private const val CRASH_FILE = "last-crash.txt"
* How much of a stack is kept. * How much of a stack is kept.
* *
* This is pasted into a conversation, so it has a budget like any other output written for a * This is pasted into a conversation, so it has a budget like any other output written for a
* reader. The top of a stack is what identifies a crash and the bottom is framework plumbing, so * reader. The top of a stack is what identifies a crash and the bottom is framework plumbing.
* what gets cut is the part nobody reads.
*/ */
private const val CRASH_LIMIT = 4000 private const val CRASH_LIMIT = 4000
@@ -35,8 +33,7 @@ private const val CRASH_LIMIT = 4000
* Records uncaught exceptions, then lets the platform do what it was going to do. * Records uncaught exceptions, then lets the platform do what it was going to do.
* *
* Chained rather than replacing: the default handler is what shows the "app has stopped" dialog and * Chained rather than replacing: the default handler is what shows the "app has stopped" dialog and
* ends the process, and an app that swallows that instead sits there in an unknown state. This only * ends the process, and an app that swallows that instead sits there in an unknown state.
* adds a witness.
*/ */
fun installCrashLog(context: Context) { fun installCrashLog(context: Context) {
val app = context.applicationContext val app = context.applicationContext
@@ -12,10 +12,9 @@ import java.util.concurrent.atomic.AtomicLong
* *
* Here because the emulator cannot answer the question this is for. Its own scroll sits at the same * Here because the emulator cannot answer the question this is for. Its own scroll sits at the same
* frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost * frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost
* is under the floor of what it can measure, and a frame number taken in it says nothing about a * is under the floor of what it can measure. Counts do not have that problem: how many times a row
* 120Hz phone. Counts do not have that problem: how many times a row was composed, or a reply * was composed, or a reply parsed, is the same number on any machine, and it is the number that
* parsed, is the same number on any machine, and it is the number that says whether the work is * says whether the work is proportional to what is on screen or to everything ever loaded.
* proportional to what is on screen or to everything ever loaded.
* *
* Always on rather than behind a build flag. What is measured is an atomic increment on paths that * Always on rather than behind a build flag. What is measured is an atomic increment on paths that
* already allocate lists and parse markdown, and a counter that is only compiled into the build * already allocate lists and parse markdown, and a counter that is only compiled into the build
@@ -90,14 +89,12 @@ object DebugStats {
* *
* The draw phase is where Compose's measurement lands as well as its recording -- the platform * The draw phase is where Compose's measurement lands as well as its recording -- the platform
* calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three * calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three
* different things is high. The transcript times its own measure, its own placement and its own * different things is high. The transcript times its own measure, placement and recording, and this
* recording, and this is the subtraction that was otherwise done by hand in a conversation every * is the subtraction. What is left over is the framework's per-frame bookkeeping after a layout,
* time a report arrived. What is left over is the framework's per-frame bookkeeping after a layout, * which grows with how many nodes are alive rather than how many are on screen.
* which grows with how many nodes are alive rather than with how many are on screen.
* *
* Per frame rather than in total, because the budget it has to fit in is per frame. The recordings * Per frame rather than in total, because the budget it has to fit in is per frame. The recordings
* are not themselves per-frame -- a measurement happens on the frames that need one -- so these are * are not themselves per-frame, so these are shares of an average frame.
* shares of an average frame, not a claim about any particular one.
*/ */
fun drawAccounting(drawNanos: Long, frames: Int): List<String> { fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
if (frames == 0 || drawNanos == 0L) return emptyList() if (frames == 0 || drawNanos == 0L) return emptyList()
@@ -122,8 +119,7 @@ fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
* frames went, and what the app did to produce them. * frames went, and what the app did to produce them.
* *
* Written for somebody to paste into a conversation, so it is plain text with the units on every * Written for somebody to paste into a conversation, so it is plain text with the units on every
* number -- a report whose reader has to ask what the columns mean costs another round trip, and * number -- a report whose reader has to ask what the columns mean costs another round trip.
* the whole point of it is to save one.
*/ */
fun debugReport( fun debugReport(
device: String, device: String,
@@ -21,11 +21,7 @@ import androidx.compose.ui.unit.dp
* reader scrolling back, both mean "the session no longer has what is above this", and which of the * reader scrolling back, both mean "the session no longer has what is above this", and which of the
* two it was is said by the words and the colour. * two it was is said by the words and the colour.
* *
* The rules take [color] too, so the whole divider reads as one mark of one kind rather than a * The rules take [color] too, so the whole divider reads as one mark of one kind.
* coloured phrase sitting in an unrelated grey line.
*
* Written once here rather than styled at each of them, so the two cannot drift into looking like
* different kinds of thing.
*/ */
@Composable @Composable
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) { fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
@@ -44,9 +40,8 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
* The mark a clear leaves. * The mark a clear leaves.
* *
* Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a * Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a
* compaction it summarises nothing and measures nothing, so there is nothing to report but the * compaction it summarises nothing and measures nothing. Everything above stays on screen and stays
* fact. Everything above stays on screen and stays scrollable -- the reader can see that, which is * scrollable -- the reader can see that, which is why this does not say it.
* why this does not say it.
*/ */
@Composable @Composable
fun ClearedRow(modifier: Modifier = Modifier) { fun ClearedRow(modifier: Modifier = Modifier) {
@@ -10,12 +10,11 @@ private const val DRAFTS = "session-drafts"
* *
* On this device rather than on the backend, which is where this app otherwise keeps state so that * On this device rather than on the backend, which is where this app otherwise keeps state so that
* every device sees it. A draft is the case that rule is not about: it is the contents of a text * every device sees it. A draft is the case that rule is not about: it is the contents of a text
* box on the phone somebody is holding, written on every keystroke, and half a sentence surfacing * box on the phone somebody is holding, and half a sentence surfacing on another device would be a
* on another device would be a surprise rather than a convenience. What has been *sent* is the * surprise. What has been *sent* is the server's.
* server's, and that is the part which has to outlive this phone.
* *
* Kept per session id, because the thing being typed belongs to the conversation it is aimed at: * Kept per session id: one shared box would hand a message meant for one session to whichever was
* one shared box would hand a message meant for one session to whichever was opened next. * opened next.
*/ */
fun loadDraft(context: Context, sessionId: String): String = fun loadDraft(context: Context, sessionId: String): String =
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).getString(sessionId, "").orEmpty() context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).getString(sessionId, "").orEmpty()
@@ -23,11 +22,9 @@ fun loadDraft(context: Context, sessionId: String): String =
/** /**
* Records [text] as the draft for [sessionId], or forgets it when there is nothing left to keep. * Records [text] as the draft for [sessionId], or forgets it when there is nothing left to keep.
* *
* The path out is emptying the box, which is what sending does -- so a sent message removes its own * The path out is emptying the box, which is what sending does. A session *deleted* while it held a
* entry and nothing accumulates for a session in ordinary use. A session *deleted* while it held a * draft does leave its key behind: pruning those means a pass over the live session list, and the
* draft does leave its key behind: pruning those means a pass over the live session list, which * residue is a few bytes per session ever abandoned mid-sentence.
* this file would otherwise have no reason to know about, and the residue is a few bytes per
* session ever abandoned mid-sentence. That is a trade rather than an oversight.
*/ */
fun saveDraft(context: Context, sessionId: String, text: String) { fun saveDraft(context: Context, sessionId: String, text: String) {
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).edit { context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).edit {
@@ -5,13 +5,12 @@ package com.example.aiapp
* *
* A tool's timeout arrives as `480000`, which nobody reads as eight minutes. The rule has two * A tool's timeout arrives as `480000`, which nobody reads as eight minutes. The rule has two
* halves, because a short span and a long one are read for different things. Under a minute the * halves, because a short span and a long one are read for different things. Under a minute the
* question is "roughly how long", so only the largest unit is shown and a fraction of it carries * question is "roughly how long", so only the largest unit is shown and a fraction carries the rest
* the rest -- `2.5s`, `30ms`. At a minute or more the question is "how long exactly", so every unit * -- `2.5s`. At a minute or more the question is "how long exactly", so every unit with something
* that has something in it is written out -- `5d 12h 4m`. Units that are empty are left out rather * in it is written out -- `5d 12h 4m`. Empty units are left out rather than written as zero.
* than written as zero, since the labels say which is which and `5d 0h 4m` is only longer.
* *
* Sub-second precision is dropped past a minute: nothing that takes days is measured in * Sub-second precision is dropped past a minute: nothing that takes days is measured in
* milliseconds, and carrying them would make the common case the widest one. * milliseconds.
*/ */
fun formatMillis(ms: Long): String { fun formatMillis(ms: Long): String {
if (ms < 0) return "-" + formatMillis(-ms) if (ms < 0) return "-" + formatMillis(-ms)
@@ -12,7 +12,7 @@ private const val RESET_EVENT = "reset"
* *
* The connection and its framing belong to [Sse]; what stays here is what this stream's frames * The connection and its framing belong to [Sse]; what stays here is what this stream's frames
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it * mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
* saw as the new cursor. See SessionScreen. * saw as the new cursor.
*/ */
class EventStream(settings: ServerSettings, private val sessionId: String) { class EventStream(settings: ServerSettings, private val sessionId: String) {
private val stream = Sse(settings) private val stream = Sse(settings)
@@ -24,15 +24,21 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
* *
* [onReset] fires when the server answers that the cursor is too far behind to continue from: * [onReset] fires when the server answers that the cursor is too far behind to continue from:
* everything already displayed is stale and the events that follow are a fresh window, so the * everything already displayed is stale and the events that follow are a fresh window, so the
* caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It * caller drops what it holds and rebuilds. It arrives before those events, so a caller that
* arrives before those events, so a caller that clears on it stays in order. * clears on it stays in order.
*/ */
fun run(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) { fun run(
after: Long,
onOpen: () -> Unit,
onReset: () -> Unit,
// The frame's own text as well as the event parsed from it: the transcript cache stores the
// one and the screen folds the other, and they have to be the same line.
onEvent: (raw: String, event: SeqEvent) -> Unit,
) {
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data -> stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name, so this is one or // A named frame carries no payload and a data frame has no name.
// the other.
if (name == RESET_EVENT) onReset() if (name == RESET_EVENT) onReset()
else if (data.isNotEmpty()) onEvent(parseSeqEvent(data)) else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
} }
} }
} }
@@ -2,10 +2,9 @@ package com.example.aiapp
import org.json.JSONObject import org.json.JSONObject
// The common event model, mirrored from server/src/session/driver.rs -- // The common event model, mirrored from server/src/session/driver.rs -- the app renders purely from
// the app renders purely from this stream (replayed from the transcript by // this stream (replayed from the transcript by cursor, then live), so there is no separate "load
// cursor, then live), so there is no separate "load history" shape to keep // history" shape to keep in sync with it.
// in sync with it.
/** One transcript line: the event plus its resume cursor and time. */ /** One transcript line: the event plus its resume cursor and time. */
data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent) data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
@@ -13,8 +12,7 @@ data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
/** /**
* One choice offered in answer to a question. * One choice offered in answer to a question.
* *
* More than a label because the reader is deciding rather than confirming: what an option means, * More than a label because the reader is deciding rather than confirming. Both are absent on a
* and what picking it would produce, are the things that decide it. Both are absent on a
* permission, whose Allow and Deny mean exactly what they say. * permission, whose Allow and Deny mean exactly what they say.
*/ */
data class QuestionOption(val label: String, val description: String?, val preview: String?) data class QuestionOption(val label: String, val description: String?, val preview: String?)
@@ -30,13 +28,12 @@ sealed class SessionEvent {
*/ */
val id: String?, val id: String?,
/** /**
* What was attached to it, by the ref the files route serves: images, and since 2026-09-03 * What was attached to it, by the ref the files route serves: images, and any file, told
* any file, told apart by [isImageRef]. * apart by [isImageRef].
* *
* On the message rather than beside it: these arrived as separate image events until * On the message rather than beside it: these arrived as separate image events until
* 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent * 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent
* it, and left this app deciding from adjacency alone which message an image went with -- * it, and left this app deciding from adjacency which message an image went with.
* something the sender knew and could simply have said.
*/ */
val attachments: List<String>, val attachments: List<String>,
) : SessionEvent() ) : SessionEvent()
@@ -45,11 +42,10 @@ sealed class SessionEvent {
* A message the server has accepted and the session has not read yet. * A message the server has accepted and the session has not read yet.
* *
* From the server, not from this app's memory of what it sent. The pending bubble used to be * From the server, not from this app's memory of what it sent. The pending bubble used to be
* screen state, so leaving the session or restarting the app drew nothing waiting while the * screen state, so leaving the session drew nothing waiting while the message was still queued
* message was still queued -- and nothing waiting is what "there is nothing" looks like. * -- and nothing waiting is what "there is nothing" looks like.
* *
* Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by * Resolved by the [UserMessage] carrying the same id.
* [CommandSent].
*/ */
data class MessageQueued(val id: String, val text: String, val attachments: List<String>) : data class MessageQueued(val id: String, val text: String, val attachments: List<String>) :
SessionEvent() SessionEvent()
@@ -59,8 +55,7 @@ sealed class SessionEvent {
* *
* Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects * Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects
* replays both, and without this one it would put back a bubble for a message that is never * replays both, and without this one it would put back a bubble for a message that is never
* coming -- with nothing left to resolve it, since the [UserMessage] that normally does is * coming.
* exactly what was cancelled.
*/ */
data class MessageDropped(val id: String) : SessionEvent() data class MessageDropped(val id: String) : SessionEvent()
@@ -108,17 +103,14 @@ sealed class SessionEvent {
* *
* The live Claude Code path only learns a turn was somebody else's when the turn ends, so * The live Claude Code path only learns a turn was somebody else's when the turn ends, so
* the event arrives below everything it caused; this is what puts it back above it. Null * the event arrives below everything it caused; this is what puts it back above it. Null
* for a message read out of a session file, which is already in the right place, and for * for a message read out of a session file, and for one that started no turn.
* one that started no turn. See the server's `Event::PeerMessage`.
*/ */
val turnStart: Long? = null, val turnStart: Long? = null,
) : SessionEvent() ) : SessionEvent()
/** /**
* A command the session was asked to run on itself and cannot run yet. * A command the session was asked to run on itself and cannot run yet. Resolved by
* * [CommandSent] with the same id; a command that ran straight away has only that one.
* Resolved by [CommandSent] with the same id. A command that ran straight away has only that
* one, so nothing here ever draws a bubble that resolves in the same frame.
*/ */
data class CommandQueued(val id: String, val text: String) : SessionEvent() data class CommandQueued(val id: String, val text: String) : SessionEvent()
@@ -130,29 +122,26 @@ sealed class SessionEvent {
/** /**
* What the session is set to, as the session itself reports it. * What the session is set to, as the session itself reports it.
* *
* Either field alone: the two are confirmed separately and by different things. Asking for a * Either field alone: the two are confirmed separately. Asking for a change is not having one,
* change is not having one, so this -- not the request -- is what the pickers show. * so this -- not the request -- is what the pickers show.
*/ */
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent() data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
/** /**
* What a turn cost, and how much the model was holding when it ended. * What a turn cost, and how much the model was holding when it ended.
* *
* [context] is prompt plus both cache figures, measured by the backend from the turn's own * [context] is prompt plus both cache figures. Carried on the event rather than summed by the
* usage. Carried on the event rather than summed by the reader, because it is not a sum: a * reader, because it is not a sum: a conversation's context drops at a compaction and a clear,
* conversation's context drops at a compaction and a clear, so adding turns up would report a * so adding turns up would report a figure the session stopped being true of. Null where the
* figure the session stopped being true of. Null where the dialect did not say, and on entries * dialect did not say, which leaves the context unmeasured rather than unchanged.
* recorded before the backend sent it -- which leaves the context unmeasured rather than
* unchanged.
*/ */
data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent() data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
/** /**
* A compaction that finished, and how much context it recovered. * A compaction that finished, and how much context it recovered.
* *
* The counts are nullable because the server sends them only when it was told them: a * The counts are nullable because the server sends them only when it was told them: a zero here
* compaction whose size nobody measured has to be able to say so, since a zero here would read * would read as "recovered nothing" and a made-up number would read as a measurement.
* as "recovered nothing" and a made-up number would read as a measurement.
*/ */
data class Compacted( data class Compacted(
val preTokens: Long?, val preTokens: Long?,
@@ -163,9 +152,7 @@ sealed class SessionEvent {
/** /**
* The conversation was cleared. Everything above this is still here to read and is no longer in * The conversation was cleared. Everything above this is still here to read and is no longer in
* the session's context. * the session's context. An object rather than a class because what it means is entirely its
*
* An object rather than a class because it carries nothing: what it means is entirely its
* position in the transcript. * position in the transcript.
*/ */
data object Cleared : SessionEvent() data object Cleared : SessionEvent()
@@ -173,17 +160,15 @@ sealed class SessionEvent {
data class Error(val message: String) : SessionEvent() data class Error(val message: String) : SessionEvent()
/** /**
* An event type this app build doesn't know -- a newer server. Kept (not thrown) so one new * An event type this app build doesn't know -- a newer server. Kept rather than thrown so one
* event kind degrades to a placeholder row instead of killing the stream. * new event kind degrades to a placeholder row instead of killing the stream.
*/ */
data class Unknown(val type: String) : SessionEvent() data class Unknown(val type: String) : SessionEvent()
} }
/** /**
* A JSON array of strings under [name], empty when the field is absent. * A JSON array of strings under [name], empty when the field is absent -- the ordinary case, since
* * the server omits the field rather than sending an empty list.
* Absent is the ordinary case -- most messages carry no attachment, and the server omits the field
* rather than sending an empty list -- so this is the shape every caller wants.
*/ */
private fun JSONObject.stringList(name: String): List<String> { private fun JSONObject.stringList(name: String): List<String> {
val array = optJSONArray(name) ?: return emptyList() val array = optJSONArray(name) ?: return emptyList()
@@ -212,8 +197,8 @@ fun parseSeqEvent(json: String): SeqEvent {
SessionEvent.ToolStart( SessionEvent.ToolStart(
id = body.getString("id"), id = body.getString("id"),
tool = body.getString("tool"), tool = body.getString("tool"),
// Kept as raw JSON text: the input shape is the tool's own // Kept as raw JSON text: the input shape is the tool's own business, and the UI
// business, and the UI only ever shows it verbatim. // only ever shows it verbatim.
input = body.get("input").toString(), input = body.get("input").toString(),
) )
"toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output")) "toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output"))
@@ -282,36 +267,33 @@ fun parseSeqEvent(json: String): SeqEvent {
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event) return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
} }
/**
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
*
* One predicate because two readers have to agree on the list: the session screen's working
* indicator, and the fold's decision that the newest reply is finished. Two copies would drift the
* first time the server grows a state, and the drift would be a reply that never splits or one
* split mid-stream.
*/
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
/** /**
* The context after [event], given what it was before. * The context after [event], given what it was before.
* *
* The same rule the server folds with, because the screen has to keep up between page loads: the * The same rule the server folds with, because the screen has to keep up between page loads: the
* summary it opened with is a measurement from before this stream started, and every event that * summary it opened with is a measurement from before this stream started.
* moves the figure arrives here.
* *
* The two that lower it are the point. A clear takes the conversation away and a compaction * The two that lower it are the point. A clear takes the conversation away and a compaction
* replaces it with a summary, so a figure measured before either stopped being true at that moment * replaces it with a summary, so a figure measured before either stopped being true at that moment
* -- and carrying it forward is how a session that had just been cleared went on reporting the * -- and carrying it forward is how a session that had just been cleared went on reporting the
* context it no longer had. * context it no longer had.
* *
* Null is "we don't know", which is a state each of them can reach: nothing measured yet, a * Null is "we don't know", which each of them can reach.
* compaction that finished without saying how much it recovered, or a clear nobody has run a turn
* since.
*/ */
/**
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
*
* One predicate because two readers have to agree on the list: the session screen's working
* indicator, and the fold's decision that the newest reply is finished
* ([TranscriptItem.AssistantMsg.settled]). Two copies would drift the first time the server grows a
* state, and the drift would be a reply that never splits or one split mid-stream.
*/
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
fun contextAfter(current: Long?, event: SessionEvent): Long? = fun contextAfter(current: Long?, event: SessionEvent): Long? =
when (event) { when (event) {
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a // Falls back to what we had, so a turn the dialect reported no usage for is stale by a turn
// turn -- which every context figure is -- rather than unknown. // -- which every context figure is -- rather than unknown.
is SessionEvent.UsageDelta -> event.context ?: current is SessionEvent.UsageDelta -> event.context ?: current
is SessionEvent.Compacted -> event.postTokens is SessionEvent.Compacted -> event.postTokens
is SessionEvent.Cleared -> null is SessionEvent.Cleared -> null
@@ -26,7 +26,7 @@ import androidx.compose.ui.text.style.TextAlign
/** /**
* The largest file this app will open in the editor, in bytes. * The largest file this app will open in the editor, in bytes.
* *
* Measured on the emulator on 2026-09-04, in a debug build, on generated Rust: * Measured on the emulator 2026-09-04, in a debug build, on generated Rust:
* *
* | file | lines | scan per keystroke | worst frame record | typing | * | file | lines | scan per keystroke | worst frame record | typing |
* |--------|--------|--------------------|--------------------|-------------------| * |--------|--------|--------------------|--------------------|-------------------|
@@ -35,15 +35,13 @@ import androidx.compose.ui.text.style.TextAlign
* | 1 MB | 28,660 | -- | -- | stops responding | * | 1 MB | 28,660 | -- | -- | stops responding |
* *
* The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file * The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file
* costs 40ms a keystroke, which is noticeable and survivable, while laying the same text out in one * costs 40ms a keystroke, which is survivable, while laying the same text out in one
* `BasicTextField` costs two seconds. So switching highlighting off above a size -- which is what * `BasicTextField` costs two seconds. So switching highlighting off above a size -- what
* EXPLORER.md expected to have to decide -- would not have saved it; the cost is Compose laying out * EXPLORER.md expected to have to decide -- would not have saved it; every arrangement of a single
* one enormous text, and every arrangement of a single text field pays it. A line-by-line editor is * text field pays that cost. A line-by-line editor is the way past this.
* the way past this and is a good deal more than this feature needed.
* *
* 32 kB rather than something between it and 128 kB, because 32 kB is the largest size that was * 32 kB because it is the largest size actually measured as usable. The viewer's own limit stays
* actually measured as usable. The viewer's own limit stays the server's `FILE_LIMIT` of 1 MiB: * the server's `FILE_LIMIT` of 1 MiB: reading a big file is fine, and only editing one is not.
* reading a big file is fine, and it is only editing one that is not.
*/ */
const val EDIT_LIMIT = 32L * 1024 const val EDIT_LIMIT = 32L * 1024
@@ -53,18 +51,15 @@ const val EDIT_LIMIT = 32L * 1024
* `BasicTextField(TextFieldValue)` with a [VisualTransformation] is the one Compose arrangement * `BasicTextField(TextFieldValue)` with a [VisualTransformation] is the one Compose arrangement
* that colours a field's own text rather than replacing the field with something that only looks * that colours a field's own text rather than replacing the field with something that only looks
* like one: the transformation returns the text unchanged and the scanner's spans as styles, so * like one: the transformation returns the text unchanged and the scanner's spans as styles, so
* [OffsetMapping.Identity] is correct by construction -- no character moves, so no offset does. The * [OffsetMapping.Identity] is correct by construction. The newer `TextFieldState` API has no hook
* newer `TextFieldState` API has no hook for styles at all, which is why this is the older one. * for styles at all.
* *
* The cost is that the whole file is re-scanned on every keystroke. For a file under the server's * The cost is that the whole file is re-scanned on every keystroke, which is what [EDIT_LIMIT] is
* limit that is expected to be a few milliseconds; see EXPLORER.md's "Numbers to measure", which is * sized against.
* where a size below which highlighting is switched off would be decided if it turns out to be
* needed.
* *
* The gutter is one `Text` of `1\n2\n` beside the field rather than a number per row, because * The gutter is one `Text` of `1\n2\n` beside the field rather than a number per row, because
* there are no rows here -- the field is one text object. It stays put while the text scrolls * there are no rows here -- the field is one text object. It lines up for the same reason the
* sideways, and it lines up for the same reason the viewer's does: nothing wraps, so a logical line * viewer's does: nothing wraps, so a logical line is a visual line.
* is a visual line.
*/ */
@Composable @Composable
fun FileEditor( fun FileEditor(
@@ -12,10 +12,8 @@ import androidx.compose.ui.text.buildAnnotatedString
* and again on every recomposition. * and again on every recomposition.
* *
* Why per line at all: the viewer is a `LazyColumn` of lines rather than one `Text`, because text * Why per line at all: the viewer is a `LazyColumn` of lines rather than one `Text`, because text
* layout is linear in the text and a twenty-thousand-line file in one `Text` measures all of it to * layout is linear in the text. That means each row needs *its* colours, and the scanner answers in
* draw a screenful. That means each row needs *its* colours, and the scanner answers in offsets * offsets into the whole file -- so the spans are bucketed here, once, in one pass.
* into the whole file -- so the spans are bucketed here, once, in one pass over an already-ordered
* list, rather than each row searching the whole list for the part that is its.
*/ */
class FileLines class FileLines
private constructor( private constructor(
@@ -37,11 +35,9 @@ private constructor(
get() = lines.size get() = lines.size
/** /**
* One line, coloured. * One line, coloured. Built when the row is composed rather than up front: a file has far more
* * lines than a screen shows, and an `AnnotatedString` per line for all of them is the cost the
* Built when the row is composed rather than up front: a file has far more lines than a screen * lazy list exists to avoid.
* shows, and an `AnnotatedString` per line for all of them is the cost the lazy list exists to
* avoid.
*/ */
fun line(index: Int): AnnotatedString { fun line(index: Int): AnnotatedString {
val text = lines[index] val text = lines[index]
@@ -60,16 +56,13 @@ private constructor(
* *
* Exactly one trailing newline is dropped before splitting, so a file that ends the way * Exactly one trailing newline is dropped before splitting, so a file that ends the way
* text files are supposed to end has the number of lines its author would count -- `wc -l` * text files are supposed to end has the number of lines its author would count -- `wc -l`
* agrees, and so does every editor. Without that, every well-formed file gained a phantom * agrees. Without that, every well-formed file gained a phantom empty last line. An empty
* empty last line, which is a wrong line number on every file in the repository. An empty * file is one empty line numbered 1, which is what it is.
* file is one empty line numbered 1, which is what it is: a file with nothing in it still
* has somewhere for a cursor to go.
*/ */
fun of(text: String, language: Language?): FileLines = fun of(text: String, language: Language?): FileLines =
// Timed, and always, for the same reason everything else here is: the cost of opening // Timed, and always, for the reason everything else here is: the cost of opening a
// a large file is the number that decides whether the server's size limit is right, // large file is the number that decides whether the server's size limit is right, and
// and an instrument that is only in the build nobody is running answers nothing. It // an instrument that is only in the build nobody is running answers nothing.
// lands in the render report beside the transcript's own figures.
DebugStats.timed("file scanned and cut into lines") { DebugStats.timed("file scanned and cut into lines") {
val body = text.removeSuffix("\n") val body = text.removeSuffix("\n")
val lines = body.split('\n') val lines = body.split('\n')
@@ -80,10 +73,9 @@ private constructor(
/** /**
* How many columns a line occupies. * How many columns a line occupies.
* *
* A tab counts as eight rather than as one, and deliberately upwards: this decides how far * A tab counts as eight rather than one, and deliberately upwards: this decides how far the
* the viewer can scroll, and over-estimating leaves a little empty space past the longest * viewer can scroll, and over-estimating leaves a little empty space past the longest line
* line where under-estimating makes the end of that line unreachable. Compose draws a tab * where under-estimating makes the end of that line unreachable.
* as a single advance, so eight is the generous reading rather than the accurate one.
*/ */
private fun columnsOf(line: String): Int { private fun columnsOf(line: String): Int {
var count = 0 var count = 0
@@ -95,10 +87,9 @@ private constructor(
* The scanner's spans, in file offsets, as spans per line in line offsets. * The scanner's spans, in file offsets, as spans per line in line offsets.
* *
* One walk down both lists, which is what the scanner's guarantee buys: its spans come out * One walk down both lists, which is what the scanner's guarantee buys: its spans come out
* ordered, non-overlapping and inside the text, so a span can only belong to the line the * ordered, non-overlapping and inside the text. A span crossing a line break is cut at each
* walk has reached or to ones after it. A span crossing a line break -- a block comment, a * break and appears in each line it covers, because a row is drawn on its own and cannot
* multi-line string -- is cut at each break and appears in each line it covers, because a * inherit a colour from the row above.
* row is drawn on its own and cannot inherit a colour from the row above.
*/ */
private fun bucket(lines: List<String>, spans: List<Span>): List<List<Span>> { private fun bucket(lines: List<String>, spans: List<Span>): List<List<Span>> {
val out = ArrayList<List<Span>>(lines.size) val out = ArrayList<List<Span>>(lines.size)
@@ -50,15 +50,12 @@ fun codeStyle(): TextStyle =
/** /**
* [content] scanned off the main thread, then drawn. * [content] scanned off the main thread, then drawn.
* *
* Measured on the emulator on 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file * Measured on the emulator 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file (28,660
* (28,660 lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was * lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was first
* first written, that is 460ms of frozen screen at the size the server is willing to send -- long * written, that is 460ms of frozen screen at the size the server is willing to send -- long enough
* enough that the accessibility tree cannot be read, which is what "the app has stopped" looks like * that the accessibility tree cannot be read, which is what "the app has stopped" looks like.
* from outside. So it runs on [Dispatchers.Default] and the spinner is what the reader sees
* meanwhile, in the place the file will appear.
* *
* Keyed on the text and the language, so re-reading the same file does not rescan it and a file * Keyed on the text and the language, so re-reading the same file does not rescan it.
* that changed does.
*/ */
@Composable @Composable
fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) { fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) {
@@ -76,39 +73,31 @@ fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modif
* A file, one line per row, coloured by the same scanner that colours a reply's code fences. * A file, one line per row, coloured by the same scanner that colours a reply's code fences.
* *
* A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a * A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a
* twenty-thousand-line file in a single `Text` measures all of it to draw a screenful, and the * twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. The cost is
* scroll never recovers. The cost of the choice is that each row needs its own colours, which is * that each row needs its own colours, which is what [FileLines] works out once and off this
* what [FileLines] works out once and off this thread. * thread.
* *
* Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a * Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a
* block and a long line does not silently become three -- which would put the gutter's numbers * block and a long line does not silently become three -- which would put the gutter's numbers
* against the wrong text, the one thing a numbered listing must never do. Because nothing wraps, a * against the wrong text.
* logical line is one visual line and the two cannot drift.
* *
* **Every row is given the same content width**, and that is what makes the shared scroll state * **Every row is given the same content width**, and that is what makes the shared scroll state
* behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset * behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset
* into *its own* range -- `content width - viewport` -- so with rows of their natural widths a * into *its own* range -- `content width - viewport` -- so with rows of their natural widths a
* short line's range is zero and it never moves at all while a long one beside it does. Each row * short line's range is zero and it never moves while a long one beside it does. Each row also
* also writes `maxValue` on the shared state as it measures, so how far the file could be dragged * writes `maxValue` as it measures, so how far the file could be dragged was decided by whichever
* was decided by whichever row happened to measure last and changed as the list scrolled. Both * row measured last. Both disappear once every row is [FileLines.columns] wide. Reported by Iris on
* disappear once every row is [FileLines.columns] wide: one range, one maximum, and the file moves * 2026-09-04 as "it seems to affect different rows differently", which is what a per-row range
* as the block this comment always claimed it was. Reported by Iris on 2026-09-04 as "it seems to * looks like.
* affect different rows differently", which is exactly what a per-row range looks like.
* *
* The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box * The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box
* around the list rather than by each row. `horizontalScroll` makes its own per node otherwise, so * around the list rather than by each row -- `horizontalScroll` makes its own per node otherwise,
* only the line under the finger stretched and the rest of the file sat still beside it -- the same * so only the line under the finger stretched. Only possible because every row now has the same
* complaint as the offsets above, one layer further out. Handing every row the same effect and * range.
* rendering it once is what makes the file bend as the block it scrolls as. Only possible because
* every row now has the same range: rows that disagreed about where the end was would disagree
* about when to stretch.
* *
* The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the * The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the
* numbers out of both effects: they do not travel with the text and they do not bend with it. The * numbers out of both effects. The rows leave a spacer and [LineGutter] draws them there; its width
* rows leave a spacer where the numbers will go and [LineGutter] draws them there. Its width is * is measured from the digit count of the line count in the style it is drawn in.
* measured from the digit count of the line count in the very style it is drawn in, so a nine-line
* file and a twelve-thousand-line file each get exactly what they need and nothing is nudged by
* hand.
* *
* Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and * Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and
* copying it gives the code rather than the code with a number in front of every line. * copying it gives the code rather than the code with a number in front of every line.
@@ -139,8 +128,8 @@ fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
softWrap = false, softWrap = false,
// The scroll outside the width: the scrolling node's viewport is // The scroll outside the width: the scrolling node's viewport is
// what the row has room for, and its content is the whole file's // what the row has room for, and its content is the whole file's
// widest line. The shared effect is given to every row and // widest line. The shared effect is given to every row and rendered
// rendered by none of them -- see the box above. // by none of them -- see the box above.
modifier = modifier =
Modifier.horizontalScroll(scroll, overscroll).width(content), Modifier.horizontalScroll(scroll, overscroll).width(content),
) )
@@ -157,24 +146,20 @@ fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
* The line numbers, drawn beside the file rather than in it. * The line numbers, drawn beside the file rather than in it.
* *
* They have to be outside the box the stretch is rendered on, or they bend with the text; and they * They have to be outside the box the stretch is rendered on, or they bend with the text; and they
* have to stay exactly level with the lines they number, which is the one thing a numbered listing * have to stay exactly level with the lines they number. Those two pull in opposite directions.
* may never get wrong. Those two pull in opposite directions -- out of the list, but pinned to it.
* *
* A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come * A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come
* from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during * from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during
* measurement, so this is not composing from a value it read a frame ago, it is composing from the * measurement, so this composes from the answer the list has just produced rather than one it read
* answer the list has just produced. A `Column` translated by the scroll position could not do * a frame ago. A `Column` translated by the scroll position could not: the translation would be
* that: the translation would be a layout read and current while the set of numbers would be a * current while the set of numbers was a composition behind, so during a fling the numbers would
* composition behind it, so during a fling the numbers would slide against their lines. * slide against their lines.
* *
* The list is measured before this is -- they are siblings in a `Box` and it is declared first -- * The list is measured before this is -- they are siblings in a `Box` and it is declared first.
* and a scroll that remeasures the list on its own does so synchronously, ahead of the layout pass,
* which is the same reason a lazy list does not lag its own content.
* *
* `onSurfaceVariant`, because a number is not part of the file: it is this app numbering it, and * `onSurfaceVariant`, because a number is not part of the file. The background is painted because
* the text's own colour would put it in the same voice as the code. The background is painted * the stretch can carry the text sideways under this column, and a digit with a smear of code
* because the stretch can carry the text sideways under this column, and a digit with a smear of * behind it reads as a rendering fault.
* code behind it reads as a rendering fault.
*/ */
@Composable @Composable
private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) { private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) {
@@ -205,10 +190,9 @@ private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) {
/** /**
* How wide the widest line number is, measured rather than guessed. * How wide the widest line number is, measured rather than guessed.
* *
* `9` repeated, because digits in a monospace face are all one width and the count's own digits * `9` repeated, because digits in a monospace face are all one width -- what matters is how many
* would measure the same -- what matters is how many there are. Measuring in the style the numbers * there are. Measuring in the style the numbers are drawn in is what makes this survive a font
* are drawn in is what makes this survive a font size, a density or a display scale nobody here * size, a density or a display scale nobody here chose.
* chose.
*/ */
@Composable @Composable
fun gutterWidth(lineCount: Int, style: TextStyle): Dp { fun gutterWidth(lineCount: Int, style: TextStyle): Dp {
@@ -225,15 +209,15 @@ fun gutterWidth(lineCount: Int, style: TextStyle): Dp {
/** /**
* How wide to make every row: the widest line in the file, in this style. * How wide to make every row: the widest line in the file, in this style.
* *
* One character measured rather than the line itself, because the face is monospace -- every * One character measured rather than the line itself, because the face is monospace and measuring
* advance is the same -- and measuring the actual widest line of a twenty-thousand-line file is * the actual widest line of a twenty-thousand-line file is work for an answer arithmetic already
* work for an answer arithmetic already has. Sixty-four of them, divided, so the answer does not * has. Sixty-four of them, divided, so the answer does not carry a whole character's worth of
* carry a whole character's worth of rounding. * rounding.
* *
* Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary * Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary
* one: a minified file is a single line of a hundred thousand characters, and asking to lay that * one: a minified file is a single line of a hundred thousand characters, and laying that out as
* out as one row is a crash rather than a slow scroll. Past the cap the far end of such a line * one row is a crash rather than a slow scroll. Past the cap the far end of such a line cannot be
* cannot be reached, which is the tolerable half of that trade. * reached, which is the tolerable half of that trade.
*/ */
@Composable @Composable
private fun contentWidth(columns: Int, style: TextStyle): Dp { private fun contentWidth(columns: Int, style: TextStyle): Dp {
@@ -252,9 +236,7 @@ private fun contentWidth(columns: Int, style: TextStyle): Dp {
private const val MAX_CONTENT_PX = 100_000f private const val MAX_CONTENT_PX = 100_000f
/** /**
* The space between the numbers and the code. * The space between the numbers and the code. A gap, not an alignment: the two are already aligned
* * by the row, and this is only so the digits and the first character are not touching.
* A gap, not an alignment: the two are already aligned by the row, and this is only so the digits
* and the first character of the line are not touching.
*/ */
val GUTTER_GAP = 8.dp val GUTTER_GAP = 8.dp
@@ -45,7 +45,7 @@ import kotlinx.coroutines.withContext
* Which machine's files to show, and where to start. * Which machine's files to show, and where to start.
* *
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says * A **setup**, not a session: a filesystem is a property of a machine, and a session only says
* where it was working. That is what makes a second way in -- from the setups tab, say -- one more * where it was working. That is what makes a second way in -- from the setups tab -- one more
* caller rather than any new code here. * caller rather than any new code here.
*/ */
data class FilesTarget(val setup: String, val setupName: String, val start: String) data class FilesTarget(val setup: String, val setupName: String, val start: String)
@@ -61,13 +61,13 @@ private sealed class Spot(val path: String) {
* The files on the machine a session runs on: browse them, read one, change one. * The files on the machine a session runs on: browse them, read one, change one.
* *
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps * Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
* flowing, its draft and scroll position stay where they were, and coming back from a file costs * flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
* nothing. Back steps one level inside here -- editor to viewer, viewer to the directory it came * viewer, viewer to the directory it came from, directory to the one above -- and only closes from
* from, directory to the one above it -- and only closes from where it opened. * where it opened.
* *
* Every directory that has been visited is kept for as long as this is open, so stepping back is * Every directory that has been visited is kept for as long as this is open; the refresh glyph is
* instant; the refresh glyph is how a directory gets asked again on purpose, and creating something * how one gets asked again on purpose, and creating something refetches the directory it was
* refetches the directory it was created in, since that is the one thing that changed. * created in.
*/ */
@Composable @Composable
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) { fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
@@ -121,17 +121,16 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
Box( Box(
Modifier.fillMaxSize() Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
// The session under this deliberately takes no keyboard inset (see SessionScreen's // The session under this deliberately takes no keyboard inset, so the explorer adds its
// layout note), so the explorer adds its own -- otherwise the editor types under the // own -- otherwise the editor types under the keyboard.
// keyboard.
.imePadding() .imePadding()
) { ) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
when (val spot = here) { when (val spot = here) {
is Spot.Dir -> { is Spot.Dir -> {
val state = listings[spot.path] ?: LoadState.Loading val state = listings[spot.path] ?: LoadState.Loading
// The resolved path once there is one: a directory opened as `~` is called // The resolved path once there is one: a directory opened as `~` is called what
// what it turned out to be, not what it was asked for. // it turned out to be, not what it was asked for.
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
FilesHeader( FilesHeader(
title = baseName(at), title = baseName(at),
@@ -304,9 +303,8 @@ private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot)
* *
* A symlink says so instead of giving a size, because the size a listing reports for one is the * A symlink says so instead of giving a size, because the size a listing reports for one is the
* length of the path it points at -- a number that looks exactly like a file size and is about * length of the path it points at -- a number that looks exactly like a file size and is about
* something else entirely. `other` covers a fifo, a device, and a link whose target is gone: the * something else. `other` covers a fifo, a device, and a link whose target is gone: the row still
* row still appears, because a directory that hid what it held would be lying about being empty, * appears, because a directory that hid what it held would be lying about being empty.
* and the word is there because a colour cannot say "this is a different kind of thing".
*/ */
private fun trailingOf(entry: DirEntry): String? = private fun trailingOf(entry: DirEntry): String? =
when { when {
@@ -350,8 +348,7 @@ private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: ()
* *
* Its own composable so that everything about one file -- what came back, what has been typed, and * Its own composable so that everything about one file -- what came back, what has been typed, and
* whether a save is out -- is remembered under that file's path and thrown away when the reader * whether a save is out -- is remembered under that file's path and thrown away when the reader
* moves to another. What is *not* here is edit mode itself: back has to know about it, and back * moves to another. What is *not* here is edit mode itself: back has to know about it.
* belongs to the screen.
*/ */
@Composable @Composable
private fun ColumnScope.DocPane( private fun ColumnScope.DocPane(
@@ -423,8 +420,8 @@ private fun ColumnScope.DocPane(
onDirty(false) onDirty(false)
onEditing(false) onEditing(false)
} catch (e: ApiException) { } catch (e: ApiException) {
// The one refusal that is a question rather than a message: somebody else's edit // The one refusal that is a question rather than a message: somebody else's edit is
// is on the machine, and which of the two survives is not this app's to decide. // on the machine, and which of the two survives is not this app's to decide.
if (e.status == 409) conflict = e.message ?: "It changed on the machine." if (e.status == 409) conflict = e.message ?: "It changed on the machine."
else saveError = e.message else saveError = e.message
} finally { } finally {
@@ -467,10 +464,10 @@ private fun ColumnScope.DocPane(
) )
} }
// Why the pencil is off. A disabled control teaches what the thing can do, but it cannot say // Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it
// why it is disabled -- and a reader who cannot edit a file they can plainly read will // is disabled -- and a reader who cannot edit a file they can plainly read will otherwise
// otherwise conclude the app is broken. Said once, here, rather than waiting for a tap that a // conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button
// disabled button never receives. // never gets.
if (loaded != null && !editable) { if (loaded != null && !editable) {
Text( Text(
"Too big to edit here (${humanSize(loaded.size)}; the limit is " + "Too big to edit here (${humanSize(loaded.size)}; the limit is " +
@@ -687,6 +684,5 @@ internal fun baseName(path: String): String {
return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/') return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/')
} }
/** A resolved directory and a name in it, as one path. */
internal fun join(directory: String, name: String): String = internal fun join(directory: String, name: String): String =
if (directory.endsWith("/")) "$directory$name" else "$directory/$name" if (directory.endsWith("/")) "$directory$name" else "$directory/$name"
@@ -19,19 +19,15 @@ import androidx.compose.ui.platform.LocalContext
* The point of splitting it up is that "the scroll is laggy" has two completely different causes * The point of splitting it up is that "the scroll is laggy" has two completely different causes
* and one appearance. If the layout-and-measure and draw figures are small and the total is large, * and one appearance. If the layout-and-measure and draw figures are small and the total is large,
* the time is going into rasterising and compositing, and no amount of doing less work per row will * the time is going into rasterising and compositing, and no amount of doing less work per row will
* move it. If they are large, the work per row is the problem and it is ours to fix. Guessing * move it. If they are large, the work per row is the problem and it is ours to fix.
* between those two is how a day gets spent rewriting the half that was already fast.
* *
* The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds, * The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds,
* broken down into the parts the UI thread is responsible for -- handling input, running * broken into the parts the UI thread is responsible for and the parts after it.
* animations, measuring and laying out, recording the draw -- and the parts after it.
* *
* One of these for the app, like [DebugStats], because the two are read as one report and * One of these for the app, like [DebugStats], because the two are read as one report and
* [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session * [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session
* and the counters were not, so a report copied after visiting two sessions divided every session's * and the counters were not, so a report copied after visiting two sessions divided every session's
* work by the newest one's frame count -- and printed the result as a per-frame measurement. It * work by the newest one's frame count -- 36.8 seconds of placement inside a 13.5 second window.
* said 36.8 seconds of placement inside a 13.5 second window, and left "everything else" clamped at
* 0.00ms (0%), which reads as a screen whose whole cost is this app's own code.
*/ */
object FrameStats { object FrameStats {
private val total = ArrayList<Long>() private val total = ArrayList<Long>()
@@ -54,7 +50,7 @@ object FrameStats {
total += metrics.getMetric(FrameMetrics.TOTAL_DURATION) total += metrics.getMetric(FrameMetrics.TOTAL_DURATION)
// How long the frame waited for the UI thread to be free before it could start. Reported // How long the frame waited for the UI thread to be free before it could start. Reported
// because the phases otherwise do not add up to the total, and the gap is the interesting // because the phases otherwise do not add up to the total, and the gap is the interesting
// part: it is the frame being held up by work that is not the frame's. // part: the frame being held up by work that is not the frame's.
waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION) waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)
input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION) input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION)
animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION) animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION)
@@ -123,7 +119,7 @@ private const val CAP = 20_000
* Records into [FrameStats] for as long as this screen is on it. * Records into [FrameStats] for as long as this screen is on it.
* *
* The listener is what comes and goes; what it writes into does not, so a report covers the same * The listener is what comes and goes; what it writes into does not, so a report covers the same
* stretch of time as the counters beside it. See [FrameStats]. * stretch of time as the counters beside it.
* *
* The listener is handed its own thread because the platform calls it for every frame and the * The listener is handed its own thread because the platform calls it for every frame and the
* documentation is explicit that doing that on the main thread taxes the very thing being measured. * documentation is explicit that doing that on the main thread taxes the very thing being measured.
@@ -47,12 +47,11 @@ data class SyntaxPalette(
/** /**
* [code] with its keywords, strings and comments coloured, or plain if there is no language for it. * [code] with its keywords, strings and comments coloured, or plain if there is no language for it.
* *
* Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the same * Shared by a tool call's input and a reply's fences, so the same code is the same colours wherever
* code is the same colours wherever it appears. * it appears.
* *
* Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it * Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it
* off the drawing thread: the syntax palette is fixed, and a fence with no language is plain text * off the drawing thread.
* which needs no colour of its own -- the style the caller draws it with carries that.
* *
* The timing is the number the highlighter is judged by: the library this replaced took **174ms** * The timing is the number the highlighter is judged by: the library this replaced took **174ms**
* on the emulator for a two-hundred-line Kotlin fence, which is why [ParsedReplies.highlighted] * on the emulator for a two-hundred-line Kotlin fence, which is why [ParsedReplies.highlighted]
@@ -82,8 +81,7 @@ fun highlight(code: String, language: Language?): AnnotatedString {
* to the end of the code, which is also what it looks like while a fence is still being written. * to the end of the code, which is also what it looks like while a fence is still being written.
* *
* In ordinary code the order of recognition is comment, string, attribute, number, word, and * In ordinary code the order of recognition is comment, string, attribute, number, word, and
* finally a single punctuation or mark character. Punctuation and marks are coloured only in * finally a single punctuation or mark character, which are coloured only in ordinary code.
* ordinary code, never inside a string or a comment.
*/ */
fun scan(code: String, rules: Rules): List<Span> = Scanner(code, rules).run() fun scan(code: String, rules: Rules): List<Span> = Scanner(code, rules).run()
@@ -156,8 +154,8 @@ private class Scanner(private val code: String, private val rules: Rules) {
at += comment.open.length at += comment.open.length
var depth = 1 var depth = 1
while (at < code.length && depth > 0) { while (at < code.length && depth > 0) {
// The closer is tried first so that a language whose two delimiters are the same // The closer is tried first so that a language whose two delimiters are the same string
// string -- CoffeeScript's `###` -- closes rather than nesting forever. // -- CoffeeScript's `###` -- closes rather than nesting forever.
if (starts(comment.close)) { if (starts(comment.close)) {
depth-- depth--
at += comment.close.length at += comment.close.length
@@ -49,11 +49,9 @@ private const val DELETING = "deleting"
/** /**
* What the rows further down a batch say while they wait their turn. * What the rows further down a batch say while they wait their turn.
* *
* Its own word rather than the operation's, because it is its own state and the difference is the * Its own word rather than the operation's, because nothing has been done to this session yet, so a
* kind that matters: nothing has been done to this session yet, so a batch stopped here leaves it * batch stopped here leaves it exactly as it was. Marked from the moment the batch is handed over
* exactly as it was. Marked from the moment the batch is handed over all the same -- a queued row * all the same -- a queued row that still looked ordinary was still tappable.
* that still looked ordinary was still tappable, and tapping it would import it a second time
* behind the batch already coming for it.
*/ */
private const val WAITING = "waiting" private const val WAITING = "waiting"
@@ -62,25 +60,22 @@ private const val WAITING = "waiting"
* *
* A batch takes rows out of the list as each one lands, so everything below the one that went * A batch takes rows out of the list as each one lands, so everything below the one that went
* slides up -- and a tap already on its way then arrives at whichever row moved into that place. On * slides up -- and a tap already on its way then arrives at whichever row moved into that place. On
* this screen that means importing a session nobody chose, which is not something a second tap can * this screen that means importing a session nobody chose.
* undo.
* *
* Swallowed silently rather than shown, because anything drawn on every row a batch passes would be * Swallowed silently rather than shown, because anything drawn on every row a batch passes would be
* a flicker running down the list. Half a second: long enough to cover a tap already travelling * a flicker running down the list.
* when the row moved, short enough that it is not in the way of a deliberate one.
*/ */
private const val SETTLE_MS = 500L private const val SETTLE_MS = 500L
/** /**
* Continuing a Claude Code session the machine already has. * Continuing a Claude Code session the machine already has.
* *
* The list is the machine's answer, not this app's: it asks a setup what sessions it holds and * The list is the machine's answer, not this app's. Choosing one sends its **id**, never a path, so
* shows them. Choosing one sends its **id**, never a path, so an enrolled phone cannot turn this * an enrolled phone cannot turn this screen into a file reader.
* screen into a file reader.
* *
* Holding a row selects it and puts the screen in selection mode, where the options that act on a * Holding a row selects it and puts the screen in selection mode, where the options that act on a
* selection appear along the bottom. That exists because these arrive in bulk a machine * selection appear along the bottom. That exists because these arrive in bulk -- a machine
* accumulates dozens of abandoned sessions and one confirmation dialog per row is the reason * accumulates dozens of abandoned sessions -- and one confirmation dialog per row is the reason
* clearing them out was not worth doing. * clearing them out was not worth doing.
*/ */
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@@ -91,28 +86,25 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
var chosen by remember { mutableStateOf<Setup?>(null) } var chosen by remember { mutableStateOf<Setup?>(null) }
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) } var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
// What is happening to each row right now, as the word the row shows: "importing" or // What is happening to each row right now, as the word the row shows. A map keyed by id rather
// "deleting". A map keyed by id rather than a flag per row, because the rows are rebuilt from // than a flag per row, because the rows are rebuilt from whatever the server last said and this
// whatever the server last said and this belongs to the request rather than to the session -- // belongs to the request rather than to the session.
// the same arrangement the session list uses for its deletes.
var running by remember { mutableStateOf<Map<String, String>>(emptyMap()) } var running by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Which rows the reader has picked out. Empty means selection mode is off: there is no // Which rows the reader has picked out. Empty means selection mode is off: a selection mode
// separate flag, because a selection mode with nothing selected is a state with no controls // with nothing selected is a state with no controls in it and no way to leave except Back.
// in it and no way to leave except Back.
var selected by remember { mutableStateOf<Set<String>>(emptySet()) } var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
// Failures that belong to one row rather than to the screen, shown on that row. A batch is // Failures that belong to one row rather than to the screen, shown on that row. A batch is
// exactly where a single banner fails: nine deletes succeeded and one did not, and the // exactly where a single banner fails: nine deletes succeeded and one did not, and the banner
// banner cannot say which. // cannot say which.
var rowErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) } var rowErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows // Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
// themselves, not a flag, so the dialog can say what it is about. // themselves, not a flag, so the dialog can say what it is about.
var confirming by remember { mutableStateOf<List<Importable>?>(null) } var confirming by remember { mutableStateOf<List<Importable>?>(null) }
// Same default as the spawn screen, and for the same reason: a phone // Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty
// is the wrong place to answer "allow Bash?" forty times. // times.
var permissionMode by remember { mutableStateOf("auto") } var permissionMode by remember { mutableStateOf("auto") }
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from // When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
// it, so a tap reading it needs no recomposition and there is no timer to cancel when a // so a tap reading it needs no recomposition.
// second removal lands on top of the first.
val movedAt = remember { mutableMapOf<String, Long>() } val movedAt = remember { mutableMapOf<String, Long>() }
fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS
@@ -120,8 +112,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* Fetches the list and takes the row states from it. * Fetches the list and takes the row states from it.
* *
* Taken from the answer rather than kept across the load: the server is what knows what is * Taken from the answer rather than kept across the load: the server is what knows what is
* running, and this screen may be opening on work another screen -- or another phone -- * running, and this screen may be opening on work another phone started.
* started. Anything held locally would be a second version of that, and the stale one.
*/ */
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> = suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
try { try {
@@ -167,13 +158,11 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* Hands [targets] to the server in one request, marking every row it covers. * Hands [targets] to the server in one request, marking every row it covers.
* *
* The request only *starts* the work -- the server runs it and says how each row went on the * The request only *starts* the work -- the server runs it and says how each row went on the
* change stream, which is what lets this screen be left while a batch is still going. So there * change stream, which is what lets this screen be left while a batch is still going.
* is nothing here to wait for and nothing to sequence: the rows are marked, the batch goes, and
* everything after that arrives as an event.
* *
* Marked [WAITING] rather than with the operation's own word until the server confirms. Between * Marked [WAITING] rather than with the operation's own word until the server confirms. Between
* the request leaving and the `started` event coming back, "we have asked" is the truth and "it * the request leaving and the `started` event coming back, "we have asked" is the truth and "it
* is importing" is a guess -- and the row is inert either way, which is the part that matters. * is importing" is a guess.
* *
* The selection is dropped as the work is handed over, not when it finishes: the screen goes * The selection is dropped as the work is handed over, not when it finishes: the screen goes
* back to how it started, and what says the work is happening is the rows it is happening to. * back to how it started, and what says the work is happening is the rows it is happening to.
@@ -186,17 +175,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
val ids = targets.map { it.id } val ids = targets.map { it.id }
scope.launch { scope.launch {
// One request for the whole batch, not one per row. Sent row by row, a handover was // One request for the whole batch, not one per row. Sent row by row, a handover was
// only as atomic as the network: the fourth of six could fail, or the screen could be // only as atomic as the network, and what came back was some rows running and some
// left with two still unsent, and what came back was some rows running and some // untouched -- indistinguishable, on the list, from rows nobody had picked.
// untouched -- indistinguishable, on the list, from rows nobody had picked. Now
// either the server has the batch or it has none of it, and this is the one place
// that can be true.
try { try {
withContext(Dispatchers.IO) { send(ids) } withContext(Dispatchers.IO) { send(ids) }
} catch (err: Exception) { } catch (err: Exception) {
// The server never took it, so nothing is running and no event will arrive to say // The server never took it, so nothing is running and no event will arrive to say
// so. This is the one failure the screen must report itself -- and it is now the // so. This is the one failure the screen must report itself -- and it is the whole
// whole batch's failure, which is the point: no row was singled out. // batch's failure, which is the point: no row was singled out.
running = running - ids.toSet() running = running - ids.toSet()
rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" } rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" }
return@launch return@launch
@@ -205,19 +191,17 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// Then ask what actually happened, if anything still looks outstanding. // Then ask what actually happened, if anything still looks outstanding.
// //
// The change stream is a broadcast with no memory, so an operation that started and // The change stream is a broadcast with no memory, so an operation that started and
// finished while it was still connecting is one nothing will ever be said about -- // finished while it was still connecting is one nothing will ever be said about -- and
// and the row sits marked for ever. That is not hypothetical: with responses held // the row sits marked for ever. That is not hypothetical: with responses held back far
// back far enough for the stream to open late, one row of a pair of deletes cleared // enough, one row of a pair of deletes cleared and the other stayed on "waiting".
// and the other stayed on "waiting".
// //
// The listing is the repair, because it carries the same state the events do. Only // The listing is the repair, because it carries the same state the events do. Only when
// when something still looks outstanding, so the ordinary case -- where the events // something still looks outstanding, so the ordinary case does not pay for a second
// arrived and the rows are already gone -- does not pay for a second listing, which // listing, which is the most expensive call this screen makes.
// is the most expensive call this screen makes.
if (setup != null && targets.any { running.containsKey(it.id) }) { if (setup != null && targets.any { running.containsKey(it.id) }) {
// Quietly: no Loading, because blanking the list to report on rows that are // Quietly: no Loading, because blanking the list to report on rows that are already
// already saying what is happening to them is the flicker this screen avoids // saying what is happening to them is the flicker this screen avoids everywhere
// everywhere else. // else.
sessions = fetchInto(setup) sessions = fetchInto(setup)
} }
} }
@@ -225,13 +209,6 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" } val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
/**
* Imports [targets], and goes to the session it made when [thenOpen].
*
* One function for the tap and for the bar, differing in that one flag: continuing a session
* and then looking at it is what a tap on a row means, and a batch has several results and no
* reason to pick one of them to become the screen.
*/
/** Continues [targets] in the background, leaving the screen where it is. */ /** Continues [targets] in the background, leaving the screen where it is. */
fun importAll(targets: List<Importable>) { fun importAll(targets: List<Importable>) {
val setup = chosen ?: return val setup = chosen ?: return
@@ -283,13 +260,12 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} }
} }
// Live changes to what the server is doing to these sessions, for as long as this screen is // Live changes to what the server is doing to these sessions, for as long as this screen is up.
// up. The listing already carried the same state when the screen opened -- this is what keeps // The listing already carried the same state when the screen opened -- this is what keeps it
// it current afterwards, including for work another screen or another phone started. // current afterwards, including for work another phone started.
// //
// Failures here are deliberately quiet. There is nothing for a reader to do about a dropped // Failures here are deliberately quiet. There is nothing for a reader to do about a dropped
// event stream, and nothing is lost by one: every state it would have carried is in the next // event stream, and every state it would have carried is in the next listing.
// listing, which is what Refresh and re-entering the tab already fetch.
val liveChanges = remember { val liveChanges = remember {
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null) java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
} }
@@ -307,8 +283,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
running = running =
running + (change.session to (change.operation ?: WAITING)) running + (change.session to (change.operation ?: WAITING))
// Gone from the machine either way: a delete removed the // Gone from the machine either way: a delete removed the
// transcript, an import made it a session, and neither is // transcript, an import made it a session.
// something this list still has to offer.
"finished" -> { "finished" -> {
running = running - change.session running = running - change.session
forget(change.session) forget(change.session)
@@ -323,17 +298,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} }
} }
} catch (e: kotlinx.coroutines.CancellationException) { } catch (e: kotlinx.coroutines.CancellationException) {
// The screen leaving, not a failure -- and swallowing it would leave this // The screen leaving, not a failure -- and swallowing it would leave this loop
// loop reconnecting to a stream nobody is watching. // reconnecting to a stream nobody is watching.
throw e throw e
} catch (_: Exception) { } catch (_: Exception) {
// Retried below; the listing is the truth in the meantime. // Retried below; the listing is the truth in the meantime. Any failure, not
// // only an [ApiException]: a stream is an optimisation over the listing here,
// Any failure, not only an [ApiException]. A stream is an optimisation over // and catching only the expected failure means an unexpected one closes the app
// the listing here, so nothing it can do is worth taking the app down for -- // from a screen that is merely loading a list.
// and catching only the failure that was expected means an unexpected one
// reaches the top of the app and closes it, from a screen that is merely
// loading a list.
} finally { } finally {
stream.close() stream.close()
} }
@@ -351,9 +323,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// Nested inside MainScreen's own handler, so it wins while there is a selection. // Nested inside MainScreen's own handler, so it wins while there is a selection.
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() } BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last // Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
// row can still be scrolled to while it is up, and nothing is nudged by a number that was // can still be scrolled to while it is up.
// right for one font size.
var barHeight by remember { mutableStateOf(0.dp) } var barHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current val density = LocalDensity.current
@@ -428,8 +399,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} }
} }
// Beside nothing in particular, because a selection is not one row: the options that act // Beside nothing in particular, because a selection is not one row: the options that act on
// on it belong to the screen, and the bottom is where a thumb already is. // it belong to the screen, and the bottom is where a thumb already is.
if (selected.isNotEmpty()) { if (selected.isNotEmpty()) {
val picked = val picked =
(sessions as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty() (sessions as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
@@ -486,8 +457,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* What can be done to the rows that are selected. * What can be done to the rows that are selected.
* *
* Delete and Import only, for now: they are the two things this screen has ever done to a session, * Delete and Import only, for now: they are the two things this screen has ever done to a session,
* and an option that appears here has to work on every row in a selection rather than on the one * and an option that appears here has to work on every row in a selection.
* somebody was thinking of.
*/ */
@Composable @Composable
private fun SelectionBar( private fun SelectionBar(
@@ -567,26 +537,23 @@ private fun ImportableList(
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.combinedClickable( .combinedClickable(
// Off while something is happening to this row -- // Off while something is happening to this row -- see
// see [BusyItem], which draws that but deliberately // [BusyItem], which draws that but leaves the gestures
// leaves the gestures alone so the list still // alone so the list still scrolls.
// scrolls.
enabled = running[session.id] == null, enabled = running[session.id] == null,
onClick = { onClick = {
if (settling(session.id)) return@combinedClickable if (settling(session.id)) return@combinedClickable
// In selection mode a tap is a selection, so the // In selection mode a tap is a selection, so the
// reader is never one mis-tap away from starting // reader is never one mis-tap away from starting a
// a CLI they were only picking rows for. // CLI they were only picking rows for.
// //
// Outside it, a tap continues the session -- // Outside it, a tap continues the session -- except
// except on a row that cannot be continued, // on a row that cannot be continued, where it
// where it selects instead. That row's only // selects instead. That row's only remaining action
// remaining action is Delete, and a tap that // is Delete, and a tap that did nothing at all
// did nothing at all would be a worse answer // would be a worse answer. Two `--resume` processes
// than one that offers the thing it can do. // on one transcript each replay the other's writes,
// Two `--resume` processes on one transcript // which is why this must not simply try.
// each replay the other's writes, which is why
// this must not simply try.
if (selecting || session.inUse == "yes") if (selecting || session.inUse == "yes")
onToggle(session) onToggle(session)
else onOpen(session) else onOpen(session)
@@ -606,8 +573,7 @@ private fun ImportableList(
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
// Beside the title, because "which one was I just in" is // Beside the title, because "which one was I just in" is
// the question this list answers and the order already // the question this list answers and the order already
// reflects it -- the reader should be able to see the // reflects it.
// ordering they are being given rather than infer it.
Text( Text(
relativeTime(session.modified), relativeTime(session.modified),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -616,12 +582,11 @@ private fun ImportableList(
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
// The path first, and the only thing here that is cut: it is // The path first, and the only thing here that is cut: it is
// one long value with no natural break, where the lines below // one long value with no natural break. Cut at the head,
// it are short enough to wrap readably. Cut at the head, // because a path is identified by its tail and these all share
// because a path is identified by its tail and these all // a long prefix. By the row's real width rather than a
// share a long prefix. By the row's real width rather than a // character count, which was one guess for every font size and
// character count, which was one guess for every font size // screen.
// and screen.
session.cwd session.cwd
.takeIf { it.isNotEmpty() } .takeIf { it.isNotEmpty() }
?.let { cwd -> ?.let { cwd ->
@@ -648,8 +613,7 @@ private fun ImportableList(
color = warningColor, color = warningColor,
) )
} }
// Reported where it happened, in the server's own words, the // Reported where it happened, in the server's own words.
// way every other failure in this app is shown.
errors[session.id]?.let { message -> errors[session.id]?.let { message ->
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
@@ -673,14 +637,14 @@ private fun statsOf(session: Importable): String =
// Said, because a name and a last message are different claims: one describes the // Said, because a name and a last message are different claims: one describes the
// session, the other is only what happened last in it. // session, the other is only what happened last in it.
if (session.named) "named" else null, if (session.named) "named" else null,
// What continuing it costs, which is the question this list is really asked. First // What continuing it costs, which is the question this list is really asked. Absent
// of the measurements for that reason, and absent rather than zero when nothing has // rather than zero when nothing has been measured -- a session with no turns yet has no
// been measured -- a session with no turns yet has no figure, not a figure of none. // figure, not a figure of none.
session.contextTokens?.let { "${it / 1000}k context" }, session.contextTokens?.let { "${it / 1000}k context" },
"${session.lines} lines", "${session.lines} lines",
// Kept beside the context figure because the two disagree usefully: most of a large // Kept beside the context figure because the two disagree usefully: most of a large
// transcript is history from before a compaction, which the model is no longer // transcript is history from before a compaction, so a big file can be cheap to
// given, so a big file can be cheap to continue and a small one expensive. // continue.
humanSize(session.bytes), humanSize(session.bytes),
) )
.joinToString(" · ") .joinToString(" · ")
@@ -689,16 +653,14 @@ private fun statsOf(session: Importable): String =
* Why this session might not be safe to take, if it isn't. * Why this session might not be safe to take, if it isn't.
* *
* Words rather than only a colour: "open somewhere else" and "we could not check" differ in kind, * Words rather than only a colour: "open somewhere else" and "we could not check" differ in kind,
* and no shade distinguishes them. The colour is what makes it findable; the words are what make it * and no shade distinguishes them.
* actionable.
*/ */
private fun warningOf(session: Importable): String? = private fun warningOf(session: Importable): String? =
when (session.inUse) { when (session.inUse) {
// What was measured is that a live process on that machine holds this session open. Which // What was measured is that a live process on that machine holds this session open. Which
// process is not measured, so it isn't claimed: "a terminal close it there first" sent // process is not measured, so it isn't claimed: "a terminal -- close it there first" sent
// people looking for a window that need not exist. It is just as likely another agent, or // people looking for a window that need not exist. Naming a place the reader then can't
// this app on a session it spawned. Naming a place the reader then can't find turns a // find turns a correct refusal into a wrong instruction.
// correct refusal into a wrong instruction.
"yes" -> "something on that machine is running it" "yes" -> "something on that machine is running it"
"unknown" -> "can't tell if it's open" "unknown" -> "can't tell if it's open"
else -> null else -> null
@@ -49,10 +49,9 @@ data class Rules(
/** Tokens that open a comment running to the end of the line. */ /** Tokens that open a comment running to the end of the line. */
val lineComments: List<String> = emptyList(), val lineComments: List<String> = emptyList(),
/** /**
* Whether [lineComments] count only at the start of a word. * Whether [lineComments] count only at the start of a word. The shells need it: `$#`, `${#x}`
* * and `a#b` are not comments, and greying the rest of those lines is one of the mistakes this
* The shells need it: `$#`, `${#x}` and `a#b` are not comments, and greying the rest of those * scanner exists to stop.
* lines is one of the mistakes this scanner exists to stop.
*/ */
val lineCommentsAtWordStart: Boolean = false, val lineCommentsAtWordStart: Boolean = false,
val blockComment: BlockComment? = null, val blockComment: BlockComment? = null,
@@ -63,8 +62,8 @@ data class Rules(
val rawStrings: Boolean = false, val rawStrings: Boolean = false,
/** /**
* Rust: `'` opens a character literal only when a backslash or one character and a `'` follow. * Rust: `'` opens a character literal only when a backslash or one character and a `'` follow.
* Otherwise it is a lifetime or a label and no string starts -- without this, `'a` opens a * Otherwise it is a lifetime or a label -- without this, `'a` opens a string that runs to the
* string that runs to the next apostrophe in the block. * next apostrophe in the block.
*/ */
val lifetimes: Boolean = false, val lifetimes: Boolean = false,
) )
@@ -91,11 +90,10 @@ enum class Attributes {
* The spans [language] colours in [code] -- the one way to ask, whatever the language turns out to * The spans [language] colours in [code] -- the one way to ask, whatever the language turns out to
* be made of. * be made of.
* *
* Nearly every language here is tokens: keywords, strings and comments, which is a row of [RULES] * Nearly every language here is tokens, which is a row of [RULES] and the one shared scanner.
* and the one shared scanner in [scan]. Markdown has none of those, and what a character means * Markdown has none of those, and what a character means there depends on where on the line it
* there depends on where on the line it sits, so it brings a scanner of its own ([scanMarkdown]). * sits, so it brings a scanner of its own. That is the whole extension point -- a new language is a
* That is the whole extension point -- a new language is a row of rules or an entry in [SCANNERS], * row of rules or an entry in [SCANNERS], and no caller learns which one it got.
* and no caller learns which one it got.
*/ */
fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(language)(code) fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(language)(code)
@@ -140,8 +138,8 @@ private val RULES: Map<Language, Rules> by lazy {
blockComment = C_STYLE, blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE), quotes = listOf(DOUBLE, SINGLE),
), ),
// `###` opens and closes a block comment and `#` opens a line one, which is why the // `###` opens and closes a block comment and `#` opens a line one, which is why the scanner
// scanner tries the block opener first. // tries the block opener first.
Language.COFFEESCRIPT to Language.COFFEESCRIPT to
Rules( Rules(
keywords = KEYWORDS_COFFEESCRIPT, keywords = KEYWORDS_COFFEESCRIPT,
@@ -162,8 +160,8 @@ private val RULES: Map<Language, Rules> by lazy {
keywords = KEYWORDS_FISH, keywords = KEYWORDS_FISH,
lineComments = listOf("#"), lineComments = listOf("#"),
lineCommentsAtWordStart = true, lineCommentsAtWordStart = true,
// fish's single quotes escape only `\'` and `\\`, which is what "skip the // fish's single quotes escape only `\'` and `\\`, which is what "skip the character
// character after a backslash" already does. // after a backslash" already does.
quotes = listOf(DOUBLE, SINGLE), quotes = listOf(DOUBLE, SINGLE),
), ),
Language.GO to Language.GO to
@@ -288,10 +286,9 @@ private val RULES: Map<Language, Rules> by lazy {
* The keyword sets. * The keyword sets.
* *
* Every list below other than RON, TOML, fish and JSON came from dev.snipme:highlights 1.1.0 * Every list below other than RON, TOML, fish and JSON came from dev.snipme:highlights 1.1.0
* (`SyntaxTokens.kt`, Apache-2.0), the library this scanner replaced, so that no fence which is * (Apache-2.0), the library this scanner replaced, so that no fence which is coloured today turns
* coloured today turns plain. Entries that are not plain words were dropped -- Kotlin's `as?`, * plain. Entries that are not plain words were dropped -- Kotlin's `as?`, Swift's `#if` family,
* `!in` and `!is`, Swift's `#if` family, Ruby's `defined?`, CoffeeScript's `=` and `->` -- because * Ruby's `defined?` -- because the word scanner cannot reach them.
* the word scanner cannot reach them and the library only matched them by luck.
*/ */
private fun words(list: String): Set<String> = private fun words(list: String): Set<String> =
list.split(Regex("\\s+")).filterNot(String::isEmpty).toSet() list.split(Regex("\\s+")).filterNot(String::isEmpty).toSet()
@@ -8,7 +8,7 @@ package com.example.aiapp
* empty list, which is the one wrong answer that looks like a right one. * empty list, which is the one wrong answer that looks like a right one.
* *
* [Loading] and [Error] carry no payload, so they are `LoadState<Nothing>` and this is covariant in * [Loading] and [Error] carry no payload, so they are `LoadState<Nothing>` and this is covariant in
* [T]: one `LoadState.Loading` serves every screen rather than each needing its own. * [T]: one `LoadState.Loading` serves every screen.
*/ */
sealed class LoadState<out T> { sealed class LoadState<out T> {
data object Loading : LoadState<Nothing>() data object Loading : LoadState<Nothing>()
@@ -28,14 +28,13 @@ import androidx.compose.ui.layout.layout
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
// Bumped whenever enrollment lands via an aiapp:// intent so the // Bumped whenever enrollment lands via an aiapp:// intent so the composition below re-reads the
// composition below re-reads the stored settings. // stored settings.
private var settingsVersion by mutableIntStateOf(0) private var settingsVersion by mutableIntStateOf(0)
// The session a notification tap asked for, or null if nothing has. The // The session a notification tap asked for, or null if nothing has. The serial is what makes a
// serial is what makes a second tap on the same session's notification a // second tap on the same session's notification a second request: without it the two compare
// second request: without it the two compare equal and the composition // equal and the composition below has nothing to react to.
// below has nothing to react to.
private var openRequest by mutableStateOf<SessionOpenRequest?>(null) private var openRequest by mutableStateOf<SessionOpenRequest?>(null)
private var opens = 0 private var opens = 0
@@ -43,8 +42,8 @@ class MainActivity : ComponentActivity() {
private var shareRequest by mutableStateOf<ShareRequest?>(null) private var shareRequest by mutableStateOf<ShareRequest?>(null)
private var shares = 0 private var shares = 0
// Registered up front since permission launchers must be registered // Registered up front since permission launchers must be registered before the activity reaches
// before the activity reaches STARTED. // STARTED.
private val requestLocalNetworkPermission = private val requestLocalNetworkPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {} registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
@@ -52,8 +51,8 @@ class MainActivity : ComponentActivity() {
* The service starts either way, and posts nothing if this is refused. * The service starts either way, and posts nothing if this is refused.
* *
* Deliberately not gated on the answer: the permission can be granted later from Android's own * Deliberately not gated on the answer: the permission can be granted later from Android's own
* settings, and a service that only ever started at the moment it was granted would then stay * settings, and a service that only ever started at the moment it was granted would stay down
* down until the app was launched again -- which is the case notifications exist to avoid. * until the app was launched again.
*/ */
private val requestNotificationPermission = private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {} registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
@@ -64,21 +63,17 @@ class MainActivity : ComponentActivity() {
// Before anything else that could throw, so the first crash of a launch is caught too. // Before anything else that could throw, so the first crash of a launch is caught too.
installCrashLog(this) installCrashLog(this)
// Transparent status bar on every version; the Surface below paints // Transparent status bar on every version; the Surface below paints through underneath it
// through underneath it and content insets itself. Same reasoning // and content insets itself. Same reasoning as dev-updater's MainActivity.
// as dev-updater's MainActivity.
enableEdgeToEdge() enableEdgeToEdge()
// Dark status-bar icons only over a light background, decided from the scheme rather // Dark status-bar icons only over a light background, decided from the scheme rather than
// than fixed. It was hardcoded to `true` -- dark icons -- which was right against the // fixed. It was hardcoded to `true`, which was right against the default light surface and
// default light surface and became unreadable the moment the app wore Catppuccin Mocha. // became unreadable the moment the app wore Catppuccin Mocha.
// Asking the colour means a future palette change cannot reintroduce that: whatever
// `background` becomes, the icons follow it.
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
AiAppColors.background.luminance() > 0.5f AiAppColors.background.luminance() > 0.5f
// Android 17+ silently drops local-network traffic without this; // Android 17+ silently drops local-network traffic without this; requested up front because
// requested up front because a denial is invisible at the socket // a denial is invisible at the socket layer (it just times out).
// layer (it just times out).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
} }
@@ -88,16 +83,14 @@ class MainActivity : ComponentActivity() {
} }
handleIntent(intent) handleIntent(intent)
// After enrollment, so a first launch that arrives with a token // After enrollment, so a first launch that arrives with a token starts the service with
// starts the service with something to connect to rather than // something to connect to rather than stopping it and waiting for the next launch.
// stopping it and waiting for the next launch.
NotificationService.sync(this) NotificationService.sync(this)
setContent { setContent {
// Selection colours with the theme rather than at each place text is drawn: the // Selection colours with the theme rather than at each place text is drawn: the
// transcript is one selection container, and a selection that ran from a reply into // transcript is one selection container, and a selection that ran from a reply into the
// the code block under it would otherwise change colour halfway. See // code block under it would otherwise change colour halfway.
// [AiAppSelectionColors].
MaterialTheme(colorScheme = AiAppColors) { MaterialTheme(colorScheme = AiAppColors) {
CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) { CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
@@ -107,8 +100,7 @@ class MainActivity : ComponentActivity() {
// the frame's draw phase is where Compose's measurement lands, and // the frame's draw phase is where Compose's measurement lands, and
// a report saying "draw is high" cannot otherwise say whether the // a report saying "draw is high" cannot otherwise say whether the
// cost is the transcript or the chrome around it. The keyboard is // cost is the transcript or the chrome around it. The keyboard is
// the case that made it matter -- every frame of the IME animation // the case that made it matter.
// relays out and re-records this whole box.
Modifier.layout { measurable, constraints -> Modifier.layout { measurable, constraints ->
val started = System.nanoTime() val started = System.nanoTime()
val placeable = measurable.measure(constraints) val placeable = measurable.measure(constraints)
@@ -135,19 +127,16 @@ class MainActivity : ComponentActivity() {
} }
.fillMaxSize() .fillMaxSize()
.statusBarsPadding() .statusBarsPadding()
// The gesture strip at the bottom of most // The gesture strip at the bottom of most phones. Without it
// phones. Without it the send row sits under // the send row sits under the swipe area, where a tap is as
// the swipe area, where a tap is as likely to // likely to navigate away as to press a button.
// navigate away as to press a button.
// //
// No imePadding here, deliberately: applied at the root it // No imePadding here, deliberately: applied at the root it
// resizes this whole box on every frame of the keyboard // resizes this whole box on every frame of the keyboard
// animation, which re-measures, re-places and re-records every // animation, which re-measures, re-places and re-records every
// screen's entire tree per frame -- measured above as most of // screen's entire tree per frame. Each screen takes the
// the frame budget. Each screen takes the keyboard itself // keyboard itself, so the per-frame cost is scoped to what
// (AppRoot wraps the ordinary ones; the session screen moves // actually moves.
// only its composer and transcript), so the per-frame cost is
// scoped to what actually moves.
.navigationBarsPadding() .navigationBarsPadding()
) { ) {
AppRoot(settingsVersion, openRequest, shareRequest) AppRoot(settingsVersion, openRequest, shareRequest)
@@ -158,9 +147,8 @@ class MainActivity : ComponentActivity() {
} }
} }
// launchMode="singleTop": an enrollment scan, or a notification tapped // launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
// while the app is open, lands here rather than in a second activity // lands here rather than in a second activity instance.
// instance.
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleIntent(intent) handleIntent(intent)
@@ -171,8 +159,7 @@ class MainActivity : ComponentActivity() {
* *
* Three things arrive this way -- a share from another app, and an `aiapp://` URI that is * Three things arrive this way -- a share from another app, and an `aiapp://` URI that is
* either an enrollment code or a notification naming a session. The URIs are told apart by host * either an enrollment code or a notification naming a session. The URIs are told apart by host
* rather than by two entry points, so a further kind is a branch here rather than another * rather than by two entry points, so a further kind is a branch here.
* intent to remember to handle.
*/ */
private fun handleIntent(intent: Intent?) { private fun handleIntent(intent: Intent?) {
intent ?: return intent ?: return
@@ -195,8 +182,8 @@ class MainActivity : ComponentActivity() {
} }
saveServerSettings(this, settings) saveServerSettings(this, settings)
settingsVersion++ settingsVersion++
// Enrolling is the moment there is a backend to watch, and // Enrolling is the moment there is a backend to watch, and re-enrolling elsewhere is the
// re-enrolling elsewhere is the moment the old one stops being it. // moment the old one stops being it.
NotificationService.sync(this) NotificationService.sync(this)
Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show() Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show()
} }
@@ -29,12 +29,10 @@ import androidx.lifecycle.repeatOnLifecycle
* The app's root: one title, and four views of the backend behind it. * The app's root: one title, and four views of the backend behind it.
* *
* These were four screens reached by four words in a row under the title, and the row was already * These were four screens reached by four words in a row under the title, and the row was already
* full -- the comment it replaced recorded that a fifth would have to go somewhere else. Tabs say * full. Tabs say the same thing in less space and say one more thing besides: that these are places
* the same thing in less space and say one more thing besides: that these are places to be rather * to be rather than errands to run. Sessions, the machine's importable history, the models on it
* than errands to run. Sessions, the machine's importable history, the models on it and the * and the machines themselves are all *the same backend*, looked at four ways, and none is a step
* machines themselves are all *the same backend*, looked at four ways, and none of them is a step * down from another. Settings still is, which is why it stays a pushed screen with its own Back.
* down from another. Settings still is a step down, which is why it stays a pushed screen and keeps
* its own Back.
*/ */
private enum class MainTab(val label: String) { private enum class MainTab(val label: String) {
Sessions("Sessions"), Sessions("Sessions"),
@@ -61,17 +59,12 @@ fun MainScreen(
// //
// What these four draw is a snapshot of a backend they are not connected to, so it is only as // What these four draw is a snapshot of a backend they are not connected to, so it is only as
// fresh as the last answer -- and a *failed* answer is the one that outstays its welcome. A // fresh as the last answer -- and a *failed* answer is the one that outstays its welcome. A
// phone that was away while the tunnel was down, or that fetched before the network came up, // phone that was away while the tunnel was down came back to "Couldn't reach the server"
// came back to "Couldn't reach the server" sitting at the top of a list the server would now // sitting at the top of a list the server would now answer for perfectly well. A stale failure
// answer for perfectly well, and nothing took it off until somebody pressed Refresh. A stale // is worse than a stale list: it is a claim about right now.
// failure is worse than a stale list: it is a claim about right now.
// //
// Through the same token the Refresh button uses, so this is one instruction the tabs already // Through the same token the Refresh button uses, so this is one instruction the tabs already
// understand rather than a second path into each of them -- which is also what makes it cover // understand. Not on the first entry: the tab composing already asks.
// all four rather than the one the report came from.
//
// Not on the first entry: the tab composing already asks, and bumping here would make every
// cold start fetch twice.
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) { LaunchedEffect(lifecycleOwner) {
var opening = true var opening = true
@@ -81,9 +74,8 @@ fun MainScreen(
} }
} }
// A tab the app put over the list has to step back to it rather than fall through to the // A tab the app put over the list has to step back to it rather than fall through to the system
// system default, which closes the app -- that reads as a crash to somebody who only meant to // default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled.
// get back to their sessions. Nested inside AppRoot's handler, so it wins while it is enabled.
BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions } BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions }
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
@@ -98,20 +90,18 @@ fun MainScreen(
) )
// Glyphs rather than the words they replaced: neither ever changes, both are read // Glyphs rather than the words they replaced: neither ever changes, both are read
// faster than they are spelled, and together they take the width that let the title // faster than they are spelled, and together they take the width that let the title
// keep its own line. They sit on the title's row because they act on the whole // keep its own line. They sit on the title's row because they act on the whole screen.
// screen -- everything below this row is one tab's business, and a control belongs //
// with the thing it acts on. // Flush against each other: a glyph button carries its own padding, so two side by side
// Flush against each other: a glyph button carries its own padding, so two of them // already have two rings between their marks.
// side by side already have two rings between their marks and one ring plus this
// row's padding to the screen edge.
Row { Row {
GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ }) GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ })
GlyphButton(SETTINGS_GLYPH, "Settings", onSettings) GlyphButton(SETTINGS_GLYPH, "Settings", onSettings)
} }
} }
// What is waiting to be attached, and what to do about it. Said here because the list // What is waiting to be attached, and what to do about it. Said here because the list below
// below is where the choice is made, and a share that arrived with nothing on screen // is where the choice is made, and a share that arrived with nothing on screen saying so
// saying so would read as a tap that did nothing. // would read as a tap that did nothing.
share?.let { share?.let {
Text( Text(
it.summary() + " -- open the session it belongs in.", it.summary() + " -- open the session it belongs in.",
@@ -127,8 +117,8 @@ fun MainScreen(
.padding(12.dp), .padding(12.dp),
) )
} }
// Primary rather than the plain TabRow, which is deprecated in favour of the two that // Primary rather than the plain TabRow, which is deprecated in favour of the two that say
// say where they sit: these are the app's top-level destinations. // where they sit: these are the app's top-level destinations.
PrimaryTabRow(selectedTabIndex = tab.ordinal) { PrimaryTabRow(selectedTabIndex = tab.ordinal) {
MainTab.entries.forEach { entry -> MainTab.entries.forEach { entry ->
Tab( Tab(
@@ -139,10 +129,9 @@ fun MainScreen(
} }
} }
// Refreshing means "ask again about what I am looking at", so the button feeds the tab // Refreshing means "ask again about what I am looking at", so the button feeds the tab that
// that is showing. The token from above means something else already changed what these // is showing. The token from above means something else already changed what these show;
// show; the two are the same instruction to the tab below, so they are summed rather than // the two are the same instruction, so they are summed rather than tracked apart.
// tracked apart -- either one moving moves the sum, which is all a tab watches.
val token = reloadToken + refreshToken val token = reloadToken + refreshToken
when (tab) { when (tab) {
MainTab.Sessions -> MainTab.Sessions ->
@@ -69,13 +69,10 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
* *
* [live] is the reply still arriving, and two things are different for it. Its parse is incremental * [live] is the reply still arriving, and two things are different for it. Its parse is incremental
* -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole * -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole
* message. And its pieces get a layer each: when drawing is invalidated, only the piece that * message. And its pieces get a layer each, so only the piece that changed is re-recorded. That is
* changed is re-recorded instead of the whole reply, which is worth a great deal while every delta * worth a great deal while every delta invalidates the message and worth nothing once it stops
* invalidates the message and a finished one can be twenty-five screens tall. It is worth nothing * changing -- and it is not free: each layer is a layout node and a display list held for the life
* once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows were re-recorded 65 * of the row, and live node count is what the transcript's per-frame cost scales with.
* times in fifty seconds of reading -- and it is not free: each layer is a layout node and a
* display list held for the life of the row, and live node count is what the per-frame cost of the
* transcript scales with.
*/ */
@Composable @Composable
fun MarkdownText( fun MarkdownText(
@@ -92,8 +89,8 @@ fun MarkdownText(
var previousSegment: Segment? = null var previousSegment: Segment? = null
segments.forEachIndexed { at, segment -> segments.forEachIndexed { at, segment ->
val nextContinues = segments.getOrNull(at + 1)?.continues == true val nextContinues = segments.getOrNull(at + 1)?.continues == true
// Only the tail is still being written; a frozen segment is finished text that // Only the tail is still being written; a frozen segment is finished text that happens
// happens to sit in a live reply, and it takes its colours now. See [MarkdownRoot]. // to sit in a live reply, and it takes its colours now.
MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) { MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) {
segment.pieces.forEachIndexed { index, piece -> segment.pieces.forEachIndexed { index, piece ->
val gap = val gap =
@@ -103,9 +100,9 @@ fun MarkdownText(
if (segment.continues) 0.dp else BLOCK_SPACING if (segment.continues) 0.dp else BLOCK_SPACING
else -> gapBefore(previous, piece) else -> gapBefore(previous, piece)
} }
// Keyed by where the piece starts in the message rather than by its position // Keyed by where the piece starts in the message rather than by its position in
// in this column, so a delta landing in the last block leaves every other // this column, so a delta landing in the last block leaves every other piece's
// piece's composition alone -- and a block keeps its key when it freezes. // composition alone -- and a block keeps its key when it freezes.
key(segment.start, piece) { key(segment.start, piece) {
MarkdownPiece( MarkdownPiece(
segment.parse, segment.parse,
@@ -137,8 +134,7 @@ fun MarkdownText(
* A stretch of a message with a parse of its own: the whole of a settled message, or one block, the * A stretch of a message with a parse of its own: the whole of a settled message, or one block, the
* finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins * finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins
* in the message. [continues] says the first piece is an item of the list the segment before it * in the message. [continues] says the first piece is an item of the list the segment before it
* ended with, so the two draw as one list: no block gap between them, and neither the item above * ended with, so the two draw as one list.
* the seam nor the one below it takes the padding of a list's edge.
*/ */
private class Segment( private class Segment(
val text: String, val text: String,
@@ -154,14 +150,11 @@ private class Segment(
* *
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading * The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
* slot until its result arrives, so a row is measured at nothing before it is measured at its real * slot until its result arrives, so a row is measured at nothing before it is measured at its real
* height, and the transcript above it collapses and springs back. Seen with five replies on screen * height, and the transcript above it collapses and springs back -- seen with five replies on
* at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment * screen at once, the whole conversation shrunk to fit a single screen.
* later it was all there again. That is the "skipping up and down" this list must never do.
* *
* Every parse after the first is off the composing thread, and the row keeps drawing the parse it * Every parse after the first is off the composing thread, and the row keeps drawing the parse it
* already has until the new one lands, so there is never a frame without a height. What is on * already has until the new one lands, so there is never a frame without a height.
* screen is always a real prefix of the reply rather than a guess at it; it is simply one parse
* behind.
*/ */
@Composable @Composable
private fun liveSegments(text: String): List<Segment> { private fun liveSegments(text: String): List<Segment> {
@@ -187,24 +180,18 @@ private fun liveSegments(text: String): List<Segment> {
* Reparsing the whole message per delta was fine for a short reply and not for a long one: a * Reparsing the whole message per delta was fine for a short reply and not for a long one: a
* twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran * twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran
* off the composing thread it was every core busy while the frame's own thread waited for one. * off the composing thread it was every core busy while the frame's own thread waited for one.
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
* finished -- nothing appended later can reach back into it, since a paragraph ends at the blank
* line or the block that interrupts it, a fence at its closing fence, a list at the first line that
* is neither an item nor indented under one. So every block but the last is [frozen] with the parse
* that finished it, and only the tail -- the last block and whatever has arrived since -- is parsed
* again.
* *
* A list is cut once more, at its last item, by the same reasoning one level down: an item is * Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
* finished once the next item has begun, since a line can only continue the item it is indented * finished -- nothing appended later can reach back into it. So every block but the last is
* under or start a new one. Without this a reply that is one long list -- forty sources -- parsed * [frozen] with the parse that finished it, and only the tail is parsed again.
* the whole list per delta, and a list streams as forty paragraphs would. The item the cut lands on *
* has to have begun in earnest: a bare `-` is an empty item now and the first character of a * A list is cut once more, at its last item, by the same reasoning one level down. Without this a
* paragraph line once `-x` arrives, and cutting on it would draw that line as a new item. * reply that is one long list -- forty sources -- parsed the whole list per delta. The item the cut
* lands on has to have begun in earnest: a bare `-` is an empty item now and the first character of
* a paragraph line once `-x` arrives.
* *
* What the cut gives up is one thing: a reference definition arriving later than a link that uses * What the cut gives up is one thing: a reference definition arriving later than a link that uses
* it, since the frozen block's parse never sees it. The link draws as its brackets until the reply * it. The link draws as its brackets until the reply settles and is parsed whole by [warm].
* settles and is parsed whole by [warm], which is the same moment every other transient of
* streaming is put right.
*/ */
private class LiveParse( private class LiveParse(
val text: String, val text: String,
@@ -217,8 +204,8 @@ private class LiveParse(
get() = frozen + tail get() = frozen + tail
fun advanceTo(next: String): LiveParse { fun advanceTo(next: String): LiveParse {
// Anything but an append to what was frozen -- a message replaced, a stream reset -- // Anything but an append to what was frozen -- a message replaced, a stream reset -- starts
// starts over. // over.
if (!next.regionMatches(0, text, 0, consumed)) return whole(next) if (!next.regionMatches(0, text, 0, consumed)) return whole(next)
val tailText = next.substring(consumed) val tailText = next.substring(consumed)
val parse = parseMarkdown(tailText) val parse = parseMarkdown(tailText)
@@ -264,8 +251,7 @@ private class LiveParse(
/** /**
* The piece of the tail still being written: the last item of a list of several, or the first * The piece of the tail still being written: the last item of a list of several, or the first
* piece of the last block when there is more than one block. Null when nothing before it is * piece of the last block when there is more than one. Null when nothing before it is finished.
* finished, so the tail stays whole.
*/ */
private fun openPiece(parse: State.Success, all: List<Piece>): Piece? { private fun openPiece(parse: State.Success, all: List<Piece>): Piece? {
val last = all.lastOrNull() ?: return null val last = all.lastOrNull() ?: return null
@@ -315,28 +301,24 @@ fun MarkdownPiece(
* The renderer's own environment -- its colours, type scale, dimensions, component table and * The renderer's own environment -- its colours, type scale, dimensions, component table and
* reference links -- around whatever draws pieces of [parse]. * reference links -- around whatever draws pieces of [parse].
* *
* The parsing is the library's. Markdown is somebody else's specification, and a hand-written * The parsing is the library's: markdown is somebody else's specification, and a hand-written
* parser would get the edge cases wrong one case at a time. So is the environment: the element * parser would get the edge cases wrong one case at a time. So is the environment: the element
* composables its dispatch reaches read these locals, and providing them once here is what lets a * composables its dispatch reaches read these locals, and providing them once here is what lets a
* piece be drawn anywhere -- in a message's column, or as one item of the transcript list. * piece be drawn anywhere -- in a message's column, or as one item of the transcript list.
* Everything below this is the mapping onto the app's palette and type scale.
* *
* The locals are provided directly rather than through the renderer's `Markdown()` composable, * The locals are provided directly rather than through the renderer's `Markdown()` composable,
* which was the last of its composables on the hot path and was here only to provide them. What * which was the last of its composables on the hot path and was here only to provide them. So
* that buys is that nothing between a piece and the screen is the library's but the leaf * nothing between a piece and the screen is the library's but the leaf composables named in the
* composables named in the component table, so a different parser could stand behind [State] * component table.
* without the renderer's entry point being involved.
* *
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules * Colours come from the theme rather than the renderer's defaults. Nothing here picks one of its
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own. * own.
* *
* [streaming] says this parse is the part of a reply still being written, which only the fences * [streaming] says this parse is the part of a reply still being written, which only the fences
* care about: lexing is proportional to how much code there is, and a fence still arriving is * care about: lexing is proportional to how much code there is. Measured streaming a two-hundred-
* re-lexed at every delta on the composing thread. Measured streaming a two-hundred-line Kotlin * line Kotlin fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms --
* fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms -- for colours on * for colours on text being replaced as fast as they were computed. So a fence still being written
* text that was being replaced as fast as they were computed. So a fence still being written is * is drawn plain and takes its colours when the block freezes.
* drawn plain and takes its colours when the block freezes, which is the same bargain [LiveParse]
* already makes for a reference link defined at the foot of a message.
*/ */
@Composable @Composable
private fun MarkdownRoot( private fun MarkdownRoot(
@@ -354,45 +336,40 @@ private fun MarkdownRoot(
CompositionLocalProvider( CompositionLocalProvider(
LocalReferenceLinkHandler provides parse.referenceLinkHandler, LocalReferenceLinkHandler provides parse.referenceLinkHandler,
LocalMarkdownPadding provides markdownPadding(), LocalMarkdownPadding provides markdownPadding(),
// Read by the renderer's own text composable, which no paragraph reaches any more, and // Read by the renderer's own text composable, which no paragraph reaches any more, and by
// by its checkbox. Provided so a path that does reach them draws no image rather than // its checkbox. Provided so a path that does reach them draws no image rather than failing
// failing to compose. // to compose.
LocalImageTransformer provides remember { NoOpImageTransformerImpl() }, LocalImageTransformer provides remember { NoOpImageTransformerImpl() },
LocalMarkdownAnimations provides markdownAnimations(), LocalMarkdownAnimations provides markdownAnimations(),
LocalMarkdownColors provides LocalMarkdownColors provides
markdownColor( markdownColor(
text = MaterialTheme.colorScheme.onSurface, text = MaterialTheme.colorScheme.onSurface,
dividerColor = MaterialTheme.colorScheme.outlineVariant, dividerColor = MaterialTheme.colorScheme.outlineVariant,
// The dark surface every verbatim thing in this app sits on -- see [rawSurface], // The dark surface every verbatim thing in this app sits on -- and the tool call
// and the tool call above this reply, which now matches. `surfaceVariant` was // above this reply, which now matches. `surfaceVariant` was exactly a card's own
// exactly a card's own fill, so a fenced block inside a tool call had no // fill, so a fenced block inside a tool call had no background at all.
// background at all and one in a reply read as a step *up* out of the page.
codeBackground = rawSurface, codeBackground = rawSurface,
// The same colour. Not drawn by the renderer as a span background but by // The same colour. Not drawn by the renderer as a span background but by
// [LinkedText] behind the text, so a selection lands on top of it as it does on a // [LinkedText] behind the text, so a selection lands on top of it -- see
// fenced block -- see `appendCodeChip`. // `appendCodeChip`.
inlineCodeBackground = rawSurface, inlineCodeBackground = rawSurface,
// The same tint a code block gets, rather than the renderer's 2%-alpha default: // The same tint a code block gets, rather than the renderer's 2%-alpha default: two
// two adjacent tints that differ by a fiftieth read as one flat block on a phone, // adjacent tints that differ by a fiftieth read as one flat block on a phone.
// so the table would have had a border-less grid and nothing saying where it began.
tableBackground = MaterialTheme.colorScheme.surfaceVariant, tableBackground = MaterialTheme.colorScheme.surfaceVariant,
), ),
LocalMarkdownTypography provides LocalMarkdownTypography provides
markdownTypography( markdownTypography(
// A ladder that starts near the body text and descends, because these are headings // A ladder that starts near the body text and descends, because these are headings
// inside a chat message rather than the top of a document. The renderer's defaults // inside a chat message rather than the top of a document. The renderer's defaults
// are the Material *display* styles -- `#` came out at 57sp and `##` at 45sp, which // are the Material *display* styles -- `#` came out at 57sp, bigger than this app's
// is bigger than this app's own screen titles and reads as the reply shouting. // own screen titles. Every step is a different size, so two levels of nesting never
// // draw the same.
// Every step is a different size, so two levels of nesting never draw the same:
// one clear step per level is the whole job of a heading.
h1 = MaterialTheme.typography.headlineSmall, h1 = MaterialTheme.typography.headlineSmall,
h2 = MaterialTheme.typography.titleLarge, h2 = MaterialTheme.typography.titleLarge,
h3 = MaterialTheme.typography.titleMedium, h3 = MaterialTheme.typography.titleMedium,
h4 = MaterialTheme.typography.titleSmall, h4 = MaterialTheme.typography.titleSmall,
h5 = MaterialTheme.typography.labelMedium, h5 = MaterialTheme.typography.labelMedium,
h6 = MaterialTheme.typography.labelSmall, h6 = MaterialTheme.typography.labelSmall,
// Body text at the size everything else in the transcript uses.
text = body, text = body,
paragraph = body, paragraph = body,
ordered = body, ordered = body,
@@ -400,16 +377,10 @@ private fun MarkdownRoot(
list = body, list = body,
table = body, table = body,
// Code in a monospace face, in the ordinary text colour. The face and the tinted // Code in a monospace face, in the ordinary text colour. The face and the tinted
// background are what say "this is code"; colour is not, and it used to be green // background are what say "this is code"; colour is not, and it used to be green --
// -- the palette's colour for a *literal*. A block of code is not a literal, it // the palette's colour for a *literal*. A block of code is not a literal, and
// is text that happens to be code, and painting all of it green said the whole // painting all of it green said the whole block was one. Where a literal really
// block was one. Where a literal really does appear inside code, the thing that // does appear inside code, what should colour it is a syntax highlighter.
// should colour it is a syntax highlighter looking at the code, which is exactly
// what a tool call's input already gets from `catppuccinSyntax`.
//
// The colour rides on the style here rather than in `markdownColor`, which
// stopped carrying `codeText`/`inlineCodeText`/`linkText` when the renderer moved
// them onto the typography.
code = code =
MaterialTheme.typography.bodyMedium.copy( MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
@@ -436,31 +407,26 @@ private fun MarkdownRoot(
LocalMarkdownDimens provides LocalMarkdownDimens provides
markdownDimens( markdownDimens(
// Half the renderer's 16dp. Padding is charged on both sides of every cell, so at // Half the renderer's 16dp. Padding is charged on both sides of every cell, so at
// the default a fifth of the narrowest column went on space rather than on words // the default a fifth of the narrowest column went on space rather than on words.
// -- and the narrowest column is where the wrapping below has the least room.
tableCellPadding = 8.dp, tableCellPadding = 8.dp,
// What a column narrows to before the table starts scrolling sideways instead. It // What a column narrows to before the table starts scrolling sideways instead. It
// is the floor, not the width: a table with room to spare spreads across it. // is the floor, not the width: a table with room to spare spreads across it.
// //
// Down from the renderer's 160dp, and the number is a measurement rather than a // Down from the renderer's 160dp, and the number is a measurement rather than a
// taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp // taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp
// makes even a three-column table -- the commonest shape there is -- scroll, while // makes even a three-column table scroll, while 136dp fits three across the phone
// 136dp fits three across the phone this app is read on. Four and up still scroll, // this app is read on. Four and up still scroll, which is the right answer for
// which is the right answer for genuinely too many columns: squeezing six columns // genuinely too many columns. This is the widest minimum that keeps three on
// into a phone would give every cell one word per line. // screen.
//
// Narrower would fit more, and stop being readable. This is the widest minimum
// that keeps three columns on screen, which is the trade the number is making.
tableCellWidth = 136.dp, tableCellWidth = 136.dp,
), ),
LocalMarkdownComponents provides LocalMarkdownComponents provides
markdownComponents( markdownComponents(
// The m3 renderer's own default, restored: supplying `components` at all replaces // The m3 renderer's own default, restored: supplying `components` at all replaces
// the whole set, and this is the only member of it the Material layer overrides. // the whole set, and this is the only member the Material layer overrides.
checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) }, checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) },
// Everything that draws a run of text, so a link is a span rather than a node -- // Everything that draws a run of text, so a link is a span rather than a node --
// see [LinkedText]. Setext headings take the same styles as `#` and `##`, which // see [LinkedText]. Setext headings take the same styles as `#` and `##`.
// is the renderer's own pairing.
text = { LinkedText(it, it.typography.text) }, text = { LinkedText(it, it.typography.text) },
paragraph = { LinkedText(it, it.typography.paragraph) }, paragraph = { LinkedText(it, it.typography.paragraph) },
heading1 = { LinkedHeading(it, it.typography.h1) }, heading1 = { LinkedHeading(it, it.typography.h1) },
@@ -471,8 +437,8 @@ private fun MarkdownRoot(
heading6 = { LinkedHeading(it, it.typography.h6) }, heading6 = { LinkedHeading(it, it.typography.h6) },
setextHeading1 = { LinkedHeading(it, it.typography.h1) }, setextHeading1 = { LinkedHeading(it, it.typography.h1) },
setextHeading2 = { LinkedHeading(it, it.typography.h2) }, setextHeading2 = { LinkedHeading(it, it.typography.h2) },
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- // Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- so
// so they draw like the top-level ones the transcript cuts into items. // they draw like the top-level ones the transcript cuts into items.
orderedList = { MarkdownList(it.content, it.node, it.listDepth) }, orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) }, unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
table = { LinkedTable(it.content, it.node, it.typography.table) }, table = { LinkedTable(it.content, it.node, it.typography.table) },
@@ -491,14 +457,12 @@ private fun MarkdownRoot(
/** /**
* A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need. * A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need.
* *
* Each column has a floor ([markdownDimens]'s `tableCellWidth`), so the table is at least * Each column has a floor, so the table is at least columns-times-floor wide; narrower than the
* columns-times-floor wide; narrower than the room it has, it spreads to fill it, and wider, it * room it has, it spreads to fill it, and wider, it scrolls sideways rather than squeezing. The
* scrolls sideways rather than squeezing. The renderer decided that with a `BoxWithConstraints`, * renderer decided that with a `BoxWithConstraints`, which is a subcomposition; here it is one
* which is a subcomposition; here it is one layout modifier, and the trick is where it sits. * layout modifier. `fillMaxWidth` fixes the minimum width to the room available, the horizontal
* `fillMaxWidth` fixes the minimum width to the room available, the horizontal scroll passes that * scroll passes that minimum through while lifting the maximum to unbounded, and the modifier after
* minimum through to its content while lifting the maximum to unbounded, and the modifier after it * it reads the minimum back and sizes the rows to the larger of that and the floor.
* reads the minimum back as the room and sizes the rows to the larger of that and the floor. The
* scroll then has exactly the overflow to scroll, which is none when the table fits.
*/ */
@Composable @Composable
private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) { private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
@@ -539,19 +503,15 @@ private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
* One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText]. * One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText].
* *
* The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means * The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means
* most of a table is simply not readable: anything past about twenty characters ends in "..." with * most of a table is simply not readable: an elided cell looks like a short one, so a table of
* no way to see the rest, and an elided cell looks like a short one, so a table of measurements * measurements reads as a table of plausible shorter measurements. And they draw a link in a cell
* reads as a table of plausible shorter measurements. And they draw a link in a cell as its own * as its own layout node, the cost [LinkedText] exists to avoid.
* layout node, the cost [LinkedText] exists to avoid.
* *
* So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell * So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell
* beside a one-line one centred the short one against the middle of the tall one and lost the line * beside a one-line one centred the short one against the middle of the tall one. What the wrapping
* the reader was reading across. What the wrapping does *not* do is make a wide table fit; * does *not* do is make a wide table fit; [LinkedTable] scrolls it instead.
* [LinkedTable] scrolls it instead, which is the right answer for too many columns -- wrapping a
* six-column table into the width of a phone would give every cell one word per line.
* *
* The semantics are the renderer's: each cell is an item of the table's collection, and a header * The semantics are the renderer's: each cell is an item of the table's collection.
* cell is a heading.
*/ */
@Composable @Composable
private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) { private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) {
@@ -587,19 +547,14 @@ private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowI
* Parsing is the expensive half of drawing a reply, and it is expensive in proportion to how much * Parsing is the expensive half of drawing a reply, and it is expensive in proportion to how much
* was written. Measured against a real Claude Code transcript on the emulator, one message took * was written. Measured against a real Claude Code transcript on the emulator, one message took
* **51ms** and several took 10-25ms, against 4.6ms for the short synthetic replies this was first * **51ms** and several took 10-25ms, against 4.6ms for the short synthetic replies this was first
* tuned on -- so a page of history landing composed several rows that each stalled the frame they * tuned on -- so a page of history landing composed several rows that each stalled the frame.
* appeared in. That is the lag when a block loads.
* *
* Nothing here changes what a row does when it has no answer waiting: it parses inline, on the * Nothing here changes what a row does when it has no answer waiting: it parses inline, because a
* composing thread, because a row measured at nothing before it is measured at its real height * row measured at nothing before its real height collapses the transcript above it. The point is
* collapses the transcript above it. The point is only that by the time the reader scrolls to a * only that by the time the reader scrolls to a row, the answer is usually already made.
* row, the answer is usually already made -- [warm] runs on a background thread as each page of
* history arrives, which is seconds before anybody reaches the rows it brought.
* *
* A miss is not stored, and that is what bounds this: the map holds one entry per message a page * A miss is not stored, and that is what bounds this: the map holds one entry per message a page
* warmed and nothing else, so a reply still streaming cannot fill it with hundreds of copies of * warmed, so a reply still streaming cannot fill it with hundreds of copies of itself.
* itself on the way to being finished. It is dropped with the screen, and emptied by the stream
* reset that drops the rows it describes.
*/ */
@Stable @Stable
class ParsedReplies { class ParsedReplies {
@@ -607,14 +562,13 @@ class ParsedReplies {
/** /**
* How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per * How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per
* fold, and walking the tree again each time is proportional to the message where a lookup is * fold, and walking the tree again each time is proportional to the message.
* proportional to nothing.
*/ */
private val pieces = ConcurrentHashMap<String, List<Piece>>() private val pieces = ConcurrentHashMap<String, List<Piece>>()
/** /**
* How each message divides into prose and memory notes, cached for the same reason as * How each message divides into prose and memory notes, cached for the same reason: the regex
* [piecesOf]: the regex scan behind [messageParts] is proportional to the message. * scan behind [messageParts] is proportional to the message.
*/ */
private val parts = ConcurrentHashMap<String, List<MessagePart>>() private val parts = ConcurrentHashMap<String, List<MessagePart>>()
@@ -627,7 +581,7 @@ class ParsedReplies {
* much code was written -- a two-hundred-line Kotlin fence measured 174ms on the emulator -- * much code was written -- a two-hundred-line Kotlin fence measured 174ms on the emulator --
* and a lazy list drops the composition of a block that scrolls away, so a `remember` inside * and a lazy list drops the composition of a block that scrolls away, so a `remember` inside
* the fence paid that again every time the reader came back to it. Six times in one scroll, * the fence paid that again every time the reader came back to it. Six times in one scroll,
* measured. [warm] fills this off the drawing thread before the row is reached. * measured.
*/ */
private val highlights = ConcurrentHashMap<String, AnnotatedString>() private val highlights = ConcurrentHashMap<String, AnnotatedString>()
@@ -649,11 +603,10 @@ class ParsedReplies {
* Whether [warm] has made everything drawing [text] as pieces will look up. * Whether [warm] has made everything drawing [text] as pieces will look up.
* *
* What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole * What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole
* message and the flatten runs on the composing thread -- so a reply not marked yet stays * message and the flatten runs on the composing thread, so a reply not marked yet stays whole
* whole, drawing the parse it already has, until the screen has warmed it and re-flattens. An * until the screen has warmed it. An explicit mark rather than a peek into the parse cache,
* explicit mark rather than a peek into the parse cache, because a message with memory notes is * because a message with memory notes is warmed as its *parts*: nothing ever parses its full
* warmed as its *parts*: nothing ever parses its full text, and inferring readiness from the * text, and inferring readiness from the cache left exactly that message unsplittable forever.
* cache left exactly that message unsplittable forever, re-warmed on every fold.
*/ */
fun splitReady(text: String): Boolean = text in ready fun splitReady(text: String): Boolean = text in ready
@@ -668,9 +621,8 @@ class ParsedReplies {
} }
/** /**
* [code] coloured for [language] -- the answer made ahead, or one made now. * [code] coloured for [language] -- the answer made ahead, or one made now. The key carries the
* * language, because the same code lexes differently under two of them.
* The key carries the language, because the same code lexes differently under two of them.
*/ */
fun highlighted(code: String, language: Language?): AnnotatedString = fun highlighted(code: String, language: Language?): AnnotatedString =
if (language == null) AnnotatedString(code) if (language == null) AnnotatedString(code)
@@ -686,9 +638,9 @@ class ParsedReplies {
* *
* Suspending, and yielding between messages, because "off the composing thread" is not the same * Suspending, and yielding between messages, because "off the composing thread" is not the same
* as "free". A page of history arrives as hundreds of parses at once -- 1.5 seconds of them in * as "free". A page of history arrives as hundreds of parses at once -- 1.5 seconds of them in
* a twelve second scroll, measured on a Pixel 9 Pro XL -- and on the default dispatcher that is * a twelve second scroll on a Pixel 9 Pro XL -- and on the default dispatcher that is every
* every core busy, with the frame's own thread waiting for one. That showed up as 21ms of * core busy, with the frame's own thread waiting for one: 21ms of `waited` at the 90th
* `waited` at the 90th percentile: the frame could not start, rather than taking too long. * percentile.
*/ */
suspend fun warm(texts: List<String>) { suspend fun warm(texts: List<String>) {
texts.forEach { text -> texts.forEach { text ->
@@ -697,9 +649,8 @@ class ParsedReplies {
DebugStats.timed("markdown warmed") { parseMarkdown(it) } DebugStats.timed("markdown warmed") { parseMarkdown(it) }
} }
// The fences too, and here rather than in a pass of its own: they are found in the // The fences too, and here rather than in a pass of its own: they are found in the
// parse this just made, and lexing one is the same kind of cost as parsing the // parse this just made, and lexing one is the same kind of cost as parsing the message
// message it is in -- proportional to what was written, and charged to the frame // it is in.
// that first draws it if nobody paid it earlier.
fences(parse).forEach { (code, language) -> highlighted(code, language) } fences(parse).forEach { (code, language) -> highlighted(code, language) }
} }
} }
@@ -44,27 +44,22 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
* *
* Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable, * Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable,
* hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text * hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text
* layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one of those * layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one annotation
* annotations per link. Measured on the emulator against the same paragraphs with each link * per link. Measured on the emulator against the same paragraphs with each link replaced by its
* replaced by its label and address as plain words -- *more* text, the same gestures -- the linked * label and address as plain words -- *more* text, the same gestures -- the linked version cost
* version cost five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. On a * five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time.
* Pixel 9 Pro XL that was the bump at the list of sources in a reply, and nowhere else in it.
* *
* Here a link is the link colour and underline, a string annotation carrying its address, and one * Here a link is the link colour and underline, a string annotation carrying its address, and one
* tap detector for the whole text that asks the layout which character was under the finger. What * tap detector for the whole text that asks the layout which character was under the finger. What
* that gives up is a link being its own accessibility node with a pressed state; the app's link * that gives up is a link being its own accessibility node with a pressed state; the app's link
* style never defined a pressed style, so nothing visible changes. * style never defined a pressed style, so nothing visible changes.
* *
* Every block the renderer dispatches through its component table comes here, which includes the * Every block the renderer dispatches through its component table comes here, and so does every
* paragraphs inside lists, quotes and alerts, and so does every table cell through * table cell. Reference-style links are the one kind still drawn the renderer's way.
* [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it
* resolves those against its definitions.
* *
* An image is a link too, carrying its alt text. The app has no image loader and the renderer's * An image is a link too, carrying its alt text. The app has no image loader and the renderer's
* transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the * transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the
* model put something, with no sign of what fell out. The link says what was there and where, and * model put something. The link says what was there and where, and opens it.
* opens it. It also means no paragraph needs the renderer's own text composable, which existed to
* place inline images and charged every paragraph for the possibility.
*/ */
@Composable @Composable
fun LinkedText(model: MarkdownComponentModel, style: TextStyle) { fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
@@ -74,8 +69,7 @@ fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
/** /**
* A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or * A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or
* `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it * `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it
* does not know, so handed the heading node itself it draws an empty line. Which is what this did * does not know, so handed the heading node itself it draws an empty line.
* for a week.
*/ */
@Composable @Composable
fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) { fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) {
@@ -113,18 +107,18 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
BasicText( BasicText(
text = text, text = text,
modifier = modifier =
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the // A tap here is either a link or the card's; see [LocalMarkdownTap] for why the second
// second one has to be answered from inside the text rather than left to the card. // one has to be answered from inside the text rather than left to the card.
modifier.then(chipFill).pointerInput(text, onPlainTap) { modifier.then(chipFill).pointerInput(text, onPlainTap) {
awaitEachGesture { awaitEachGesture {
// Unconsumed is not required: something outside may already be tracking this // Unconsumed is not required: something outside may already be tracking this
// press, and it is still the press that may land on a link. // press, and it is still the press that may land on a link.
awaitFirstDown(requireUnconsumed = false) awaitFirstDown(requireUnconsumed = false)
// A tap and nothing else. Null when the gesture became something somebody // A tap and nothing else. Null when the gesture became somebody else's -- a
// else's -- a scroll, or a press held past the long-press timeout, which is // scroll, or a press held past the long-press timeout, which is how a selection
// how a selection starts. The timeout is the load-bearing half: without it a // starts. The timeout is the load-bearing half: without it a press held for a
// press held for a second and released was still an up with nothing consumed, // second and released was still an up with nothing consumed, so holding a peer
// so holding a peer message to select from it shut the card instead. // message to select from it shut the card instead.
val up = val up =
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
waitForUpOrCancellation() waitForUpOrCancellation()
@@ -156,27 +150,24 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
* usually -- or null where a plain tap means nothing. * usually -- or null where a plain tap means nothing.
* *
* A composition local because there is nowhere else to put it. The paragraphs of a message are * A composition local because there is nowhere else to put it. The paragraphs of a message are
* composed by the renderer's own dispatch out of its component table, so nothing between a card and * composed by the renderer's own dispatch, so nothing between a card and the text inside it is ours
* the text inside it is ours to pass a parameter through; the renderer already hands its colours, * to pass a parameter through.
* its typography and its components down the same way.
* *
* It exists because a pointer-input node over the glyphs takes the tap and the card's own click * It exists because a pointer-input node over the glyphs takes the tap and the card's own click
* handler never sees it. Measured on the emulator against an opened peer message: with a handler on * handler never sees it. Measured against an opened peer message: with a handler on the text --
* the text -- consuming or not -- a tap on its words did nothing at all, and with the handler * consuming or not -- a tap on its words did nothing at all, and with the handler removed the same
* removed entirely the same tap shut the card. So a card whose body is markdown cannot be shut by * tap shut the card. So a card whose body is markdown cannot be shut by pressing its words unless
* pressing its words unless the words do the shutting, and "nothing happens when I press it" is * the words do the shutting.
* indistinguishable from a card that has stopped working.
* *
* Provided as a value that outlives a recomposition (see [rememberMarkdownTap]), since a fresh * Provided as a value that outlives a recomposition, since a fresh lambda per composition would
* lambda per composition would invalidate every paragraph reading it. * invalidate every paragraph reading it.
*/ */
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null } val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
/** /**
* [onTap] as a stable value to provide for [LocalMarkdownTap]. * [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the
* * behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
* The identity stays put while the behaviour follows the latest [onTap], which is what keeps * under it on every recomposition of the card.
* providing it from invalidating the text under it on every recomposition of the card.
*/ */
@Composable @Composable
fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit { fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit {
@@ -212,9 +203,8 @@ private const val LINK_URL = "url"
* The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's * The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's
* background is part of the text's own drawing, and the text node draws the selection first and the * background is part of the text's own drawing, and the text node draws the selection first and the
* glyphs over it, so a chip painted as a span background covered the selection: selecting a * glyphs over it, so a chip painted as a span background covered the selection: selecting a
* sentence highlighted every word of it except the ones in backticks. Anything drawn by a modifier * sentence highlighted every word except the ones in backticks. Anything drawn by a modifier on the
* on the text is under both, which is where a fenced block's box already is and why one of those * text is under both, which is where a fenced block's box already is.
* always looked right. The [CODE_CHIP] annotation is what says where the fill goes.
*/ */
private fun appendCodeChip( private fun appendCodeChip(
builder: AnnotatedString.Builder, builder: AnnotatedString.Builder,
@@ -242,13 +232,11 @@ private const val CODE_CHIP = "code"
* Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every * Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every
* line but the last, so a chip whose code wrapped left a full-width empty box behind on the line * line but the last, so a chip whose code wrapped left a full-width empty box behind on the line
* above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space * above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space
* stops being drawn: the same rule the selection rectangle obeys, so the two agree rather than the * stops being drawn -- the same rule the selection rectangle obeys, so the two agree.
* chip sticking a space out past the end of a selected line. It is also what leaves nothing behind
* when the only thing to reach a line is the space a chip is padded with.
* *
* A run's extent is taken from the boxes of its first and last characters, which is exact while a * A run's extent is taken from the boxes of its first and last characters, which is exact while a
* line reads in one direction; mixed directions inside a code span would draw one box across the * line reads in one direction; mixed directions inside a code span would draw one box across the
* whole run rather than one per direction, and code spans are code. * whole run, and code spans are code.
*/ */
private fun TextLayoutResult.chipRects(start: Int, end: Int): List<Rect> { private fun TextLayoutResult.chipRects(start: Int, end: Int): List<Rect> {
val rects = mutableListOf<Rect>() val rects = mutableListOf<Rect>()
@@ -34,22 +34,18 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
* *
* The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and * The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and
* is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a * is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a
* hundred short ones; and the list composes an item whole in the frame it scrolls into, so an item * hundred short ones; and the list composes an item whole in the frame it scrolls into. Measured on
* has to be bounded for the worst frame to be. Measured on a Pixel 9 Pro XL, the tallest row still * a Pixel 9 Pro XL, the tallest row still being drawn was 36,982px -- twenty-five screens in one
* being drawn was 36,982px, twenty-five screens in one message. A piece is a paragraph, a fence, a * message. A piece is a paragraph, a fence, a table, one bullet: bounded, so both costs are.
* table, one bullet: bounded, so both costs are.
* *
* Cut where the parser says the blocks are, which is the whole reason this is safe: a fence, a * Cut where the parser says the blocks are, which is what makes it safe: a fence, a table and a
* table and a nested list are each one node whatever is inside them, so nothing is ever split down * nested list are each one node whatever is inside them. A list is the one block that is not
* the middle. A list is the one block that is not bounded -- a reply's list of sources can be forty * bounded -- a reply's list of sources can be forty items -- so it is cut once more, into its
* items -- so it is cut once more, into its items, and a nested list stays inside the item that * items.
* holds it.
* *
* A piece is an *address* into the message's one parse ([block] indexes the root's children, [item] * A piece is an *address* into the message's one parse rather than a substring of it. Every piece
* the list items of that child) rather than a substring of the message. Every piece of a message is * is drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and
* drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and a * a reference definition at its foot still resolves the links above it.
* reference definition at its foot still resolves the links above it -- the two costs of cutting a
* message into strings and parsing each on its own.
*/ */
@Immutable @Immutable
data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) { data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
@@ -59,8 +55,7 @@ data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
} }
/** /**
* The pieces of [parse], in reading order. Blank nodes between blocks -- the parser keeps the * The pieces of [parse], in reading order. Blank nodes between blocks are not pieces.
* newlines -- are not pieces.
* *
* A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a * A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a
* message that drew as nothing would be a hole in the transcript with no sign of what fell out. * message that drew as nothing would be a hole in the transcript with no sign of what fell out.
@@ -90,17 +85,15 @@ fun gapBefore(previous: Piece?, piece: Piece): Dp =
val BLOCK_SPACING: Dp = 6.dp val BLOCK_SPACING: Dp = 6.dp
/** /**
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which is what carries the * [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which carries the theme,
* theme, the components and the reference links to the renderer's element composables. * the components and the reference links to the renderer's element composables.
* *
* A whole block goes to the renderer's own dispatch with this app's component table, so a paragraph * A whole block goes to the renderer's own dispatch with this app's component table. Only the list
* or heading is a [LinkedText], a table is [LinkedTableRow]s, and a nested list comes back here * item is drawn directly, because a list item is the one piece the renderer has no element for.
* through [MarkdownList]. Only the list item is drawn directly, because a list item is the one
* piece the renderer has no element for.
* *
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply (see * [continuesList] and [listContinues] are for a list cut across the segments of a live reply: an
* `LiveParse`): an item that is the first or last of its own parse but not of the list the reader * item that is the first or last of its own parse but not of the list the reader sees keeps an
* sees keeps an inner item's padding, so nothing moves when the seam between segments does. * inner item's padding, so nothing moves when the seam between segments does.
*/ */
@Composable @Composable
fun MarkdownPiece( fun MarkdownPiece(
@@ -112,8 +105,8 @@ fun MarkdownPiece(
listContinues: Boolean = false, listContinues: Boolean = false,
) { ) {
if (parse !is State.Success) { if (parse !is State.Success) {
// The parser threw. Nothing else in the app has seen this happen; if it does, the words // The parser threw. Nothing else in the app has seen this happen; if it does, the words are
// are still worth more than a blank. // still worth more than a blank.
Text(text, modifier, style = MaterialTheme.typography.bodyLarge) Text(text, modifier, style = MaterialTheme.typography.bodyLarge)
return return
} }
@@ -144,8 +137,7 @@ fun MarkdownPiece(
/** /**
* A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a * A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a
* list inside a quote, and the nested lists an item holds. Top-level lists never come here; they * list inside a quote, and the nested lists an item holds. Top-level lists never come here.
* are drawn an item at a time as pieces.
*/ */
@Composable @Composable
fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) { fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) {
@@ -170,9 +162,8 @@ fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier
* list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first * list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first
* and last items, since there is no list column to carry it. * and last items, since there is no list column to carry it.
* *
* The marker is the renderer's bullet and number, and a checkbox for a task item. It is drawn here * The marker is drawn here rather than by a handler because it is the thing a reader might one day
* rather than by a handler because it is the thing a reader might one day want styled -- a * want styled -- a different glyph per depth, a colour -- and this is the one place it is drawn.
* different glyph per depth, a colour -- and this is the one place it is drawn.
*/ */
@Composable @Composable
private fun MarkdownListItem( private fun MarkdownListItem(
@@ -231,8 +222,8 @@ private fun Marker(text: String, style: TextStyle) {
/** /**
* The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a * The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a
* browser draws, so a nested list is told from its parent by the glyph as well as by the indent. * browser draws, so a nested list is told from its parent by the glyph as well as by the indent.
* Checked on the emulator's system fonts, which is what makes them safe to rely on; a glyph the * Checked on the emulator's system fonts; a glyph the platform lacks draws as a box, and that check
* platform lacks draws as a box, and that check is the price of adding one here. * is the price of adding one here.
*/ */
private val BULLETS = listOf("", "", "") private val BULLETS = listOf("", "", "")
@@ -7,23 +7,19 @@ package com.example.aiapp
* Its own scanner rather than a row of [Rules] because markdown has neither keywords nor strings: * Its own scanner rather than a row of [Rules] because markdown has neither keywords nor strings:
* what a character means depends on where it sits. A `#` opens a heading at the start of a line and * what a character means depends on where it sits. A `#` opens a heading at the start of a line and
* is an ordinary character three words in; a `*` opens emphasis only if something closes it on the * is an ordinary character three words in; a `*` opens emphasis only if something closes it on the
* same line. The token scanner cannot ask either question, and answering them with its rules is how * same line. The token scanner cannot ask either question.
* a highlighter comes to grey out the second half of a paragraph.
* *
* Structure is read a line at a time and each line's prose is then read left to right, so every * Structure is read a line at a time and each line's prose left to right, so every decision is made
* decision is made inside one line -- except the two things that are not one line. A fenced block * inside one line -- except the two that are not. A fenced block is state carried forward, so an
* is state carried forward, so an unclosed fence colours the rest of the text, which is also what * unclosed fence colours the rest of the text, which is what it looks like while somebody is
* it looks like while somebody is still writing it. A table is found by its delimiter row * writing it. A table is found by its delimiter row (`|---|---|`), the only line of one that cannot
* (`|---|---|`), which is the only line of one that cannot be anything else, and its header is the * be anything else, and its header is the line before that -- the one place here that looks ahead.
* line before that -- the one place here that looks ahead.
* *
* What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is * What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is
* one, and four spaces after a bullet is a list item's second paragraph, and the two are told apart * one, four spaces after a bullet is a list item's second paragraph, and the two are told apart by
* by what came before rather than by the line itself. Colouring the wrong one of those as code is a * what came before. Colouring the wrong one as code is a mistake the reader cannot see.
* mistake the reader cannot see, so both are left plain, which is the safe answer.
* *
* Like [scan], the spans come out ordered, non-overlapping and inside the text by construction: * Like [scan], the spans come out ordered, non-overlapping and inside the text by construction.
* every one is emitted by a pass that only moves forward, and nothing here throws.
*/ */
fun scanMarkdown(code: String): List<Span> = MarkdownScanner(code).run() fun scanMarkdown(code: String): List<Span> = MarkdownScanner(code).run()
@@ -36,7 +32,7 @@ private const val RULE_MARKERS = "-*_="
/** The characters that can open emphasis, strong emphasis or a strikethrough. */ /** The characters that can open emphasis, strong emphasis or a strikethrough. */
private const val EMPHASIS = "*_~" private const val EMPHASIS = "*_~"
/** Characters that end a bare URL wherever they appear in it, and ones only trimmed off the end. */ /** Characters that end a bare URL wherever they appear, and ones only trimmed off the end. */
private const val URL_STOPS = "<>\"'`|" private const val URL_STOPS = "<>\"'`|"
private const val URL_TRAILING = ".,:;!?" private const val URL_TRAILING = ".,:;!?"
@@ -53,8 +49,8 @@ private class MarkdownScanner(private val code: String) {
val end = lineEnd(at) val end = lineEnd(at)
val open = fence val open = fence
if (open != null) { if (open != null) {
// The content and the closing line alike: a fence is one block of code, and its // The content and the closing line alike: a fence is one block of code, and its own
// own delimiters belong to it the way a string's quotes belong to the string. // delimiters belong to it the way a string's quotes belong to the string.
emit(at, end, Kind.STRING) emit(at, end, Kind.STRING)
if (closesFence(at, end, open)) fence = null if (closesFence(at, end, open)) fence = null
} else { } else {
@@ -77,11 +73,10 @@ private class MarkdownScanner(private val code: String) {
/** /**
* One line that is not inside a fence, and whether the table it may be part of is still open. * One line that is not inside a fence, and whether the table it may be part of is still open.
* *
* A table is recognised by its delimiter row (`|---|---|`), which is the only line of one that * A table is recognised by its delimiter row, the only line of one that cannot be anything
* cannot be anything else. That row comes *after* the header it belongs to, so the header is * else. That row comes *after* the header it belongs to, so the header is found by looking one
* found by looking one line ahead -- the single piece of lookahead here, and cheaper than the * line ahead -- the single piece of lookahead here, and cheaper than colouring every `|` in the
* alternative of colouring every `|` in the document, which would mark the pipes in a shell * document, which would mark the pipes in a shell command written in a paragraph.
* command written in a paragraph.
*/ */
private fun row(start: Int, end: Int, table: Boolean): Boolean { private fun row(start: Int, end: Int, table: Boolean): Boolean {
if (tableDelimiter(start, end)) { if (tableDelimiter(start, end)) {
@@ -142,10 +137,9 @@ private class MarkdownScanner(private val code: String) {
} }
/** /**
* Spans, coalesced with the one before when they touch and agree. * Spans, coalesced with the one before when they touch and agree. Worth doing here rather than
* * leaving it to the caller: the line scanner emits per marker and per word, so a heading would
* Worth doing here rather than leaving it to the caller: the line scanner emits per marker and * otherwise arrive as a dozen abutting spans of one colour.
* per word, so a heading would otherwise arrive as a dozen abutting spans of one colour.
*/ */
private fun emit(start: Int, end: Int, kind: Kind) { private fun emit(start: Int, end: Int, kind: Kind) {
if (end <= start) return if (end <= start) return
@@ -179,17 +173,16 @@ private class MarkdownScanner(private val code: String) {
private fun opensFence(start: Int, end: Int): String? { private fun opensFence(start: Int, end: Int): String? {
val run = fenceRun(start, end) ?: return null val run = fenceRun(start, end) ?: return null
emit(run.first, run.last + 1, Kind.STRING) emit(run.first, run.last + 1, Kind.STRING)
// The info word is what the fence is a fence *of*, which is metadata about the block // The info word is what the fence is a fence *of*, which is metadata about the block rather
// rather than part of it -- the same reading as a Rust attribute above a struct. // than part of it.
emit(indented(run.last + 1, end), end, Kind.METADATA) emit(indented(run.last + 1, end), end, Kind.METADATA)
return code.substring(run.first, run.last + 1) return code.substring(run.first, run.last + 1)
} }
/** /**
* Whether this line closes a fence opened by [open]. * Whether this line closes a fence opened by [open]: the same character, at least as many of
* * them, and nothing else on the line -- so a longer run closes a shorter one and a line of
* The same character, at least as many of them, and nothing else on the line -- so a longer run * backticks with a word after it does not close anything.
* closes a shorter one and a line of backticks with a word after it does not close anything.
*/ */
private fun closesFence(start: Int, end: Int, open: String): Boolean { private fun closesFence(start: Int, end: Int, open: String): Boolean {
val run = fenceRun(start, end) ?: return false val run = fenceRun(start, end) ?: return false
@@ -227,10 +220,9 @@ private class MarkdownScanner(private val code: String) {
* A line made of one repeated rule character and nothing else. * A line made of one repeated rule character and nothing else.
* *
* `---`, `***` and `___` are thematic breaks; `===` and `---` are also the underline of a * `---`, `***` and `___` are thematic breaks; `===` and `---` are also the underline of a
* setext heading. The two are the same line to look at and mean the same thing to a reader -- a * setext heading. The two are the same line to look at and mean the same thing to a reader, so
* rule drawn across the page -- so they get one appearance rather than a lookback to tell them * they get one appearance rather than a lookback. One `=` is enough because a setext underline
* apart. One `=` is enough because a setext underline may be a single character; a break needs * may be a single character; a break needs three, which keeps a `- ` bullet out of here.
* three, which is what keeps a `- ` bullet out of here.
*/ */
private fun thematicBreak(start: Int, end: Int): Boolean { private fun thematicBreak(start: Int, end: Int): Boolean {
val marker = code[start] val marker = code[start]
@@ -292,10 +284,9 @@ private class MarkdownScanner(private val code: String) {
} }
/** /**
* `` `code` ``, closed by a run of exactly as many backticks as opened it. * `` `code` ``, closed by a run of exactly as many backticks as opened it. That count is what
* * lets a span hold a backtick of its own, and why the search skips over a shorter or longer run
* That count is what lets a span hold a backtick of its own (``` ``a ` b`` ```), and it is why * rather than stopping at the first backtick.
* the search skips over a shorter or longer run rather than stopping at the first backtick.
*/ */
private fun codeSpan(start: Int, end: Int): Int { private fun codeSpan(start: Int, end: Int): Int {
var open = start var open = start
@@ -323,9 +314,8 @@ private class MarkdownScanner(private val code: String) {
* `[text](destination)`, and the same with a leading `!` for an image. * `[text](destination)`, and the same with a leading `!` for an image.
* *
* The text is drawn as prose -- it is what the reader reads -- so only the brackets around it * The text is drawn as prose -- it is what the reader reads -- so only the brackets around it
* are marked, and the destination is metadata: the place the link goes rather than anything * are marked, and the destination is metadata. A `[text]` with no destination after it is left
* said to the reader. A `[text]` with no destination after it is left plain, because that is * plain, because that is what a reference link and a bracketed aside look like.
* what a reference link and a bracketed aside look like, and neither is worth guessing at.
*/ */
private fun link(start: Int, bracket: Int, end: Int): Int { private fun link(start: Int, bracket: Int, end: Int): Int {
var depth = 0 var depth = 0
@@ -357,8 +347,7 @@ private class MarkdownScanner(private val code: String) {
* `<https://example.com>` and `<name@example.com>`, drawn as the destination they are. * `<https://example.com>` and `<name@example.com>`, drawn as the destination they are.
* *
* The angle brackets have to hold no whitespace and something that makes an address of it -- a * The angle brackets have to hold no whitespace and something that makes an address of it -- a
* scheme's colon or an at sign -- which is what keeps an HTML tag out: `<div>` has neither, and * scheme's colon or an at sign -- which is what keeps an HTML tag out.
* `<img src="http://x">` has the colon but also a space.
*/ */
private fun autolink(start: Int, end: Int): Int { private fun autolink(start: Int, end: Int): Int {
var at = start + 1 var at = start + 1
@@ -380,13 +369,12 @@ private class MarkdownScanner(private val code: String) {
/** /**
* A bare `scheme://…` written in prose, or null if one does not start here. * A bare `scheme://…` written in prose, or null if one does not start here.
* *
* A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry, and * A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry.
* the pair of colons is what makes the match unambiguous enough to draw without a closer.
* *
* Where it ends is the part worth stating: the sentence's punctuation is not the address, so a * Where it ends is the part worth stating: the sentence's punctuation is not the address, so a
* trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the * trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the
* URL -- otherwise a link in parentheses loses its `)` to the address. A pipe stops it too, * URL -- otherwise a link in parentheses loses its `)`. A pipe stops it too, because a URL in a
* because a URL in a table cell must not swallow the cell's edge. * table cell must not swallow the cell's edge.
*/ */
private fun url(start: Int, end: Int): Int? { private fun url(start: Int, end: Int): Int? {
if (start > 0 && isWord(code[start - 1])) return null if (start > 0 && isWord(code[start - 1])) return null
@@ -415,14 +403,13 @@ private class MarkdownScanner(private val code: String) {
} }
/** /**
* `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all. * `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all -- which is how the
* token scanner draws a string: the quotes are part of the thing.
* *
* Markers and all because that is how the token scanner draws a string: the quotes are part of * The two guards keep this off code that happens to be in a paragraph: the opener must be
* the thing. The two guards are what keep this off code that happens to be in a paragraph -- * followed by something to emphasise and the closer preceded by something emphasised, so `a * b
* the opener must be followed by something to emphasise and the closer preceded by something * * c` opens nothing and neither does the `*p = *q` of a C fragment. Underscores may not start
* emphasised, so `a * b * c` opens nothing and neither does the `*p = *q` of a C fragment. * or end inside a word, or every `snake_case_name` would be half emphasised.
* Underscores additionally may not start or end inside a word, or every `snake_case_name` in a
* document would be half emphasised.
*/ */
private fun emphasis(start: Int, end: Int): Int { private fun emphasis(start: Int, end: Int): Int {
val marker = code[start] val marker = code[start]
@@ -25,12 +25,11 @@ import androidx.compose.ui.unit.dp
* Claude Code marks a sentence that came from its stored memory by wrapping it in `<cc-memory * Claude Code marks a sentence that came from its stored memory by wrapping it in `<cc-memory
* filenames="...">`. Markdown has nothing to say about that, so it arrived on screen as literal * filenames="...">`. Markdown has nothing to say about that, so it arrived on screen as literal
* angle brackets in the middle of a sentence -- which reads as the model having emitted broken * angle brackets in the middle of a sentence -- which reads as the model having emitted broken
* HTML. It is really the opposite: a claim about where something came from, which is worth showing, * HTML. It is really the opposite: a claim about where something came from, and "I was told this
* because "I was told this before" and "I worked this out just now" are different things and the * before" and "I worked this out just now" are different things the reader cannot otherwise tell
* reader cannot otherwise tell them apart. * apart.
* *
* A tag that has not finished arriving is left alone. Streaming means the closing tag may be * A tag that has not finished arriving is left alone: a half-written marker is not a marker yet.
* seconds away, and a half-written marker is not a marker yet.
*/ */
@Composable @Composable
fun AssistantMessage( fun AssistantMessage(
@@ -66,12 +65,10 @@ fun AssistantMessage(
* A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed * A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed
* prose part made while looking for them -- inspecting a message must not change it. That belongs * prose part made while looking for them -- inspecting a message must not change it. That belongs
* here rather than at the places that need the answer, because [warm] has to name the same strings * here rather than at the places that need the answer, because [warm] has to name the same strings
* the rows draw: a string warmed under a key no row ever looks up is a miss that nothing reports, * the rows draw: a string warmed under a key no row ever looks up is a miss nothing reports.
* and the row pays the parse in the frame it appears, which is the cost being removed.
* *
* Public because [transcriptUnits] flattens settled replies into the same parts; go through * Public because [transcriptUnits] flattens settled replies into the same parts; go through
* [ParsedReplies.partsOf] on any path that runs per fold or per page, so the scan happens once per * [ParsedReplies.partsOf] on any path that runs per fold or per page.
* message.
*/ */
fun messageParts(text: String): List<MessagePart> { fun messageParts(text: String): List<MessagePart> {
val parts = splitMemoryNotes(text) val parts = splitMemoryNotes(text)
@@ -83,16 +80,14 @@ fun messageParts(text: String): List<MessagePart> {
* *
* Closed by default, like a tool call and a peer message and for the same reason: it is not part of * Closed by default, like a tool call and a peer message and for the same reason: it is not part of
* what was said to the reader, it is a note about where a claim came from. Left open it breaks the * what was said to the reader, it is a note about where a claim came from. Left open it breaks the
* reply in half around a card, which reads as the answer having stopped and restarted -- and these * reply in half around a card, and these arrive several to a message.
* arrive several to a message.
* *
* What stays visible is which file it came from, because that is the whole of what the note claims * What stays visible is which file it came from, because that is the whole of what the note claims
* and it is the part a reader scanning for "why does it think that" is looking for. * and the part a reader scanning for "why does it think that" is looking for.
* *
* Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to * Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to
* still be open on the way back, and a card that remembered for itself would forget the moment the * still be open on the way back, and a card that remembered for itself would forget the moment the
* list stopped composing it. The text is a good enough name -- it does not change once the closing * list stopped composing it.
* tag has arrived, so a note stays open across the moment its reply settles.
*/ */
@Composable @Composable
fun MemoryNote( fun MemoryNote(
@@ -148,10 +143,8 @@ private val MEMORY_NOTE =
Regex("""<cc-memory\s+filenames="([^"]*)"\s*>(.*?)</cc-memory>""", RegexOption.DOT_MATCHES_ALL) Regex("""<cc-memory\s+filenames="([^"]*)"\s*>(.*?)</cc-memory>""", RegexOption.DOT_MATCHES_ALL)
/** /**
* Splits [text] into prose and memory notes, in order. * Splits [text] into prose and memory notes, in order. Always returns at least one part, so a
* * message with no notes is one piece of prose and costs nothing extra to draw.
* Always returns at least one part, so a message with no notes in it is one piece of prose and
* costs nothing extra to draw.
*/ */
fun splitMemoryNotes(text: String): List<MessagePart> { fun splitMemoryNotes(text: String): List<MessagePart> {
val parts = mutableListOf<MessagePart>() val parts = mutableListOf<MessagePart>()
@@ -5,26 +5,23 @@ package com.example.aiapp
* *
* One constant rather than a literal in each place, because the two have to agree: a picker whose * One constant rather than a literal in each place, because the two have to agree: a picker whose
* options cannot say every state its button can display is one you can leave and not get back to. * options cannot say every state its button can display is one you can leave and not get back to.
* It is also the Claude CLI's own word for "whatever is configured", so choosing it is a request * It is also the Claude CLI's own word for "whatever is configured".
* the session can act on rather than a name this app made up.
*/ */
const val DEFAULT_MODEL = "default" const val DEFAULT_MODEL = "default"
/** /**
* A model's name as a person reads it. * A model's name as a person reads it.
* *
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to * Providers answer with their own full identifier -- Claude Code resolves `haiku` to `claude-
* `claude-haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session * haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session using"
* using" and far too long for a button in a row that also has to hold Stop and Send. * and far too long for a button in a row that also holds Stop and Send.
* *
* So the two ends that identify nothing are dropped and nothing else is: the vendor prefix, which * So the two ends that identify nothing are dropped and nothing else is: the vendor prefix, which
* is the same on every model this app can show, and the release date, which distinguishes builds of * is the same on every model this app can show, and the release date, which distinguishes builds of
* one model rather than one model from another. What is left is the part somebody chose -- * one model rather than one model from another. Anything that does not look like that is returned
* `haiku-4-5` -- and anything that does not look like that is returned untouched, since a name this * untouched.
* does not recognise is a name it has no business editing.
* *
* A display decision, not a correction: the full name is what the session reports and what a reader * A display decision, not a correction: the full name is what the session reports.
* is shown when there is room for it.
*/ */
fun modelLabel(model: String?): String { fun modelLabel(model: String?): String {
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
@@ -57,12 +57,9 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
} }
} }
// Polled rather than pushed: a download belongs to the machine, not to // Polled rather than pushed: a download belongs to the machine, not to any session, so it has
// any session, so it has no event stream of its own. Slow enough not // no event stream of its own. Keyed on the token as well, so the header's Refresh restarts the
// to matter, frequent enough that a bar moves. // loop with a read now rather than leaving the reader watching for a second and a half.
// Keyed on the token as well, so the header's Refresh restarts the loop with a read now
// rather than leaving the reader watching for up to a second and a half to see whether
// anything happened.
LaunchedEffect(reloadToken) { LaunchedEffect(reloadToken) {
while (true) { while (true) {
reload() reload()
@@ -186,11 +183,9 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
} }
} }
} }
// Inside the expanded repository's own item // Inside the expanded repository's own item rather than as a section
// rather than as a section after the list: // after the list: drawn after every card, a repository's files read as
// drawn after every card, a repository's files // belonging to whichever card happened to be last.
// read as belonging to whichever card happened
// to be last.
if (open) { if (open) {
when (val files = repoFiles) { when (val files = repoFiles) {
null -> {} null -> {}
@@ -257,15 +252,15 @@ private fun DownloadCard(download: Download, onCancel: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// A determinate bar only when the size is known. The server // A determinate bar only when the size is known. The server sends no total when it was
// sends no total when it was never told one, and a bar drawn // never told one, and a bar drawn from a guess is worse than one that admits it is
// from a guess is worse than one that admits it is counting. // counting.
if (download.total != null && download.total > 0) { if (download.total != null && download.total > 0) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() }, progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is // Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say // nearing success, and colouring it like a limit being approached would say the
// the opposite of what is happening. // opposite.
color = progressColor, color = progressColor,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@@ -327,8 +322,8 @@ private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
repo.id, repo.id,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
// The owner is the part that repeats; the model name at // The owner is the part that repeats; the model name at the end is what tells
// the end is what tells two entries apart. // two entries apart.
overflow = TextOverflow.StartEllipsis, overflow = TextOverflow.StartEllipsis,
) )
Text( Text(
@@ -356,11 +351,9 @@ private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
// Disabled rather than absent, so the row reads the same whether // Disabled rather than absent, so the row reads the same whether this one is absent,
// this one is absent, already here, or on its way. Offering // already here, or on its way. Offering "Download" for a file that is downloading would be
// "Download" for a file that is downloading would be a button that // a button that does nothing anyone can see.
// does nothing anyone can see -- the server joins the running
// download rather than starting a second.
TextButton(enabled = !file.have && !downloading, onClick = onDownload) { TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
Text( Text(
when { when {
@@ -26,26 +26,21 @@ import androidx.compose.ui.unit.sp
* set and kept in step by hand. * set and kept in step by hand.
* *
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the * This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
* grounds that a system font may not have the glyph and whoever gets the empty box instead is never * grounds that a system font may not have the glyph. That objection is about *relying* on a system
* the person who wrote it. That objection is about *relying* on a system font, and it is exactly * font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
* right: the answer is not to avoid glyphs but to ship them. The font here is * is `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
* `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols * font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
* font and committed -- so the codepoints below are resolved by an asset in the APK and cannot come * the script did not subset is a glyph that silently isn't there.
* back as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the
* script did not subset is a glyph that silently isn't there.
* *
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall. * The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
* That is what makes two icons the same size without either of them being given a size: the * That is what makes two icons the same size without either being given a size: the proportional
* proportional face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side * face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side by side came
* by side came out visibly different widths, and matching them at the call site would have meant * out visibly different widths. [GLYPH_SIZE] carries the cost.
* one hardcoded measurement per pair. [GLYPH_SIZE] carries the cost.
* *
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two * The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
* Material Design codepoints. Those two must not drift: an icon that means "settings" in one app * Material Design codepoints. Those two must not drift. The script is copied rather than shared
* and something else in the other is the failure this is worth preventing. The script is copied * because most of what looks like duplication is the `GLYPHS` list, which has to differ -- the
* rather than shared because most of what looks like duplication is the `GLYPHS` list, which has to * point of subsetting is to ship only the codepoints one app draws.
* differ -- the point of subsetting is to ship only the codepoints one app draws. All Material
* Design bar one, so they read as one family; the exception is noted where it is declared.
*/ */
val NerdIcons = FontFamily(Font(R.font.nerd_icons)) val NerdIcons = FontFamily(Font(R.font.nerd_icons))
@@ -74,8 +69,7 @@ val STOP_GLYPH = glyph(0xF04DB)
* *
* The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what * The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what
* pressing it now would do to the process, and the three marks are the three answers. An interrupt * pressing it now would do to the process, and the three marks are the three answers. An interrupt
* ends a turn and nothing else -- the CLI is still there and still holds the conversation -- which * ends a turn and nothing else, which is a pause, not a stop.
* is a pause, not a stop, and drawing it as a square said otherwise.
*/ */
val PAUSE_GLYPH = glyph(0xF03E4) val PAUSE_GLYPH = glyph(0xF03E4)
@@ -86,8 +80,8 @@ val PLAY_GLYPH = glyph(0xF040A)
* `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn. * `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn.
* *
* The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than * The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than
* starting one, and the two buttons have to be told apart at a glance -- one glyph doing both jobs * starting one, and one glyph doing both jobs would promise something immediate and do something
* while looking identical would promise something immediate and do something that waits. * that waits.
*/ */
val QUEUE_GLYPH = glyph(0xF1163) val QUEUE_GLYPH = glyph(0xF1163)
@@ -104,8 +98,7 @@ val BELL_GLYPH = glyph(0xF009A)
* `fa-line_chart` -- how much of the account's rate limits is gone. * `fa-line_chart` -- how much of the account's rate limits is gone.
* *
* Font Awesome's rather than Material's, which is the one break in the family above: it was asked * Font Awesome's rather than Material's, which is the one break in the family above: it was asked
* for by name, and Material's chart glyphs are a bare line where this one has its axes, which is * for by name, and Material's chart glyphs are a bare line where this one has its axes.
* what makes it read as a measurement rather than as a trend.
*/ */
val USAGE_GLYPH = glyph(0xF201) val USAGE_GLYPH = glyph(0xF201)
@@ -121,9 +114,8 @@ val SPEED_GLYPH = glyph(0xF04C5)
* `md-folder` -- the files on the machine this session runs on. * `md-folder` -- the files on the machine this session runs on.
* *
* The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and * The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and
* the refresh arrow must not: a folder that meant something else in one of the two apps is exactly * the refresh arrow must not. Doubles as the mark on a directory row inside the explorer, which is
* the confusion sharing them prevents. Doubles as the mark on a directory row inside the explorer, * what makes the button say where it leads.
* which is what makes the button say where it leads.
*/ */
val FOLDER_GLYPH = glyph(0xF024B) val FOLDER_GLYPH = glyph(0xF024B)
@@ -148,10 +140,9 @@ val SAVE_GLYPH = glyph(0xF0193)
* The size an icon draws at beside a line of text. * The size an icon draws at beside a line of text.
* *
* 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at * 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at
* most 0.83 em of its point size and most filled a good deal less, so the number was standing in * most 0.83 em of its point size, so the number was standing in for the headroom above the tallest
* for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and * one; in the Mono face every glyph fills its em exactly, and keeping 20 would have stepped every
* keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked * icon in the app up by a fifth.
* for. This is what the largest of them already drew at.
*/ */
private val GLYPH_SIZE = 17.sp private val GLYPH_SIZE = 17.sp
@@ -165,16 +156,14 @@ private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
* *
* The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to * The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to
* the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of * the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of
* its own, and a mark cannot end up further from the button beside it than from the edge of the * its own. That is what it was: the box was the size of the mark (28dp) and the separation was
* screen. That is what it was: the box was the size of the mark (28dp) and the separation was
* bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the * bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the
* edge, so a pair that acts on one screen read as two unrelated marks with one falling off it. * edge.
* *
* 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has * 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has
* to find. It is what the pressed-state ripple draws, too: at 28dp that circle was inscribed in the * to find, and what the pressed-state ripple draws: at 28dp that circle was inscribed in the mark's
* mark's own corners, and beside a title it arrived at the first letter. And it is taller than any * own corners and beside a title it arrived at the first letter. And it is taller than any header's
* header's text, which is what lets the button fill a header row rather than sit in the middle of * text, which is what lets the button fill a header row rather than sit in the middle of one.
* one -- the rows add no vertical padding of their own for the same reason they add no gap.
*/ */
private val GLYPH_BUTTON_SIZE = 48.dp private val GLYPH_BUTTON_SIZE = 48.dp
@@ -182,11 +171,9 @@ private val GLYPH_BUTTON_SIZE = 48.dp
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside * The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
* a back arrow. * a back arrow.
* *
* Two glyph buttons need nothing between them: each brings its own ring and the two add up, which * Two glyph buttons need nothing between them: each brings its own ring and the two add up. Text
* is why a row of them sets no spacing. Text brings none, so the second ring has to be asked for. * brings none, so the second ring has to be asked for -- without it the pressed-state circle
* Without it the pressed-state circle, which fills the whole square, arrives at the first letter of * arrives at the first letter of the title.
* the title -- and the gap a reader sees between the mark and that title is then half the one
* between the two marks at the other end of the same row.
*/ */
val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2 val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
@@ -195,8 +182,7 @@ val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
* *
* Its own composable so that every icon button in the app is one size and one colour without each * Its own composable so that every icon button in the app is one size and one colour without each
* caller saying so, and so the [label] none of them displays is still there for a screen reader -- * caller saying so, and so the [label] none of them displays is still there for a screen reader --
* which is all assistive technology has to go on, and also the answer to "what was that button for" * which is also the answer to "what was that button for" six months from now.
* six months from now.
* *
* [enabled] is passed through rather than left to callers hiding the button: a control that comes * [enabled] is passed through rather than left to callers hiding the button: a control that comes
* and goes makes its own absence the signal, and absence cannot say whether there was nothing to do * and goes makes its own absence the signal, and absence cannot say whether there was nothing to do
@@ -220,9 +206,8 @@ fun GlyphButton(
* The same square, around a mark that is not a glyph. * The same square, around a mark that is not a glyph.
* *
* A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the * A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the
* size, spacing and touch target every other icon button on this app's headers already is -- so * size, spacing and touch target every other icon button already is. The caller still owes it a
* this is [GlyphButton] with the mark left to the caller rather than a second set of measurements * [label]: nothing here draws a word.
* beside it. The caller still owes it a [label]: nothing here draws a word.
*/ */
@Composable @Composable
fun MarkButton( fun MarkButton(
@@ -245,9 +230,7 @@ fun MarkButton(
* The square a glyph button occupies, with a spinner in it instead of a mark. * The square a glyph button occupies, with a spinner in it instead of a mark.
* *
* For a button whose work is under way. It takes the button's whole box rather than the mark's, so * For a button whose work is under way. It takes the button's whole box rather than the mark's, so
* swapping one for the other leaves everything in the row exactly where it was -- a control that * swapping one for the other leaves everything in the row exactly where it was.
* changed the width of its header while it worked would move its neighbours at the moment somebody
* was pressing them.
*/ */
@Composable @Composable
fun GlyphSpinner(label: String, modifier: Modifier = Modifier) { fun GlyphSpinner(label: String, modifier: Modifier = Modifier) {
@@ -273,10 +256,9 @@ fun Glyph(
size: TextUnit = GLYPH_SIZE, size: TextUnit = GLYPH_SIZE,
) { ) {
// Line height of the point size, which for this font is the square the glyph draws in: its // Line height of the point size, which for this font is the square the glyph draws in: its
// ascent and descent add up to exactly one em, and every glyph in the Mono face fills that em. // ascent and descent add up to exactly one em. Left to the inherited body style the line box
// Left to the inherited body style the line box was 24sp tall around a 17sp-wide mark, so a // was 24sp tall around a 17sp-wide mark, so a glyph took a seventh more vertical space than
// glyph took a seventh more vertical space than horizontal wherever one is drawn without a box // horizontal.
// around it -- and where there is a box, that leading is what its padding is measured through.
Text( Text(
glyph, glyph,
fontFamily = NerdIcons, fontFamily = NerdIcons,
@@ -34,12 +34,11 @@ import org.json.JSONObject
* gets a push from Google's servers, which would mean this backend talking to Google about * gets a push from Google's servers, which would mean this backend talking to Google about
* somebody's coding sessions, and the whole point of the tunnel is that it does not. * somebody's coding sessions, and the whole point of the tunnel is that it does not.
* *
* The cost Android charges for it is a notification of its own that cannot be dismissed. That is * The cost Android charges is a notification of its own that cannot be dismissed. That is made as
* made as quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no * quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
* sound, shows no status-bar icon, and sits at the bottom of the shade -- the same arrangement * no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
* Syncthing's "hide the persistent notification" option produces. It is not hidden outright, * cannot be and because it should not be: it is the honest indicator that something is holding a
* because it cannot be and because it should not be: it is the honest indicator that something is * connection open.
* holding a connection open.
*/ */
class NotificationService : Service() { class NotificationService : Service() {
@Volatile private var stream: HttpURLConnection? = null @Volatile private var stream: HttpURLConnection? = null
@@ -50,18 +49,18 @@ class NotificationService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val settings = loadServerSettings(this) val settings = loadServerSettings(this)
if (settings == null) { if (settings == null) {
// Nothing to connect to. Stopping rather than idling: a service // Nothing to connect to. Stopping rather than idling: a service holding no connection
// holding no connection still costs the ongoing notification, // still costs the ongoing notification, which would be announcing work that is not
// which would then be announcing work that is not happening. // happening.
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
// Through ServiceCompat so the type is stated once and ignored on // Through ServiceCompat so the type is stated once and ignored on the versions that predate
// the versions that predate types, rather than branching here. // types, rather than branching here.
ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType()) ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType())
thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) } thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) }
// Restarted if Android kills it, which is the whole point: the // Restarted if Android kills it, which is the whole point: the window this covers is
// window this covers is exactly the one where nobody is watching. // exactly the one where nobody is watching.
return START_STICKY return START_STICKY
} }
@@ -73,11 +72,10 @@ class NotificationService : Service() {
/** /**
* Follows the backend's notification stream, reconnecting until stopped. * Follows the backend's notification stream, reconnecting until stopped.
* *
* A dropped connection is the ordinary case here rather than an error -- a phone changes * A dropped connection is the ordinary case here rather than an error, so it retries quietly
* networks, the tunnel comes and goes, the backend restarts -- so it retries quietly and * and forever. Nothing is shown when it cannot connect: a notification saying "I could not tell
* forever. Nothing is shown when it cannot connect: a notification saying "I could not tell you * you whether anything happened" is noise about a condition nobody can act on, and the session
* whether anything happened" on a phone in somebody's pocket is noise about a condition they * list already says what is waiting when they next look.
* cannot act on, and the session list already says what is waiting when they next look.
*/ */
private fun follow(settings: ServerSettings) { private fun follow(settings: ServerSettings) {
while (!stopping) { while (!stopping) {
@@ -102,8 +100,8 @@ class NotificationService : Service() {
try { try {
connection.applyPinnedTls() connection.applyPinnedTls()
connection.connectTimeout = CONNECT_TIMEOUT_MS connection.connectTimeout = CONNECT_TIMEOUT_MS
// No read timeout, for the reason EventStream gives: between // No read timeout, for the reason EventStream gives: between notifications there is
// notifications there is nothing to read, possibly for hours. // nothing to read, possibly for hours.
connection.readTimeout = 0 connection.readTimeout = 0
connection.setRequestProperty("Authorization", "Bearer ${settings.token}") connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
connection.setRequestProperty("Accept", "text/event-stream") connection.setRequestProperty("Accept", "text/event-stream")
@@ -134,28 +132,23 @@ class NotificationService : Service() {
* *
* Keyed by session id rather than accumulating: two sessions wanting attention are two things * Keyed by session id rather than accumulating: two sessions wanting attention are two things
* to know about, but one session that finished and then asked a question is one thing -- the * to know about, but one session that finished and then asked a question is one thing -- the
* question. A stack of stale rows for the same conversation is how a notification drawer * question. A stack of stale rows is how a drawer becomes something to clear rather than read.
* becomes something to clear rather than read.
*/ */
private fun show(notification: SessionNotification) { private fun show(notification: SessionNotification) {
// Nothing to tell somebody about the session they are reading. The transcript in front of // Nothing to tell somebody about the session they are reading. The transcript in front of
// them is already saying it, and a sound over the top of it would be this app announcing // them is already saying it.
// what the screen is showing.
if (isOnScreen(notification.sessionId)) return if (isOnScreen(notification.sessionId)) return
// The app is up: it says this itself, as a banner over whatever screen they are on. See // The app is up: it says this itself, as a banner over whatever screen they are on. Never
// [forTheScreen]. Never both -- one thing happened, and a drawer filling up behind an // both -- one thing happened, and a drawer filling up behind an app that already showed you
// app that already showed you each one is a drawer nobody reads. // each one is a drawer nobody reads.
if (handOver(notification)) return if (handOver(notification)) return
val manager = NotificationManagerCompat.from(this) val manager = NotificationManagerCompat.from(this)
// Two different noes, and both are answers rather than faults: the runtime permission // Two different noes, and both are answers rather than faults: the runtime permission
// refused, and notifications switched off for the app in Android's own settings. Neither // refused, and notifications switched off for the app in Android's own settings.
// is reported anywhere -- the person said no, and saying it back to them through the
// channel they closed is not available anyway.
// //
// The permission only exists from Android 13. Asking an older version about it gets // The permission only exists from Android 13. Asking an older version about it gets
// "denied" for a name it does not know, which read as the person having said no -- so // "denied" for a name it does not know, which read as the person having said no -- so every
// every notification on Android 12 and below was silently dropped. Before 13 the // notification on Android 12 and below was silently dropped.
// switch in Android's own settings, checked below, is the whole of the answer.
val allowed = val allowed =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
@@ -187,9 +180,8 @@ class NotificationService : Service() {
* The type Android 14+ requires a foreground service to declare, and nothing before it. * The type Android 14+ requires a foreground service to declare, and nothing before it.
* *
* Named behind a version check rather than passed as a constant: the value is inlined at * Named behind a version check rather than passed as a constant: the value is inlined at
* compile time and would be handed to platforms that have no concept of it, which is exactly * compile time and would be handed to platforms that have no concept of it, which is what
* the case lint's InlinedApi exists to catch. Zero is what ServiceCompat wants where types do * lint's InlinedApi exists to catch.
* not apply.
*/ */
private fun foregroundType(): Int = private fun foregroundType(): Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
@@ -226,11 +218,10 @@ class NotificationService : Service() {
/** /**
* Two channels, because they are two different things to be told. * Two channels, because they are two different things to be told.
* *
* The alerts are what somebody turned this on for, so they get the default importance and * The alerts are what somebody turned this on for, so they get the default importance. The
* whatever sound and heads-up display the person has chosen for the app. The ongoing one is * ongoing one is the platform's tax for staying connected, so it takes the lowest
* the platform's tax for staying connected, so it takes the lowest importance that exists. * importance that exists. Both are created before the service starts, since posting to a
* Both are created before the service starts, since posting to a channel that does not * channel that does not exist is silently dropped.
* exist is silently dropped.
*/ */
private fun createChannels(context: Context) { private fun createChannels(context: Context) {
val manager = NotificationManagerCompat.from(context) val manager = NotificationManagerCompat.from(context)
@@ -256,12 +247,10 @@ class NotificationService : Service() {
* The session somebody is looking at, or null when no screen is showing one. * The session somebody is looking at, or null when no screen is showing one.
* *
* Process-wide state, which the rest of this app does without: Android constructs the * Process-wide state, which the rest of this app does without: Android constructs the
* service and the composition draws the screen, so the two have no common owner a value * service and the composition draws the screen, so the two have no common owner. Clearing
* could be passed through. [showing] and [stoppedShowing] are the pair, both called from * names the session rather than setting null outright, because moving from one session to
* the one composable that shows a session. Clearing names the session rather than setting * another composes the new screen before the old one's coroutine is cancelled -- an
* null outright, because moving from one session to another composes the new screen before * unconditional clear would throw away the new screen's claim.
* the old one's coroutine is cancelled -- an unconditional clear would then throw away the
* new screen's claim and start notifying about what is on it.
*/ */
@Volatile private var onScreen: String? = null @Volatile private var onScreen: String? = null
@@ -271,10 +260,9 @@ class NotificationService : Service() {
* The way a notification reaches the app instead of Android's drawer. * The way a notification reaches the app instead of Android's drawer.
* *
* Whether there is an app to reach is the subscriber count rather than a flag of its own: * Whether there is an app to reach is the subscriber count rather than a flag of its own:
* [SessionAlerts] collects this exactly while it is on screen, so there is nothing that * [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
* could be left saying the app is up after it has gone. `tryEmit` neither suspends nor * nor blocks the thread reading the stream, and the buffer is there so a handful of
* blocks the thread reading the stream, and the buffer is there so a handful of sessions * sessions finishing together all land rather than the last one winning.
* finishing together all land rather than the last one winning.
*/ */
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8) private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
@@ -287,9 +275,8 @@ class NotificationService : Service() {
/** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */ /** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
fun showing(context: Context, sessionId: String) { fun showing(context: Context, sessionId: String) {
onScreen = sessionId onScreen = sessionId
// Whatever was posted about it before is about to be read, so it has nothing left // Whatever was posted about it before is about to be read, so it has nothing left to
// to say -- and a row in the drawer for the conversation on screen is the same // say.
// duplication this whole rule is about.
NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID) NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID)
} }
@@ -311,8 +298,8 @@ class NotificationService : Service() {
* The intent that opens one session, and the id it carries back out. * The intent that opens one session, and the id it carries back out.
* *
* The two halves are written together so neither can be changed without the other, and the scheme * The two halves are written together so neither can be changed without the other, and the scheme
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look at * is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look
* when an intent arrives rather than two. * at.
* *
* The id rides in the intent's **data** rather than in an extra, which is not a style choice: * The id rides in the intent's **data** rather than in an extra, which is not a style choice:
* PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring * PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring
@@ -345,9 +332,8 @@ data class SessionNotification(
* What a notification asks of the reader, in the words they see. * What a notification asks of the reader, in the words they see.
* *
* What they have to do, not what the session did: "awaitingInput" is the wire's word and says * What they have to do, not what the session did: "awaitingInput" is the wire's word and says
* nothing to somebody reading a lock screen. One function because the same fact is now shown in two * nothing to somebody reading a lock screen. One function because the same fact is shown in two
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift. The * places -- Android's drawer and the app's own banner -- and two mappings of one word drift.
* banner colours the line as well, which is its own decision and stays with the drawing.
*/ */
fun attentionLine(kind: String): String = fun attentionLine(kind: String): String =
when (kind) { when (kind) {
@@ -31,13 +31,12 @@ import androidx.compose.ui.unit.dp
* *
* Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a * Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a
* transcript that puts it in their voice is making a claim about who asked for the work that * transcript that puts it in their voice is making a claim about who asked for the work that
* follows -- which is exactly the question a peer message is usually the answer to. * follows.
* *
* Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block, * Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block,
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for the measurements that * each its own item of the transcript list. See [TranscriptUnit.PeerHead] for what that bought;
* bought; what matters here is that the pieces have to add up to the card that was there before, so * what matters here is that the pieces have to add up to the card that was there before, so the
* the fill, the corner radius and the padding all live in [peerSurface] rather than being written * fill, the corner radius and the padding all live in [peerSurface].
* out at each piece.
*/ */
@Composable @Composable
fun PeerHeadRow( fun PeerHeadRow(
@@ -91,9 +90,9 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap]. // The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
// Without this the card closes everywhere except on the text, which is most of it. // Without this the card closes everywhere except on the text, which is most of it.
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) { CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
// The gap the card's own column used to provide between its heading and its prose, // The gap the card's own column used to provide between its heading and its prose, and
// and between one block and the next -- inside the piece, so the card's fill runs // between one block and the next -- inside the piece, so the card's fill runs through
// through it. // it.
MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing)) MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
} }
} }
@@ -102,16 +101,14 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl
/** /**
* One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it. * One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it.
* *
* A filled Material card is elevation zero ([CardDefaults] takes it from `FilledCardTokens`, which * A filled Material card is elevation zero, so there is no shadow that a seam would show through --
* is `Level0`), so there is no shadow that a seam would show through -- which is the whole reason a * which is the whole reason a card can be cut up at all. Each piece paints the caller's container
* card can be cut up at all. Each piece paints the caller's container colour the way a * colour and rounds only the corners at the ends of the message, so the pieces abut into one
* [androidx.compose .material3.Card] would and rounds only the corners at the ends of the message, * continuous card. Shared by the two rows cut this way -- an opened peer message and a long user
* so the pieces abut into one continuous card. Shared by the two rows that are cut this way -- an * message -- because two copies of the corner logic is how one of them grows a seam.
* opened peer message and a long user message -- because two copies of the corner logic is how one
* of them grows a seam.
* *
* The padding is the other half of it: 12dp all round was the card's own, so the top piece keeps * The padding is the other half: 12dp all round was the card's own, so the top piece keeps the top
* the top of it, the bottom piece the bottom, and the middle pieces neither. * of it, the bottom piece the bottom, and the middle pieces neither.
*/ */
@Composable @Composable
fun Modifier.cardPiece( fun Modifier.cardPiece(
@@ -34,13 +34,10 @@ import androidx.compose.ui.unit.sp
* What is about to be sent, directly above the box it will be sent from. * What is about to be sent, directly above the box it will be sent from.
* *
* The count on the "+" button was the whole of what said an image was attached, so the only way to * The count on the "+" button was the whole of what said an image was attached, so the only way to
* find out *which* image was to send it. A control belongs with the thing it acts on, and what * find out *which* image was to send it. A control belongs with the thing it acts on.
* these are attached to is the message being typed -- which is why they sit here rather than
* anywhere else on the screen.
* *
* Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is * Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is
* in it, so four attachments look like four of the same thing rather than four smaller ones. A file * in it, so four attachments look like four of the same thing rather than four smaller ones.
* is a tile of the same height carrying its name, since a name is all there is to show of it.
*/ */
@Composable @Composable
fun PendingAttachments( fun PendingAttachments(
@@ -67,8 +64,7 @@ fun PendingAttachments(
* *
* Removal is here because there is nowhere else it could be: an image picked by mistake could * Removal is here because there is nowhere else it could be: an image picked by mistake could
* otherwise only be dealt with by sending it. The whole thumbnail is the target rather than a * otherwise only be dealt with by sending it. The whole thumbnail is the target rather than a
* corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip -- and * corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip.
* the label is what says so, since nothing about the picture does.
*/ */
@Composable @Composable
private fun PendingThumbnail( private fun PendingThumbnail(
@@ -84,8 +80,7 @@ private fun PendingThumbnail(
.clip(shape) .clip(shape)
// An outline as well as a fill. Most of what gets attached here is a screenshot of a // An outline as well as a fill. Most of what gets attached here is a screenshot of a
// dark app, and cropped to a square its middle is often near-black -- against this // dark app, and cropped to a square its middle is often near-black -- against this
// background the tile then had no edge at all, and the only thing saying an image was // background the tile then had no edge at all.
// attached was the cross drawn on top of nothing.
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
// Behind the picture as well as under a missing one, so the tile is a tile before // Behind the picture as well as under a missing one, so the tile is a tile before
// anything has arrived to fill it. // anything has arrived to fill it.
@@ -105,9 +100,9 @@ private fun PendingThumbnail(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
// A spinner, as the transcript's images have: one appearance for "a picture // A spinner, as the transcript's images have: one appearance for "a picture is
// is on its way", learned once. An ellipsis had to be read as a spinner that // on its way", learned once. An ellipsis had to be read as a spinner not
// was not moving. // moving.
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
} }
else -> else ->
@@ -118,14 +113,12 @@ private fun PendingThumbnail(
modifier = Modifier.size(THUMBNAIL), modifier = Modifier.size(THUMBNAIL),
) )
} }
// The whole square removes it, and this only says so. A cross small enough to sit in // The whole square removes it, and this only says so. A cross small enough to sit in the
// the corner of a 64dp thumbnail is smaller than a fingertip, so making it the target // corner of a 64dp thumbnail is smaller than a fingertip.
// would be a control drawn at a size nobody can hit.
// //
// The disc is sized here and the mark centred inside it, rather than the glyph being // The disc is sized here and the mark centred inside it, rather than the glyph being
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box // aligned directly: a glyph's box is wider than the cross it draws, so aligning the box to
// to the corner hung the visible mark over the edge and put its backing somewhere the // the corner hung the visible mark over the edge.
// eye reads as a second, misplaced square.
Box( Box(
Modifier.align(Alignment.TopEnd) Modifier.align(Alignment.TopEnd)
.padding(2.dp) .padding(2.dp)
@@ -3,15 +3,13 @@ package com.example.aiapp
import com.example.wgapplink.PinnedTls import com.example.wgapplink.PinnedTls
import java.net.HttpURLConnection import java.net.HttpURLConnection
// PINNED_CA_PEM is generated at build time from the CA on the machine doing // PINNED_CA_PEM is generated at build time from the CA on the machine doing the build -- see the
// the build -- see the generatePinnedCert task in build.gradle.kts. It is // generatePinnedCert task in build.gradle.kts. It is deliberately not a checked-in constant: the
// deliberately not a checked-in constant: the private key that signs against // private key that signs against it must never be anywhere this repo is, and an APK should pin
// it must never be anywhere this repo is, and an APK should pin whatever CA // whatever CA the backend it was built for actually serves.
// the backend it was built for actually serves.
// //
// The pinning itself lives in wg-app-link, since dev-updater needs exactly // The pinning itself lives in wg-app-link, since dev-updater needs exactly the same thing. What
// the same thing. What stays here is the one product-specific fact -- which // stays here is which certificate this app pins.
// certificate this app pins.
private val pinned = PinnedTls(PINNED_CA_PEM) private val pinned = PinnedTls(PINNED_CA_PEM)
/** Every request this app makes goes through this -- there is no unpinned path. */ /** Every request this app makes goes through this -- there is no unpinned path. */
@@ -16,20 +16,17 @@ import androidx.compose.ui.unit.dp
* *
* A composable rather than a modifier repeated at each site, because the inset is part of it -- * A composable rather than a modifier repeated at each site, because the inset is part of it --
* monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three * monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three
* copies of "clip, fill, pad" drift apart the first time one of them is adjusted. * copies of "clip, fill, pad" drift apart the first time one is adjusted.
* *
* The colour is [rawSurface], which is also what a code block inside a reply is given; that is the * The colour is [rawSurface], which is also what a code block inside a reply is given.
* point of having one name for it. Markdown's blocks are painted by the renderer rather than by
* this, since it draws its own, but they are the same colour on purpose.
*/ */
@Composable @Composable
fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Column( Column(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a // Smaller than a card's radius, and deliberately: this sits *inside* one, and a rounded
// rounded rectangle drawn at the same radius as the rounded rectangle behind it reads // rectangle drawn at the same radius as the one behind it reads as a misprint.
// as a misprint rather than as nesting.
.clip(MaterialTheme.shapes.extraSmall) .clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface) .background(rawSurface)
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
@@ -4,17 +4,16 @@ import java.time.Duration
import java.time.OffsetDateTime import java.time.OffsetDateTime
// How long is left in a usage window. Shared by the session bar and the usage screen: the // How long is left in a usage window. Shared by the session bar and the usage screen: the
// arithmetic is the same in both and only the sentence around it differs, so everything here // arithmetic is the same in both, so everything here returns the span or the state on its own and
// returns the span or the state on its own and leaves the wording to the caller. // leaves the wording to the caller.
/** /**
* "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words. * "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words.
* *
* Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s * Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s
* left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the * left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the
* figure on a minute it has already spent -- so the reader watching the number decide whether to * figure on a minute it has already spent. One rule, so the session bar and the usage dialog cannot
* start something was consistently told less headroom than they had. One rule, so the session bar * round a shared measurement two different ways.
* and the usage dialog cannot round a shared measurement two different ways.
*/ */
fun formatSpan(until: Duration): String { fun formatSpan(until: Duration): String {
val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1) val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1)
@@ -31,14 +30,12 @@ fun formatSpan(until: Duration): String {
* Three answers rather than a nullable duration, because two of them shared `null` and they are not * Three answers rather than a nullable duration, because two of them shared `null` and they are not
* the same thing at all. A window the server sent no reset time for is one that is **not running**: * the same thing at all. A window the server sent no reset time for is one that is **not running**:
* the five-hour window is anchored to the block it started in, so between sessions there is nothing * the five-hour window is anchored to the block it started in, so between sessions there is nothing
* counting down and the API says so by omitting the field -- measured against a live response on * counting down and the API says so by omitting the field. A timestamp that did arrive and could
* 2026-08-31, where the five-hour window's reset was exactly five hours after the moment work * not be read is the genuinely unknown case.
* resumed. A timestamp that did arrive and could not be read is the genuinely unknown case, and it
* is the only one worth those words.
* *
* Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on * Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on
* the one row somebody reads before starting something big -- and the usage dialog, looking at the * the one row somebody reads before starting something big -- and the usage dialog, looking at the
* same field, quietly drew nothing. Two rules for one missing value; this is the rule. * same field, quietly drew nothing.
*/ */
sealed class WindowEnd { sealed class WindowEnd {
/** No reset time was sent, so nothing is running in this window. Not a failure to find out. */ /** No reset time was sent, so nothing is running in this window. Not a failure to find out. */
@@ -10,24 +10,21 @@ private const val ANCHORS = "session-scroll"
* *
* Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by * Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by
* the row key the list draws with. An index means nothing across a reopen, since the transcript is * the row key the list draws with. An index means nothing across a reopen, since the transcript is
* fetched newest-first and a session that has said anything since has renumbered every position. * fetched newest-first. The row key looks stable and is not: a tool row is named after its run,
* The row key looks stable and is not: a tool row is named after its run, `joinPages` gives a run * `joinPages` gives a run the name of its newest half, and the newest half is whatever the newest
* the name of its newest half, and the newest half is whatever the newest page happened to start * page started with -- so an active session renames its tool runs every time it is reopened. A seq
* with -- so an active session renames its tool runs every time it is reopened, and an anchor * is the server's own numbering, assigned once and never moved.
* naming one is never found. A seq is the server's own numbering, assigned once and never moved.
* *
* [unit] is which unit of the row the viewport started at -- see [TranscriptUnit.ordinal] -- and * [unit] is which unit of the row the viewport started at and [offset] how far that unit was
* [offset] how far that unit was scrolled past the viewport's newest edge, in pixels. A seq alone * scrolled past the viewport's newest edge. A seq alone is not a place: a reply is one seq and can
* is not a place: a reply is one seq and can be forty blocks long, and a reader stopped halfway * be forty blocks long.
* down it is put back at that block, not at the reply.
*/ */
data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0) data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0)
/** /**
* On this device rather than on the backend, which is where this app otherwise keeps state so every * On this device rather than on the backend, which is where this app otherwise keeps state so every
* device sees it. Scroll position is the same exception a draft is: it is where the phone in * device sees it. Scroll position is the same exception a draft is: it is where the phone in
* somebody's hand is pointed, and having one device jump because another was scrolled would be a * somebody's hand is pointed.
* surprise rather than a convenience.
*/ */
fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? { fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val stored = val stored =
@@ -36,8 +33,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val fields = stored.split(':') val fields = stored.split(':')
val seq = fields.getOrNull(0)?.toLongOrNull() ?: return null val seq = fields.getOrNull(0)?.toLongOrNull() ?: return null
val offset = fields.getOrNull(1)?.toIntOrNull() ?: return null val offset = fields.getOrNull(1)?.toIntOrNull() ?: return null
// Positions saved before the unit was recorded name the row's oldest unit, which is the // Positions saved before the unit was recorded name the row's oldest unit, which is the closest
// closest older place -- the same choice [unitIndexFor] makes when a unit is gone. // older place -- the same choice [unitIndexFor] makes when a unit is gone.
return ScrollAnchor(seq, offset, fields.getOrNull(2)?.toIntOrNull() ?: 0) return ScrollAnchor(seq, offset, fields.getOrNull(2)?.toIntOrNull() ?: 0)
} }
@@ -45,9 +42,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
* Records where [sessionId] is being read, or forgets it when [anchor] is null. * Records where [sessionId] is being read, or forgets it when [anchor] is null.
* *
* The path out is reading to the newest end, which is what the caller passes null for: a session * The path out is reading to the newest end, which is what the caller passes null for: a session
* left at the bottom has nothing to restore and should open at the bottom, which is also the cheap * left at the bottom has nothing to restore. A session *deleted* while it held an anchor leaves its
* case. A session *deleted* while it held an anchor leaves its key behind, for the reason and at * key behind, for the reason and at the cost `Drafts.kt` describes.
* the cost `Drafts.kt` describes.
*/ */
fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) { fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) {
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit { context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit {
@@ -14,10 +14,9 @@ typealias ServerSettings = com.example.wgapplink.ServerSettings
/** /**
* This app's enrollment, which is the whole of what is product-specific about it. * This app's enrollment, which is the whole of what is product-specific about it.
* *
* Both values are load-bearing and neither may be changed casually. The scheme is what routes a * Both values are load-bearing. The scheme is what routes a scanned QR here rather than to Dev
* scanned QR here rather than to Dev Updater, and the key alias names the Android Keystore key the * Updater, and the key alias names the Android Keystore key the token is already sealed under on
* token is already sealed under on every enrolled phone -- changing it would leave those phones * every enrolled phone -- changing it would leave those phones reading as not enrolled.
* reading as not enrolled, with no error to explain why.
*/ */
private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key") private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key")
@@ -33,16 +33,14 @@ import androidx.lifecycle.repeatOnLifecycle
/** /**
* A session wanting attention, said over the app rather than through Android's drawer. * A session wanting attention, said over the app rather than through Android's drawer.
* *
* Two places can carry the same fact and only one of them is right at a time. A row in the shade is * Two places can carry the same fact and only one is right at a time. A row in the shade is for
* for somebody looking at something else: it makes a sound, it waits however long it has to, and * somebody looking at something else: it makes a sound, it waits however long it has to, and acting
* acting on it means leaving whatever they were doing. Somebody with this app open needs none of * on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
* that -- they are already here, and what a tap on the notification would have done is what a tap * while these are on screen the stream is delivered here instead, which is arranged by the
* on this does. So while these are on screen the stream is delivered here instead, which is * collection below and nothing else.
* arranged by the collection below and nothing else; see `NotificationService.forTheScreen`.
* *
* A banner can go three ways, and each is somebody deciding something different: tapped, which * A banner can go three ways, each somebody deciding something different: tapped, which opens the
* opens the session; pushed off either side; or left alone, in which case it goes by itself when * session; pushed off either side; or left alone, in which case it goes when the bar runs out.
* the bar across its foot runs out.
*/ */
@Composable @Composable
fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) { fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) {
@@ -58,28 +56,26 @@ fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Mod
arrivals++ arrivals++
val alert = SessionAlert(notification, arrivals) val alert = SessionAlert(notification, arrivals)
// One banner per session, replacing that session's own -- the same rule the // One banner per session, replacing that session's own -- the same rule the
// drawer follows, and for the same reason: a session that finished and then // drawer follows: a session that finished and then asked a question is one
// asked a question is one thing to know about, the question. It keeps its // thing to know about, the question. It keeps its place in the queue rather
// place in the queue rather than moving to the end, because the reader may // than moving to the end, because the reader may already be reaching for it.
// already be reaching for it.
val already = queue.indexOfFirst { val already = queue.indexOfFirst {
it.notification.sessionId == notification.sessionId it.notification.sessionId == notification.sessionId
} }
if (already >= 0) queue[already] = alert else queue.add(alert) if (already >= 0) queue[already] = alert else queue.add(alert)
} }
} finally { } finally {
// Leaving the app hands the job back to the drawer, so nothing arriving while it // Leaving the app hands the job back to the drawer, so nothing arriving while it is
// is away is lost. What would be lost is the truth of what is already up: these // away is lost. What would be lost is the truth of what is already up: these say a
// say a session wants somebody *now*, and one still sitting here on a return // session wants somebody *now*, and one still sitting here on a return several
// several minutes later is a claim nobody checked. Frozen, too -- Compose stops // minutes later is a claim nobody checked. Frozen, too -- Compose stops the clock
// the clock with the window, so the timer that was going to retire it has been // with the window.
// standing still the whole time.
queue.clear() queue.clear()
} }
} }
} }
// Oldest at the top, so a new one appears below the ones already being read instead of // Oldest at the top, so a new one appears below the ones already being read instead of shoving
// shoving them down the screen mid-reach. // them down the screen mid-reach.
Column(modifier.fillMaxWidth().padding(8.dp)) { Column(modifier.fillMaxWidth().padding(8.dp)) {
queue.forEach { alert -> queue.forEach { alert ->
key(alert.arrival) { key(alert.arrival) {
@@ -103,9 +99,7 @@ private data class SessionAlert(val notification: SessionNotification, val arriv
* One banner: what wants attention, and how long this has left to say so. * One banner: what wants attention, and how long this has left to say so.
* *
* The bar and the going away are one value rather than a bar beside a timer, because two of them * The bar and the going away are one value rather than a bar beside a timer, because two of them
* would be two accounts of the same countdown and only one can be the one that fires. What is drawn * would be two accounts of the same countdown and only one can be the one that fires.
* is therefore the thing that decides, which is the only arrangement where a bar that has emptied
* cannot be sitting under a banner that is still there.
*/ */
@Composable @Composable
private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) { private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) {
@@ -135,9 +129,7 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
), ),
// Outlined, because the step it needs to make is not one this palette can make with a // Outlined, because the step it needs to make is not one this palette can make with a
// tint: the card under a banner on the session list is the same surface, so a banner // tint: the card under a banner on the session list is the same surface, so a banner
// relying on colour alone reads as one more row that happens to be in the way. The // relying on colour alone reads as one more row in the way. The border is the one cue.
// border is the one cue, and the elevation beside it is the platform's shadow rather
// than a second tint -- Material draws no tonal overlay over a container stated here.
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
) { ) {
@@ -145,8 +137,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
Text( Text(
alert.notification.title, alert.notification.title,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
// One line, cut at the tail: a session is identified by the start of its // One line, cut at the tail: a session is identified by the start of its name,
// name, and a banner that grew with the name would move the one below it. // and a banner that grew with the name would move the one below it.
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -163,9 +155,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
LinearProgressIndicator( LinearProgressIndicator(
progress = { life.value }, progress = { life.value },
// Blue because it is reporting how much of something is left rather than passing // Blue because it is reporting how much of something is left rather than passing
// judgement on it -- the reason `progressColor` exists. Stated beside the track, // judgement on it. Stated beside the track, which is the card's own colour so that
// which is the card's own colour so that the spent part reads as empty rather // the spent part reads as empty rather than as a second bar.
// than as a second bar.
color = progressColor, color = progressColor,
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
drawStopIndicator = {}, drawStopIndicator = {},
@@ -180,7 +171,6 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
* How long a banner stays if nobody touches it. * How long a banner stays if nobody touches it.
* *
* Long enough to read a session name and a line, short enough that a stack of them clears itself * Long enough to read a session name and a line, short enough that a stack of them clears itself
* while somebody is still on the screen that produced them. The bar makes the number visible, so * while somebody is still on the screen that produced them. The bar makes the number visible.
* this is a duration the reader can watch rather than one they have to learn.
*/ */
private const val ALERT_LIFE_MS = 6_000 private const val ALERT_LIFE_MS = 6_000
@@ -49,10 +49,9 @@ data class SessionBitmap(val bitmap: ImageBitmap?, val failed: Boolean)
/** /**
* Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling * Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling
* does not refetch. * does not refetch. Shared by the transcript's images and the composer's pending attachments,
* * because the fetch, the decode and the two-state answer are one block of logic that had been
* Shared by the transcript's images and the composer's pending attachments, because the fetch, the * written twice.
* decode and the two-state answer are one block of logic that had been written twice.
*/ */
@Composable @Composable
fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap { fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap {
@@ -75,15 +74,12 @@ fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: Stri
* An image in the transcript: a fixed-height thumbnail that opens full screen. * An image in the transcript: a fixed-height thumbnail that opens full screen.
* *
* The height is decided before the bytes arrive and never changes. An image row that grew when it * The height is decided before the bytes arrive and never changes. An image row that grew when it
* finished loading pushed everything below it, so a transcript being read scrolled itself while * finished loading pushed everything below it, so a transcript being read scrolled itself -- and in
* somebody was looking at it -- and in a bottom-anchored list, images loading above the viewport * a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes.
* moved the text under the reader's eyes. Reserving the final height makes loading invisible, which
* is what it should be.
* *
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather * Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
* than as a page of its own. Full size is one tap away -- but the full-size view itself is not * than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the
* here. [onOpen] hands the ref to the screen, which draws [SessionImageViewer] outside the list; * screen, which draws [SessionImageViewer] outside the list.
* see that function for the reason.
*/ */
@Composable @Composable
fun SessionImage( fun SessionImage(
@@ -97,9 +93,8 @@ fun SessionImage(
val heightPx = with(LocalDensity.current) { height.roundToPx() } val heightPx = with(LocalDensity.current) { height.roundToPx() }
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) { Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
when (val image = bitmap) { when (val image = bitmap) {
// Two states, not one: an image still arriving and an image that will never arrive // Two states, not one: an image still arriving and an image that will never arrive look
// look nothing alike to a reader who can do something about the second. So one gets a // nothing alike to a reader who can do something about the second.
// spinner in the space the picture is about to fill, and the other gets words.
null -> null ->
if (failed) { if (failed) {
Text( Text(
@@ -130,15 +125,12 @@ fun SessionImage(
* `Read` on its own is a row of one call, and the moment the next call arrives the two become a * `Read` on its own is a row of one call, and the moment the next call arrives the two become a
* group -- a different composable in a different part of the tree, so everything the old subtree * group -- a different composable in a different part of the tree, so everything the old subtree
* remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the * remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the
* transcript because the session made another tool call. The same happens to a row regrouped by a * transcript because the session made another tool call.
* page of history landing.
* *
* Held by the screen, none of that reaches it: what is open is a property of the screen, not of * Held by the screen, none of that reaches it: what is open is a property of the screen.
* whichever row happened to draw the thumbnail.
* *
* The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go * The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go
* through. Paid deliberately rather than plumbed around: it is one request for a picture somebody * through. Paid deliberately: it is one request for a picture somebody asked to see.
* asked to see, and the loading and unavailable states below are the same two the thumbnail draws.
*/ */
@Composable @Composable
fun SessionImageViewer( fun SessionImageViewer(
@@ -157,9 +149,9 @@ fun SessionImageViewer(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
when (val image = bitmap) { when (val image = bitmap) {
// Two states, not one, exactly as the thumbnail has them: still coming, and never // Two states, not one, exactly as the thumbnail has them. Stated in white because
// coming. Stated in white because this box paints its own black behind them and a // this box paints its own black behind them and a theme colour would be picked
// theme colour would be picked against a surface that is not there. // against a surface that is not there.
null -> null ->
if (failed) { if (failed) {
Text( Text(
@@ -170,8 +162,7 @@ fun SessionImageViewer(
} else { } else {
// The whole dialog is the area this picture is about to fill, so the // The whole dialog is the area this picture is about to fill, so the
// spinner sits in the middle of it. White for the same reason the words // spinner sits in the middle of it. White for the same reason the words
// beside it are: this box paints its own black, and a theme colour would // beside it are.
// be chosen against a surface that is not there.
CircularProgressIndicator(color = Color.White) CircularProgressIndicator(color = Color.White)
} }
else -> ZoomableImage(image) else -> ZoomableImage(image)
@@ -185,11 +176,10 @@ fun SessionImageViewer(
* *
* A square of the row's own height rather than the full width of the transcript: the height is what * A square of the row's own height rather than the full width of the transcript: the height is what
* [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width * [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width
* placeholder would promise a picture wider than most of them turn out to be. Square is the closest * placeholder would promise a picture wider than most turn out to be.
* thing to "the size of it" that can be drawn before knowing.
* *
* Tinted, so the reader can see that something is being kept for a picture. That is also what * Tinted, so the reader can see that something is being kept for a picture -- which is also what
* distinguishes it from the failure beside it, which is words on the ordinary surface. * distinguishes it from the failure beside it, words on the ordinary surface.
*/ */
@Composable @Composable
private fun LoadingImage(height: Dp) { private fun LoadingImage(height: Dp) {
@@ -210,8 +200,8 @@ private val LOADING_SPINNER = 24.dp
* Four lines of the body style the transcript is set in. * Four lines of the body style the transcript is set in.
* *
* Measured from the type rather than written as a dp, so it stays four lines when the text size * Measured from the type rather than written as a dp, so it stays four lines when the text size
* changes -- including when the reader has scaled fonts up, which is exactly when a hardcoded * changes -- including when the reader has scaled fonts up, which is when a hardcoded height is
* height would be wrong. * wrong.
*/ */
@Composable @Composable
private fun thumbnailHeight(): Dp { private fun thumbnailHeight(): Dp {
@@ -226,8 +216,7 @@ private fun thumbnailHeight(): Dp {
* Nearest neighbour when the image is being enlarged, smooth when it is being shrunk. * Nearest neighbour when the image is being enlarged, smooth when it is being shrunk.
* *
* A small image blown up with interpolation turns into a blur that hides what it is -- the same * A small image blown up with interpolation turns into a blur that hides what it is -- the same
* image with hard pixel edges stays readable. Shrinking wants the opposite, so this is a decision * image with hard pixel edges stays readable. Shrinking wants the opposite.
* per image rather than a preference set once.
*/ */
private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality = private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality =
if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High
@@ -237,7 +226,7 @@ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality
* *
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back * Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
* gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image * gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image
* visible, which is the thing a reader wants first; zoom is theirs from there. * visible.
*/ */
@Composable @Composable
private fun ZoomableImage(image: ImageBitmap) { private fun ZoomableImage(image: ImageBitmap) {
@@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -52,22 +53,22 @@ fun SessionListScreen(
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) } var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) } var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Failures that belong to one session rather than to the list, keyed by // Failures that belong to one session rather than to the list, keyed by its id and shown on its
// its id and shown on its own card. The two scopes are decided by // own card. The two scopes are decided by whether the server answered: it answered and refused,
// whether the server answered: it answered and refused, so this says // so this says nothing about the other rows.
// nothing about the other rows, where a server that has stopped
// answering leaves every row stale and is `listState`'s to report.
// //
// Cleared on the next successful load below -- an entry outlives its // Cleared on the next successful load below -- an entry outlives its session otherwise.
// session otherwise, and would reappear against whatever the phone
// fetched next.
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) } var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, // Which sessions have a delete in flight. A set of ids rather than a flag on the row, because
// because the rows are rebuilt from whatever the server last said and this belongs to the // the rows are rebuilt from whatever the server last said and this belongs to the request.
// request rather than to the session.
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) } var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
// This phone's copies of these sessions' transcripts, pruned from here because this is where a
// session stops existing. See TranscriptCache.
val context = LocalContext.current
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
fun refresh() { fun refresh() {
listState = LoadState.Loading listState = LoadState.Loading
scope.launch { scope.launch {
@@ -76,6 +77,13 @@ fun SessionListScreen(
val loaded = val loaded =
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) } withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
deleteErrors = emptyMap() deleteErrors = emptyMap()
// The path out for a cached transcript whose session was deleted somewhere
// else. This list is the only place that ever learns the full set. On the
// answer rather than in `finally`: a list that failed to arrive says nothing
// about which sessions exist.
withContext(Dispatchers.IO) {
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
}
loaded loaded
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
@@ -89,12 +97,9 @@ fun SessionListScreen(
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp)) {
when (val state = listState) { when (val state = listState) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
// The message as Api.kt wrote it, with nothing added: it is // The message as Api.kt wrote it, with nothing added: it is already a whole
// already a whole sentence naming the address and what to // sentence naming the address and what to check, so a prefix here read "Couldn't
// check, so a prefix here read "Couldn't reach the server: // reach the server: Couldn't reach the server at ...".
// Couldn't reach the server at ...". It was also a guess --
// a delete that the server itself refused had reached it
// fine.
is LoadState.Error -> is LoadState.Error ->
Text( Text(
state.message, state.message,
@@ -108,8 +113,7 @@ fun SessionListScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
// Awaiting-answer first (the point of the screen), then // Awaiting-answer first (the point of the screen), then most recently active.
// most recently active.
val ordered = val ordered =
state.value.sortedWith( state.value.sortedWith(
compareByDescending<SessionSummary> { it.status == "awaitingInput" } compareByDescending<SessionSummary> { it.status == "awaitingInput" }
@@ -141,40 +145,37 @@ fun SessionListScreen(
confirmingDelete?.let { session -> confirmingDelete?.let { session ->
// Reset per session, so a toggle turned on for one conversation is not still on for the // Reset per session, so a toggle turned on for one conversation is not still on for the
// next one somebody opens this dialog for. Off to begin with: see [deleteSession]. // next. Off to begin with: see [deleteSession].
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) } var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
AlertDialog( AlertDialog(
onDismissRequest = { confirmingDelete = null }, onDismissRequest = { confirmingDelete = null },
title = { Text("Delete \"${session.title}\"?") }, title = { Text("Delete \"${session.title}\"?") },
text = { text = {
// Two different acts behind one button, so it says which one this is. What // Two different acts behind one button, so it says which one this is. What
// separates them is whether the *driver* keeps its own record of the // separates them is whether the *driver* keeps its own record of the conversation
// conversation -- the Claude Code CLI does, under ~/.claude/projects, whether // -- the Claude Code CLI does, whether this app spawned the session or imported it;
// this app spawned the session or imported it; echo and llama.cpp do not, and // echo and llama.cpp do not.
// for those the app's transcript is the only copy there is.
// //
// This used to branch on `imported`, above a comment asserting that "a session // This used to branch on `imported`, above a comment asserting that "a session
// started here has no copy anywhere". That was simply false for every // started here has no copy anywhere". That was false for every claude-cli session
// claude-cli session this app spawned, and the two warnings disagreed about // this app spawned, and getting it wrong in that direction is the expensive one:
// sessions that were equally recoverable. Getting it wrong in that direction // "this can't be undone", said of something that can, spends the credibility the
// is the expensive one: "this can't be undone", said of something that can, // sentence needs.
// spends the credibility the sentence needs on the sessions where it is true.
// //
// Neither branch promises a restore. The recoverable one says what is known -- // Neither branch promises a restore. The recoverable one says what is known -- the
// the driver keeps its own record -- rather than that the file is still there, // driver keeps its own record -- rather than that the file is still there, and it
// which nothing here checked; and it names what goes either way, because this // names what goes either way, because this app's transcript holds images, peer
// app's transcript holds images, peer messages and commands that the CLI's own // messages and commands the CLI's own record never had.
// record never had.
Column { Column {
Text( Text(
when { when {
!session.keepsOwnTranscript -> !session.keepsOwnTranscript ->
"Kills the process and deletes the conversation. Nothing else " + "Kills the process and deletes the conversation. Nothing else " +
"keeps a copy, so this can't be undone." "keeps a copy, so this can't be undone."
// The sentence below is the one the toggle makes false, which is why // The sentence below is the one the toggle makes false, which is why it
// it is written twice rather than appended to: leaving "should still // is written twice rather than appended to: leaving "should still be
// be there to import again" on screen beside a switch that removes it // there to import again" on screen beside a switch that removes it is
// is the reassurance being read at the moment it stops being true. // the reassurance being read at the moment it stops being true.
alsoDeleteForeign -> alsoDeleteForeign ->
"Kills the process and deletes both copies of the conversation: " + "Kills the process and deletes both copies of the conversation: " +
"this app's, and Claude Code's own transcript on the " + "this app's, and Claude Code's own transcript on the " +
@@ -189,12 +190,12 @@ fun SessionListScreen(
) )
// Only where there is a second copy to decide about. Absent rather than // Only where there is a second copy to decide about. Absent rather than
// disabled, because this is not a capability being withheld: for echo and // disabled, because this is not a capability being withheld: for echo and
// llama.cpp there is no other transcript, and a switch offering to delete // llama.cpp there is no other transcript, and a switch offering to delete one
// one would be asking about something that does not exist. // would be asking about something that does not exist.
if (session.keepsOwnTranscript) { if (session.keepsOwnTranscript) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Its own row rather than beside the paragraph: a switch is taller than // Its own row rather than beside the paragraph: a switch is taller than a
// a line of text and re-centres whatever shares a row with it. // line of text and re-centres whatever shares a row with it.
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
"Delete Claude Code's transcript too", "Delete Claude Code's transcript too",
@@ -215,19 +216,20 @@ fun SessionListScreen(
onClick = { onClick = {
confirmingDelete = null confirmingDelete = null
// Marked here rather than after the request returns: the row has to say // Marked here rather than after the request returns: the row has to say
// something is happening to it from the moment it is asked for, which // something is happening to it from the moment it is asked for.
// is the whole of what this state is for.
deleting = deleting + session.id deleting = deleting + session.id
deleteErrors = deleteErrors - session.id deleteErrors = deleteErrors - session.id
scope.launch { scope.launch {
try { try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign) deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the
// session exactly as it was, and its transcript with it.
transcriptCache.session(session.id).purge()
} }
// Only this row, and only what changed. Refetching the list // Only this row, and only what changed. Refetching the list instead
// instead put every other session back through loading and // put every other session back through loading and handed the
// handed the reader an empty screen -- to report on something // reader an empty screen, to report on something never in doubt.
// that was never in doubt.
val loaded = listState val loaded = listState
if (loaded is LoadState.Loaded) { if (loaded is LoadState.Loaded) {
listState = listState =
@@ -246,8 +248,8 @@ fun SessionListScreen(
} }
} }
) { ) {
// Coloured by consequence: this takes something away, and does so wherever // Coloured by consequence: this takes something away, and does so wherever it
// it appears -- the same rule the import screen's Delete follows. // appears -- the same rule the import screen's Delete follows.
Text("Delete", color = MaterialTheme.colorScheme.error) Text("Delete", color = MaterialTheme.colorScheme.error)
} }
}, },
@@ -269,8 +271,7 @@ private fun SessionCard(
* *
* Suspended rather than removed while it is -- see [BusyItem] -- which says the row is on its * Suspended rather than removed while it is -- see [BusyItem] -- which says the row is on its
* way out without claiming it has gone: a row removed the moment Delete is pressed is a promise * way out without claiming it has gone: a row removed the moment Delete is pressed is a promise
* about a request that has not been answered yet, and putting it back when the server refuses * about a request that has not been answered yet.
* is worse than never having taken it away.
*/ */
deleting: Boolean, deleting: Boolean,
onOpen: () -> Unit, onOpen: () -> Unit,
@@ -278,9 +279,9 @@ private fun SessionCard(
) { ) {
BusyItem(label = if (deleting) "deleting" else null) { BusyItem(label = if (deleting) "deleting" else null) {
Card( Card(
// Off while the delete is in flight: a card that still opens a session it is // Off while the delete is in flight: a card that still opens a session it is deleting
// deleting is a race the reader can start by tapping. On the card rather than in // is a race the reader can start by tapping. On the card rather than in [BusyItem],
// [BusyItem], which leaves gestures alone so the list still scrolls. // which leaves gestures alone so the list still scrolls.
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.combinedClickable( .combinedClickable(
enabled = !deleting, enabled = !deleting,
@@ -303,9 +304,9 @@ private fun SessionCard(
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
Text( Text(
// Machine, then what runs on it, then what it is set to: the same order // Machine, then what runs on it, then what it is set to: the same order and
// and separator as the session screen's header and the usage dialog, so // separator as the session screen's header and the usage dialog, so one
// one pair of facts is not written three ways. // pair of facts is not written three ways.
listOfNotNull( listOfNotNull(
session.setupName, session.setupName,
session.provider, session.provider,
@@ -324,8 +325,7 @@ private fun SessionCard(
} }
error?.let { error?.let {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// The server's own words, unprefixed, the way every other // The server's own words, unprefixed, the way every other failure is shown.
// failure in this app is shown.
Text( Text(
it, it,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -345,16 +345,16 @@ fun StatusText(status: String) {
"running" -> "running" to runningColor "running" -> "running" to runningColor
"compacting" -> "compacting" to commandColor "compacting" -> "compacting" to commandColor
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant "exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
// Said in words, because it differs in kind from the others rather than in degree: // Said in words, because it differs in kind from the others rather than in degree: the
// the session is not idle and has not exited, nobody has been able to find out // session is not idle and has not exited, nobody has been able to find out which. A
// which. A muted colour alone would read as one of the quiet states. // muted colour alone would read as one of the quiet states.
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant "unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
else -> status to MaterialTheme.colorScheme.onSurfaceVariant else -> status to MaterialTheme.colorScheme.onSurfaceVariant
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (sessionWorking(status)) { if (sessionWorking(status)) {
// The same colour as the word beside it: the two are one signal, and a spinner in // The same colour as the word beside it: the two are one signal, and a spinner in the
// the theme's accent says the state is something other than what the label says. // theme's accent says the state is something other than what the label says.
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.width(14.dp).height(14.dp), modifier = Modifier.width(14.dp).height(14.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
File diff suppressed because it is too large. Load diff
@@ -39,12 +39,11 @@ import kotlinx.coroutines.withContext
* controls and hid the thing they act on. * controls and hid the thing they act on.
* *
* The model and the permission mode are deliberately still on the session's own bar, because those * The model and the permission mode are deliberately still on the session's own bar, because those
* are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs * are changed *while* reading a turn -- "not this model, try that one".
* with the thing it acts on.
* *
* Nothing here is captioned. Each control is a labelled noun with a switch or a field beside it, * Captions are for what a control costs rather than for what it is. A paragraph under every control
* and a paragraph under every one of them made the dialog longer than the conversation it covers. * made the dialog longer than the conversation it covers -- so Notifications has none, while Move
* Failures still get their words: those are what the reader cannot work out by looking. * and Reload do, because what those two take away is not visible from here.
*/ */
@Composable @Composable
fun SessionSettingsDialog( fun SessionSettingsDialog(
@@ -55,10 +54,16 @@ fun SessionSettingsDialog(
*/ */
title: String, title: String,
onRenamed: (String) -> Unit, onRenamed: (String) -> Unit,
/**
* What this phone is holding of the conversation, or null while that is being measured -- see
* the Reload row below, which is what would discard it.
*/
cachedBytes: Long?,
onReload: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
/** /**
* Copies what this session costs to draw. Built by the session screen, because everything it * Copies what this session costs to draw. Built by the session screen, because everything it
* measures is that screen's own state -- see `copyRenderReport` there. * measures is that screen's own state.
*/ */
onCopyRenderReport: () -> Unit, onCopyRenderReport: () -> Unit,
) { ) {
@@ -68,16 +73,14 @@ fun SessionSettingsDialog(
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
// Null until the server has been asked. The row this dialog was opened over is a snapshot of // Null until the server has been asked. The row this dialog was opened over is a snapshot of
// whenever the list was last fetched, so drawing the switch straight from it would show a // whenever the list was last fetched, so drawing the switch straight from it would show a
// position that may have been changed since -- from here or from another device -- with // position that may have been changed since. Until the answer arrives the switch is disabled
// nothing to say so. Until the answer arrives the switch is disabled and a spinner sits beside // and a spinner sits beside it, which is what not knowing looks like.
// it, which is what not knowing looks like: distinguishable from off, and from a refusal.
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) } var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var notifyError by remember { mutableStateOf<String?>(null) } var notifyError by remember { mutableStateOf<String?>(null) }
// Where the session works. Null until the server has been asked, for the same reason the // Where the session works. Null until the server has been asked, for the same reason the switch
// switch above is: the row this dialog opened over is a snapshot, and a path drawn from it // above is. An empty answer is a session that was never given a directory, which is not the
// could be one somebody changed from another device. An empty answer is a session that was // same as one whose directory is unknown -- the field is only enabled once one of those is
// never given a directory, which is not the same as one whose directory is unknown -- the // settled.
// field is only enabled once one of those two is settled.
var cwd by remember(sessionId) { mutableStateOf<String?>(null) } var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
var typedCwd by remember(sessionId) { mutableStateOf("") } var typedCwd by remember(sessionId) { mutableStateOf("") }
var cwdError by remember { mutableStateOf<String?>(null) } var cwdError by remember { mutableStateOf<String?>(null) }
@@ -90,8 +93,8 @@ fun SessionSettingsDialog(
cwd = fresh.cwd.orEmpty() cwd = fresh.cwd.orEmpty()
typedCwd = fresh.cwd.orEmpty() typedCwd = fresh.cwd.orEmpty()
} catch (e: ApiException) { } catch (e: ApiException) {
// Left unknown rather than falling back to the stale row: the switch stays // Left unknown rather than falling back to the stale row: the switch stays disabled,
// disabled, instead of offering a position nothing confirmed. // instead of offering a position nothing confirmed.
notifyError = e.message notifyError = e.message
notify = null notify = null
} }
@@ -123,8 +126,8 @@ fun SessionSettingsDialog(
} }
// Moved optimistically so the switch answers the finger that moved it, and put back if the // Moved optimistically so the switch answers the finger that moved it, and put back if the
// request is refused -- a switch that waits for a round trip reads as broken on a slow // request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
// tunnel, and one that stays moved after a refusal lies. // and one that stays moved after a refusal lies.
fun setNotify(wanted: Boolean) { fun setNotify(wanted: Boolean) {
val was = notify val was = notify
notify = wanted notify = wanted
@@ -154,7 +157,7 @@ fun SessionSettingsDialog(
onRenamed(chosen) onRenamed(chosen)
} catch (e: ApiException) { } catch (e: ApiException) {
// Reported here, where it happened, because this dialog is the only place that // Reported here, where it happened, because this dialog is the only place that
// knows a rename was attempted -- the session behind it shows nothing about it. // knows a rename was attempted.
error = e.message error = e.message
saving = false saving = false
} }
@@ -173,8 +176,8 @@ fun SessionSettingsDialog(
singleLine = true, singleLine = true,
enabled = !saving, enabled = !saving,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
// The keyboard's own action does what the button does: a one-field form // The keyboard's own action does what the button does: a one-field form where
// where the return key does nothing is a form people press return at anyway. // the return key does nothing is a form people press return at anyway.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { save() }), keyboardActions = KeyboardActions(onDone = { save() }),
) )
@@ -199,8 +202,8 @@ fun SessionSettingsDialog(
enabled = notify != null, enabled = notify != null,
) )
} }
// Beside the switch that failed, not with the rename's error: they are two // Beside the switch that failed, not with the rename's error: they are two requests
// requests and a reader has to be able to tell which one the server refused. // and a reader has to be able to tell which one the server refused.
notifyError?.let { notifyError?.let {
Text( Text(
it, it,
@@ -217,9 +220,9 @@ fun SessionSettingsDialog(
value = typedCwd, value = typedCwd,
onValueChange = { typedCwd = it }, onValueChange = { typedCwd = it },
label = { Text("Working directory") }, label = { Text("Working directory") },
// What the field cannot say by being empty: a session that was never // What the field cannot say by being empty: a session that was never given
// given one starts wherever its launcher does, and this names that // one starts wherever its launcher does, and this names that rather than
// rather than showing a path nobody chose. // showing a path nobody chose.
placeholder = { Text("wherever the session was started") }, placeholder = { Text("wherever the session was started") },
singleLine = true, singleLine = true,
enabled = cwd != null && !movingCwd, enabled = cwd != null && !movingCwd,
@@ -239,9 +242,8 @@ fun SessionSettingsDialog(
} }
} }
// The whole of what pressing Move does, where it is about to be pressed. A // The whole of what pressing Move does, where it is about to be pressed. A
// directory is settled when the process is spawned, so there is no changing one // directory is settled when the process is spawned, so it is ended and the next
// under a running session -- it is ended, and the next thing said to the session // thing said to the session starts it in the new place.
// starts it in the new place.
Text( Text(
"Moving stops the session's process. It starts again in the new directory " + "Moving stops the session's process. It starts again in the new directory " +
"with the next message, or with Start.", "with the next message, or with Start.",
@@ -255,6 +257,44 @@ fun SessionSettingsDialog(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Transcript", modifier = Modifier.weight(1f))
// The size is what the button discards, and the unknown state is drawn rather
// than guessed: a spinner while the directory is being measured, and words when
// there is nothing there, because "nothing cached" and "0 B" read as different
// claims.
when {
cachedBytes == null ->
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
else ->
Text(
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(12.dp))
// Enabled whether or not anything is cached: "what I see disagrees with the
// machine" is a state an empty cache can be in too, and a control that comes
// and goes makes its own presence the signal.
TextButton(onClick = onReload) { Text("Reload") }
}
// Captioned, unlike the controls above it, for the same reason Move is: what it
// costs is not visible, and neither is the case it exists for.
Text(
"Reload throws away this phone's copy and fetches the transcript from the " +
"server again. Use it when what is shown here disagrees with the file " +
"on the machine.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let { error?.let {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
@@ -279,8 +319,8 @@ fun SessionSettingsDialog(
} }
} }
}, },
// Disabled rather than absent while there is nothing to save: a button that comes and // Disabled rather than absent while there is nothing to save: a button that comes and goes
// goes makes its own presence the signal, and its absence cannot say why. // makes its own presence the signal, and its absence cannot say why.
confirmButton = { confirmButton = {
TextButton(onClick = { save() }, enabled = changed && !saving) { TextButton(onClick = { save() }, enabled = changed && !saving) {
Text(if (saving) "Saving..." else "Save") Text(if (saving) "Saving..." else "Save")
@@ -34,20 +34,19 @@ sealed class SessionUsage {
/** /**
* This machine meters nothing, so there is no window to show. * This machine meters nothing, so there is no window to show.
* *
* Separate from [Unavailable], and the distinction is the whole point: a session on `echo` or * Separate from [Unavailable], and the distinction is the point: a session on `echo` or on a
* on a local llama.cpp has no paid quota at all, which is a fact about how it was set up and * local llama.cpp has no paid quota at all, which is a fact about how it was set up and not a
* not a failure to find something out. The backend never asks such a machine, so it returns no * failure to find something out. The backend never asks such a machine, and reading that
* snapshot for it -- and reading that silence as "couldn't find out" is exactly the mistake of * silence as "couldn't find out" is answering with the nearest available word.
* answering with the nearest available word. Drawn as nothing, because there is nothing.
*/ */
data object NotMetered : SessionUsage() data object NotMetered : SessionUsage()
/** /**
* The question could not be answered, and why. * The question could not be answered, and why.
* *
* Its own state because "we couldn't find out" and "none of it is used" are the pair that must * Its own state because "we couldn't find out" and "none of it is used" must never share an
* never share an appearance: a bar sitting at zero because a machine is unreachable reads as * appearance: a bar sitting at zero because a machine is unreachable reads as plenty of
* plenty of headroom, which is the opposite of the truth. * headroom.
*/ */
data class Unavailable(val why: String) : SessionUsage() data class Unavailable(val why: String) : SessionUsage()
} }
@@ -59,17 +58,14 @@ private const val REFRESH_MS = 60_000L
* One poll of every machine's limits, and the handle to ask again. * One poll of every machine's limits, and the handle to ask again.
* *
* A screen shows this answer in more than one place -- the bar under the session header, the colour * A screen shows this answer in more than one place -- the bar under the session header, the colour
* of the button beside it, and the dialog that button opens -- and each of those used to fetch for * of the button beside it, and the dialog that button opens -- and each used to fetch for itself.
* itself. Two fetches say one thing twice and then disagree about it: the bar's copy can be a whole * Two fetches say one thing twice and then disagree: the bar's copy can be a whole refresh interval
* refresh interval old when the dialog opens with a fresh one, so the header read 42% while the * old when the dialog opens with a fresh one, so the header read 42% while the screen over it read
* screen over it read 47%, about a number somebody is deciding on. One feed per screen, and * 47%.
* [refresh] moves both.
*/ */
class UsageFeed( class UsageFeed(
val snapshots: LoadState<List<UsageSnapshot>>, val snapshots: LoadState<List<UsageSnapshot>>,
/** /** A fetch is outstanding. Only ever true over an answer already shown. */
* A fetch is outstanding. Only ever true over an answer already shown; see [rememberUsageFeed].
*/
val refreshing: Boolean, val refreshing: Boolean,
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */ /** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
val refresh: () -> Unit, val refresh: () -> Unit,
@@ -102,15 +98,15 @@ class UsageFeed(
fun rememberUsageFeed(settings: ServerSettings): UsageFeed { fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) } var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
var refreshing by remember { mutableStateOf(true) } var refreshing by remember { mutableStateOf(true) }
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh // Bumped to ask again now. The poll below restarts from the new value, so a manual refresh also
// also resets the countdown to the next one rather than leaving one due immediately after. // resets the countdown rather than leaving one due immediately after.
var asked by remember { mutableIntStateOf(0) } var asked by remember { mutableIntStateOf(0) }
LaunchedEffect(asked) { LaunchedEffect(asked) {
while (true) { while (true) {
refreshing = true refreshing = true
// Replaces the answer only once the next one is in hand: dropping back to Loading // Replaces the answer only once the next one is in hand: dropping back to Loading would
// would blank a bar somebody is reading for the length of a round trip, and what was // blank a bar somebody is reading for the length of a round trip, and what was on
// on screen is still the last thing the machine actually said. // screen is still the last thing the machine actually said.
snapshots = snapshots =
try { try {
LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) }) LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) })
@@ -130,13 +126,11 @@ fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a * Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked. * blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
* Taken over however many windows came back rather than the three Claude sends today -- the backend * Taken over however many windows came back rather than the three Claude sends today -- the backend
* deliberately passes windows it does not recognise straight through, so a fourth one is a thing * passes windows it does not recognise straight through.
* that happens rather than a thing to notice later.
* *
* Every state that is not a measurement takes the ordinary control colour instead. That is the * Every state that is not a measurement takes the ordinary control colour instead. That is the
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an * point where colour stops being able to help: blue is the low end of a scale here, so colouring an
* unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind * unknown blue would say "measured, and fine" about a machine nobody could reach.
* the button is where those say, in words, which one they are.
*/ */
@Composable @Composable
fun usageGlyphColour(usage: SessionUsage): Color = fun usageGlyphColour(usage: SessionUsage): Color =
@@ -154,18 +148,17 @@ fun usageGlyphColour(usage: SessionUsage): Color =
* going, and it was a screen away from the place that decision gets made. It reports on this * going, and it was a screen away from the place that decision gets made. It reports on this
* session's machine alone -- the dialog is still where every machine is compared. * session's machine alone -- the dialog is still where every machine is compared.
* *
* What it shows is the paid service's own metering, fetched from the machine that holds the * What it shows is the paid service's own metering, never derived from what this app has watched go
* account. It is never derived from what this app has watched go past: the transcript's token * past: the transcript's token counts are a different quantity, measured differently, and a bar
* counts are a different quantity, measured differently, and a bar shaped like a quota gauge built * built out of them would be a guess wearing a measurement's clothes.
* out of them would be a guess wearing a measurement's clothes.
*/ */
@Composable @Composable
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) { fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
DebugStats.count("usage bar recomposed") DebugStats.count("usage bar recomposed")
// The countdown moves even when the numbers do not, so it is driven by a clock of its own // The countdown moves even when the numbers do not, so it is driven by a clock of its own
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal // rather than recomputed at draw time: a percentage that comes back unchanged is an equal
// value, Compose skips the recomposition, and a "left" that only ticked when the quota // value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
// happened to move would sit at a stale figure for hours. // would sit at a stale figure for hours.
var now by remember { mutableStateOf(OffsetDateTime.now()) } var now by remember { mutableStateOf(OffsetDateTime.now()) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {
@@ -174,15 +167,14 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
} }
} }
// Nothing at all for a session that meters nothing: a row saying "unknown" there would // Nothing at all for a session that meters nothing: a row saying "unknown" there would report
// report a problem about a setup somebody chose, on every screen, forever. // a problem about a setup somebody chose, on every screen, forever.
// //
// And nothing while the first fetch is out, which is not the same kind of silence. A // And nothing while the first fetch is out, which is a different silence. A request in flight
// request in flight is not a state to report -- and the session that meters nothing is // is not a state to report -- and the session that meters nothing is exactly the one this
// exactly the one this cannot yet tell apart, so "5-hour usage: checking" appeared under // cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
// an echo session for half a second and was then taken away. A row that has to be // second and was then taken away. A row that has to be withdrawn is worse than one that
// withdrawn is worse than one that arrives late, and this is the only state here whose // arrives late.
// wrongness is a matter of timing rather than of fact.
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) { if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
return return
} }
@@ -239,9 +231,8 @@ private fun UsageNote(text: String) {
* The percentage on its own does not answer the question it gets asked, which is whether to start * The percentage on its own does not answer the question it gets asked, which is whether to start
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers. * something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
* *
* The window's end has two missing cases and they are worded differently on purpose; see * The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
* [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is * that is not running gets the percentage and nothing else.
* no countdown to report and inventing one would be the same fault as inventing the number.
*/ */
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String { private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${window.percent.toInt()}%" val percent = "${window.percent.toInt()}%"
@@ -265,8 +256,7 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one * account and, while a test has one set, an echo session's invented one -- and a snapshot is one
* service on one machine. * service on one machine.
* *
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it: * Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
* a machine nobody logged into, one that could not be reached, a snapshot that came back empty.
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the * None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
* machine having no quota rather than the question going unanswered. * machine having no quota rather than the question going unanswered.
*/ */
@@ -47,15 +47,14 @@ fun SettingsScreen(
val context = LocalContext.current val context = LocalContext.current
var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") } var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") }
var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) } var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) }
// Never pre-filled from the stored token: this screen shouldn't be a // Never pre-filled from the stored token: this screen shouldn't be a way to read the credential
// way to read the credential back off the device. // back off the device.
var token by remember { mutableStateOf("") } var token by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
val scanLauncher = val scanLauncher =
rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult -> rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
// Null contents means the user backed out of the scanner -- not an // Null contents means the user backed out of the scanner -- not an error.
// error, so nothing to report.
val contents = result.contents ?: return@rememberLauncherForActivityResult val contents = result.contents ?: return@rememberLauncherForActivityResult
val settings = parseEnrollmentUri(contents.toUri()) val settings = parseEnrollmentUri(contents.toUri())
if (settings == null) { if (settings == null) {
@@ -83,8 +82,8 @@ fun SettingsScreen(
// left-pointing arrow at the right edge, aimed across the title it sits beside. // left-pointing arrow at the right edge, aimed across the title it sits beside.
// //
// Absent rather than disabled on first run, which is the one place this app lets a // Absent rather than disabled on first run, which is the one place this app lets a
// control come and go: there is no screen underneath yet, so a Back here would not be // control come and go: there is no screen underneath yet, so a Back here would not be a
// a capability being withheld but a promise it could not keep. // capability being withheld but a promise it could not keep.
if (onBack != null) { if (onBack != null) {
GlyphButton(BACK_GLYPH, "Back", onBack) GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
@@ -106,14 +105,11 @@ fun SettingsScreen(
OutlinedButton( OutlinedButton(
onClick = { onClick = {
// Hold the camera permission before the scanner starts. // Hold the camera permission before the scanner starts. Letting its activity ask on
// Letting its activity ask on our behalf is what the // our behalf is what the library does by default, and it opens the camera without
// library does by default, and it opens the camera without // waiting for the answer: the first-ever scan comes up as a live preview with
// waiting for the answer: the first-ever scan comes up as // "Sorry, the Android camera encountered a problem" over it, and works on the
// a live preview with "Sorry, the Android camera // second try.
// encountered a problem" over it, and works on the second
// try. Nothing is wrong with the camera, so nothing should
// say there is.
if ( if (
context.checkSelfPermission(Manifest.permission.CAMERA) == context.checkSelfPermission(Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
@@ -187,11 +183,10 @@ fun SettingsScreen(
* just been granted. * just been granted.
* *
* MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light * MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a * ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a dark-
* dark-themed terminal it comes out as a photographic negative the scanner silently never matches. * themed terminal it comes out as a photographic negative the scanner silently never matches. The
* Which way round it renders is the terminal's business, not something this app should depend on. * mixed decoder alternates normal and inverted frames, costing half the frame rate at each
* The mixed decoder alternates normal and inverted frames, costing half the frame rate at each * polarity.
* polarity and nothing else.
*/ */
private fun enrollmentScanOptions(): ScanOptions = private fun enrollmentScanOptions(): ScanOptions =
ScanOptions() ScanOptions()
@@ -58,8 +58,8 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
LaunchedEffect(reloadToken) { reload() } LaunchedEffect(reloadToken) { reload() }
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp)) {
// The heading and Back are the tab row's now; adding a machine is this tab's own work // The heading and Back are the tab row's now; adding a machine is this tab's own work and
// and stays with the list it adds to. // stays with the list it adds to.
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = { adding = true }) { Text("Add machine") } TextButton(onClick = { adding = true }) { Text("Add machine") }
} }
@@ -200,9 +200,8 @@ private fun SetupCard(
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text(setup.name, style = MaterialTheme.typography.titleSmall) Text(setup.name, style = MaterialTheme.typography.titleSmall)
Text( Text(
// Not "this machine": the seeded setup is *called* that, // Not "this machine": the seeded setup is *called* that, and the card read "this
// and the card read "this machine / this machine". The // machine / this machine".
// line has to say something the name cannot also be.
setup.address ?: "runs where the backend does", setup.address ?: "runs where the backend does",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -277,9 +276,8 @@ private fun AddSetupDialog(
OutlinedTextField( OutlinedTextField(
value = address, value = address,
onValueChange = { address = it }, onValueChange = { address = it },
// Just the shape. What a blank one means is said once, in the text above // Just the shape. What a blank one means is said once, in the text above this
// this form -- repeating it here wrapped the label onto a second line and // form -- repeating it here wrapped the label onto a second line.
// made this field taller than the two beside it for no information.
label = { Text("user@host[:port]") }, label = { Text("user@host[:port]") },
singleLine = true, singleLine = true,
) )
@@ -290,8 +288,7 @@ private fun AddSetupDialog(
singleLine = true, singleLine = true,
) )
// Where a file attached from the phone lands on that machine. Blank means the // Where a file attached from the phone lands on that machine. Blank means the
// session's own directory, which is what most people want and what needs no // session's own directory, which is what most people want.
// path typed on a phone.
OutlinedTextField( OutlinedTextField(
value = attachmentsDir, value = attachmentsDir,
onValueChange = { attachmentsDir = it }, onValueChange = { attachmentsDir = it },
@@ -319,9 +316,8 @@ private fun AddSetupDialog(
}, },
dismissButton = { dismissButton = {
Row { Row {
// Tried before saving, so a wrong address or an // Tried before saving, so a wrong address or an unauthorised key is caught while
// unauthorised key is caught while this form is still on // this form is still on screen rather than at the first spawn.
// screen rather than at the first spawn.
TextButton( TextButton(
enabled = !testing, enabled = !testing,
onClick = { onClick = {
@@ -387,14 +383,13 @@ private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String)
/** /**
* Splits `user@host:port` into its two halves, with the port left null when none was typed. * Splits `user@host:port` into its two halves, with the port left null when none was typed.
* *
* One field rather than two because that is how an address is written and read everywhere else -- * One field rather than two because that is how an address is written and read everywhere else, and
* and because a port that is almost always 22 does not deserve a box of its own on a phone * because a port that is almost always 22 does not deserve a box of its own on a phone keyboard.
* keyboard. Null rather than 22: the backend already decides the default, and writing 22 here would * Null rather than 22: the backend already decides the default.
* put a second answer to that question in a second place.
* *
* A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it, * A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it,
* `[::1]:22`; a bare `::1` keeps every colon, because an address with several is an address, not an * `[::1]:22`; a bare `::1` keeps every colon. So the rule is: brackets, or exactly one colon
* address and a port. So the rule is: brackets, or exactly one colon followed by digits. * followed by digits.
*/ */
private fun splitHostAndPort(typed: String): Pair<String, Int?> { private fun splitHostAndPort(typed: String): Pair<String, Int?> {
if (typed.startsWith("[")) { if (typed.startsWith("[")) {
@@ -9,7 +9,7 @@ import androidx.core.content.IntentCompat
* *
* Held as the URIs rather than uploaded on arrival, because an upload belongs to a session and the * Held as the URIs rather than uploaded on arrival, because an upload belongs to a session and the
* share arrives before anyone has said which. [serial] makes two shares of the same thing two * share arrives before anyone has said which. [serial] makes two shares of the same thing two
* requests, for the reason [SessionOpenRequest] carries one: equal values would not recompose. * requests, for the reason [SessionOpenRequest] carries one.
*/ */
data class ShareRequest(val uris: List<Uri>, val text: String?, val serial: Int) data class ShareRequest(val uris: List<Uri>, val text: String?, val serial: Int)
@@ -3,13 +3,13 @@ package com.example.aiapp
/** /**
* A byte count at the coarsest unit that still says something, so rows stay comparable. * A byte count at the coarsest unit that still says something, so rows stay comparable.
* *
* Null at zero and below, because the two screens that ask disagree about what nothing means and * Null at zero and below, because the screens that ask disagree about what nothing means and only
* only the caller knows: a transcript of no bytes is a measurement that has not happened, and is * the caller knows: a transcript of no bytes is a measurement that has not happened; a file of no
* left off the row; a file of no bytes is a file with nothing in it, and the explorer says `0 B` * bytes is a file with nothing in it, and the explorer says `0 B`; a session with no cached
* rather than leaving a gap the reader would have to interpret. * transcript says "nothing cached", because a figure of none would read as a measurement.
* *
* Its own file rather than the import screen's, where it started: two screens now say a size, and a * Its own file rather than the import screen's, where it started: three screens now say a size, and
* second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B. * a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B.
*/ */
fun humanSize(bytes: Long): String? = fun humanSize(bytes: Long): String? =
when { when {
@@ -37,7 +37,7 @@ import kotlinx.coroutines.withContext
* The spawn screen: what to run, where to run it, and the per-kind fields. * The spawn screen: what to run, where to run it, and the per-kind fields.
* *
* Providers and hosts both come from the server, so adding either to its config.ron shows up here * Providers and hosts both come from the server, so adding either to its config.ron shows up here
* with no app rebuild -- and because they are independent, any provider can be sent to any host. * with no app rebuild.
*/ */
@Composable @Composable
fun SpawnScreen( fun SpawnScreen(
@@ -46,29 +46,24 @@ fun SpawnScreen(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// What the form is made of, and whether we have it yet. A failure here // What the form is made of, and whether we have it yet. A failure here is not the same as a
// is not the same as a server with nothing to offer, so it must not // server with nothing to offer, so it must not reach the pickers as empty lists.
// reach the pickers as empty lists -- see LoadState.
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) } var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
// Setup first, then one of its providers. Choosing a setup can // Setup first, then one of its providers. Choosing a setup can invalidate the provider, so the
// invalidate the provider, so the provider is stored by name and // provider is stored by name and resolved against the current setup rather than held as an
// resolved against the current setup rather than held as an object // object that could outlive the list it came from.
// that could outlive the list it came from.
var setupName by remember { mutableStateOf<String?>(null) } var setupName by remember { mutableStateOf<String?>(null) }
var providerName by remember { mutableStateOf<String?>(null) } var providerName by remember { mutableStateOf<String?>(null) }
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var model by remember { mutableStateOf("") } var model by remember { mutableStateOf("") }
var cwd by remember { mutableStateOf("") } var cwd by remember { mutableStateOf("") }
// "auto" rather than "manual": on a phone every ask is a round trip to // "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
// a question card, and answering "allow Bash?" dozens of times per task // answering "allow Bash?" dozens of times per task is what this app exists to avoid.
// is what this app exists to avoid. Manual stays one tap away for a
// session that warrants it.
var permissionMode by remember { mutableStateOf("auto") } var permissionMode by remember { mutableStateOf("auto") }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
// Only the spawn's own failure. The fetch's lives in `options`: this // Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
// one leaves a filled-in form worth keeping, and that one leaves // form worth keeping, and that one leaves nothing to fill in.
// nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) } var spawnError by remember { mutableStateOf<String?>(null) }
// The models on the *chosen machine*, for a llama provider to choose between. Kept separate // The models on the *chosen machine*, for a llama provider to choose between. Kept separate
// from the setups: a Claude session needs none, so failing to list them must not stop the // from the setups: a Claude session needs none, so failing to list them must not stop the
@@ -103,10 +98,9 @@ fun SpawnScreen(
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Nothing below is fillable until the options are here, and a // Nothing below is fillable until the options are here, and a failure to fetch them leaves
// failure to fetch them leaves no form worth showing -- so this // no form worth showing -- so this reports and stops, rather than offering empty pickers
// reports and stops, rather than offering empty pickers under an // under an error message.
// error message.
val setups = val setups =
when (val state = options) { when (val state = options) {
is LoadState.Loading -> { is LoadState.Loading -> {
@@ -132,10 +126,9 @@ fun SpawnScreen(
.getOrDefault(emptyList()) .getOrDefault(emptyList())
} }
val current = setup?.providers?.firstOrNull { it.name == providerName } val current = setup?.providers?.firstOrNull { it.name == providerName }
// Only the Claude CLI has models, a working directory and // Only the Claude CLI has models, a working directory and permission modes; keying the
// permission modes; keying the extra fields on the kind rather // extra fields on the kind rather than the provider name keeps a second Claude provider
// than the provider name keeps a second Claude provider from // from needing anything here.
// needing anything here.
val isClaude = current?.kind == "claude_cli" val isClaude = current?.kind == "claude_cli"
val isLlama = current?.kind == "llama_cpp" val isLlama = current?.kind == "llama_cpp"
@@ -146,9 +139,9 @@ fun SpawnScreen(
selected = setupName, selected = setupName,
onSelect = { name -> onSelect = { name ->
setupName = name setupName = name
// The provider list changes with the machine, so a name // The provider list changes with the machine, so a name carried over from the
// carried over from the previous one would be a selection // previous one would be a selection that isn't in the picker. Take that machine's
// that isn't in the picker. Take that machine's first. // first.
providerName = providerName =
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
}, },
@@ -159,13 +152,13 @@ fun SpawnScreen(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
// The address belongs to the setup above it, not to the // The address belongs to the setup above it, not to the provider label below; without
// provider label below; without this they read as one block. // this they read as one block.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
// Only what this machine actually has. A setup with none says so // Only what this machine actually has. A setup with none says so rather than showing an
// rather than showing an empty row that reads as a failure. // empty row that reads as a failure.
if (setup != null && setup.providers.isEmpty()) { if (setup != null && setup.providers.isEmpty()) {
Text( Text(
"\"${setup.name}\" has no providers configured.", "\"${setup.name}\" has no providers configured.",
@@ -193,9 +186,8 @@ fun SpawnScreen(
if (isLlama) { if (isLlama) {
// A llama session names one of the models on the machine it will run on, so the // A llama session names one of the models on the machine it will run on, so the
// choice is that list rather than free text -- there is nothing sensible to type // choice is that list rather than free text -- a name that is not on that machine's
// here, and a name that is not on that machine's disk is a session that cannot // disk is a session that cannot start.
// start.
if (models.isEmpty()) { if (models.isEmpty()) {
Text( Text(
"No models on ${setup?.name ?: "this machine"}. The Models screen downloads " + "No models on ${setup?.name ?: "this machine"}. The Models screen downloads " +
@@ -206,9 +198,8 @@ fun SpawnScreen(
} else { } else {
ChipGroup( ChipGroup(
label = "Model", label = "Model",
// The file, not the whole key: the repository is the // The file, not the whole key: the repository is the same for every
// same for every quantisation of a model, so the file // quantisation of a model, so the file name is what tells two of them apart.
// name is what tells two of them apart.
options = models.map { it.file }, options = models.map { it.file },
selected = models.firstOrNull { it.key == modelKey }?.file, selected = models.firstOrNull { it.key == modelKey }?.file,
onSelect = { file -> modelKey = models.first { it.file == file }.key }, onSelect = { file -> modelKey = models.first { it.file == file }.key },
@@ -290,13 +281,9 @@ fun SpawnScreen(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
spawnSession( spawnSession(
settings, settings,
// The id, not the label: labels are // The id, not the label: labels are editable and the server
// editable and the server resolves by // resolves by id. Non-null here, since `chosen` came from
// id. // `setup`'s own provider list.
// Non-null here: `chosen` came from
// `setup`'s own provider list, so
// reaching this point proves there was
// a setup to take it from.
setup = setup.id, setup = setup.id,
provider = chosen.name, provider = chosen.name,
title = title.trim(), title = title.trim(),
@@ -304,9 +291,8 @@ fun SpawnScreen(
if (isLlama) modelKey else model.trim().takeIf { isClaude }, if (isLlama) modelKey else model.trim().takeIf { isClaude },
cwd = cwd.trim().takeIf { isClaude }, cwd = cwd.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { isClaude }, permissionMode = permissionMode.takeIf { isClaude },
// Sent only when set, so blank means // Sent only when set, so blank means "whatever llama.cpp does
// "whatever llama.cpp does by default" // by default" rather than a zero.
// rather than a zero.
params = params =
buildMap { buildMap {
if (isLlama) { if (isLlama) {
@@ -17,14 +17,13 @@ const val RECONNECT_DELAY_MS = 1500L
* One server-sent-events connection, framed. * One server-sent-events connection, framed.
* *
* The framing is the part worth having once: `data:` and `event:` lines accumulate until a blank * The framing is the part worth having once: `data:` and `event:` lines accumulate until a blank
* line ends the frame, comments (keep-alives) start with `:`, and a frame is either named with no * line ends the frame, comments start with `:`, and a frame is either named with no payload or a
* payload or a payload with no name. Two screens follow two different streams a session's * payload with no name. Two screens follow two different streams and neither should re-derive that.
* transcript and what a machine's import list is doing and neither should be re-deriving that.
* *
* Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the * Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the
* cancellation path it disconnects the socket, which unblocks the read, and [run] then returns * cancellation path -- it disconnects the socket, which unblocks the read, and [run] then returns
* rather than throwing, so a deliberate close is not reported as a connection error. Reconnecting * rather than throwing. Reconnecting belongs to the caller, which is the only one that knows where
* belongs to the caller, which is the only one that knows where to resume from. * to resume from.
*/ */
class Sse(private val settings: ServerSettings) { class Sse(private val settings: ServerSettings) {
@Volatile private var connection: HttpURLConnection? = null @Volatile private var connection: HttpURLConnection? = null
@@ -38,19 +37,16 @@ class Sse(private val settings: ServerSettings) {
/** /**
* Follows the stream at [path], handing each frame to [onFrame] as its name (null for an * Follows the stream at [path], handing each frame to [onFrame] as its name (null for an
* ordinary data frame) and its payload. The path is given here rather than at construction * ordinary data frame) and its payload. The path is given here rather than at construction
* because a caller that reconnects usually resumes from somewhere new -- a cursor it has * because a caller that reconnects usually resumes from somewhere new.
* advanced past -- and that lives in the query string.
* *
* [onOpen] fires once the server has accepted the connection. That is the measured moment the * [onOpen] fires once the server has accepted the connection. That is the measured moment the
* stream is live, and the only honest thing to clear a previous failure on: clearing on the * stream is live, and the only honest thing to clear a previous failure on: clearing on the
* first *event* instead left an idle stream displaying a connection error it had already * first *event* instead left an idle stream displaying an error it had already recovered from.
* recovered from, indefinitely.
*/ */
fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) { fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) {
// Opening is inside the try, not before it. Everything this method can fail at owes the // Opening is inside the try, not before it. Everything this method can fail at owes the
// caller the same kind of failure -- both callers retry an [ApiException] and let anything // caller the same kind of failure, and a connection that could not even be constructed used
// else reach the top of the app -- and a connection that could not even be constructed // to escape as a raw `IOException` from a line no `catch` covered.
// used to escape as a raw `IOException` from a line no `catch` covered.
var connection: HttpURLConnection? = null var connection: HttpURLConnection? = null
try { try {
connection = connection =
@@ -44,16 +44,14 @@ private object Mocha {
* *
* Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link* * Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link*
* -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike * -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike
* is a preference, not a contract, and the moment one wants a different accent the shared version * is a preference, not a contract.
* becomes a thing to fight rather than a thing to use.
* *
* The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle, * The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle,
* Base, Surface 0, Surface 1 -- and Material asks for the same thing under different names, so the * Base, Surface 0, Surface 1 -- so the page is Base, a component's outlined card stays Base beside
* page is Base, a component's outlined card stays Base beside it, and a project's card is Surface * it, and a project's card is Surface 0: one visible step up, which is the whole of what the
* 0: one visible step up, which is the whole of what the nesting has to say. * nesting has to say.
* *
* Accents on this palette are light, so anything filled with one takes Crust for its text rather * Accents on this palette are light, so anything filled with one takes Crust for its text.
* than the near-white the roles default to.
*/ */
val AiAppColors = val AiAppColors =
darkColorScheme( darkColorScheme(
@@ -94,10 +92,8 @@ val AiAppColors =
* What a session is doing, said in colour. * What a session is doing, said in colour.
* *
* Here rather than beside each screen that shows a status. These were separate literals in two * Here rather than beside each screen that shows a status. These were separate literals in two
* other files -- an amber, a green and a red picked off Material's defaults -- so the same state * other files, so the same state was a slightly different colour depending which screen you looked
* was a slightly different colour depending which screen you looked at, and none of them belonged * at. A colour that carries meaning is part of the scheme, not a value typed where it was needed.
* to this palette at all. A colour that carries meaning is part of the scheme, not a value typed
* where it happened to be needed.
*/ */
val runningColor: Color val runningColor: Color
@Composable get() = Mocha.Green @Composable get() = Mocha.Green
@@ -107,7 +103,7 @@ val runningColor: Color
* *
* The scheme's error colour, and deliberately not "the same red as a destructive button" even * The scheme's error colour, and deliberately not "the same red as a destructive button" even
* though it is the same red. They are the same red for different reasons, and a state is not an * though it is the same red. They are the same red for different reasons, and a state is not an
* action -- nothing here is a button. * action.
*/ */
val failedColor: Color val failedColor: Color
@Composable get() = MaterialTheme.colorScheme.error @Composable get() = MaterialTheme.colorScheme.error
@@ -116,10 +112,9 @@ val failedColor: Color
* About the session rather than about the task: a command, and the compaction one of them starts. * About the session rather than about the task: a command, and the compaction one of them starts.
* *
* Its own colour because it is its own kind of work. Everything else a session does is progress * Its own colour because it is its own kind of work. Everything else a session does is progress
* through what was asked of it; this is the session acting on itself -- rewriting what it * through what was asked of it; this is the session acting on itself, and none of it appears in the
* remembers, taking a new name -- and none of it appears in the transcript as an answer to * transcript as an answer to anything. A reader who has learned that blue means "not stuck, but not
* anything. A reader who has learned that blue means "not stuck, but not replying to you either" * replying to you either" has learned what distinguishes it from a session that has hung.
* has learned the thing that distinguishes it from a session that has hung.
*/ */
val commandColor: Color val commandColor: Color
@Composable get() = Mocha.Blue @Composable get() = Mocha.Blue
@@ -128,10 +123,9 @@ val commandColor: Color
* A clear: the conversation taken out of what the session is given. * A clear: the conversation taken out of what the session is given.
* *
* Red because of what it does, not because anything went wrong -- somebody asked for this, and a * Red because of what it does, not because anything went wrong -- somebody asked for this, and a
* deliberate choice is not a problem to report. It is the same red as [failedColor] and [stopColor] * deliberate choice is not a problem to report. The same red as [failedColor] and [stopColor] for a
* for a third reason, which is worth naming rather than collapsing: this is neither a fault nor a * third reason: this is neither a fault nor a button, it is the mark left where something was taken
* button, it is the mark left where something was taken away. The reader never has to tell the * away. No two of the three can appear as the same kind of thing.
* three apart, because no two of them can appear as the same kind of thing.
*/ */
val clearedColor: Color val clearedColor: Color
@Composable get() = Mocha.Red @Composable get() = Mocha.Red
@@ -148,9 +142,9 @@ val warningColor: Color
* The fill of a progress bar that is only reporting how far along something is. * The fill of a progress bar that is only reporting how far along something is.
* *
* Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary * Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary
* made it the loudest thing on a screen the reader opened to do something else. A download, or a * made it the loudest thing on a screen the reader opened to do something else. A download has no
* compaction, has no limit to be near: it finishes. Only a bar measuring a *quota* escalates, and * limit to be near: it finishes. Only a bar measuring a *quota* escalates -- that one is
* that one is [quotaColor]. * [quotaColor].
*/ */
val progressColor: Color val progressColor: Color
@Composable get() = Mocha.Blue @Composable get() = Mocha.Blue
@@ -158,15 +152,13 @@ val progressColor: Color
/** /**
* The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red. * The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red.
* *
* One function rather than the same `when` written beside each bar, because the whole point of * One function rather than the same `when` written beside each bar, because the point of colouring
* colouring by consequence is that the reader learns the step once -- two bars showing the same 80% * by consequence is that the reader learns the step once. It reads as a difference in degree, which
* in different colours teaches nothing except that the colour cannot be trusted. It reads as a * is all colour can carry: the states that differ in *kind* -- a window nobody could read, a
* difference in degree, which is all colour can carry: the states that differ in *kind* from this * machine that meters nothing -- are said in words elsewhere.
* -- a window nobody could read, a machine that meters nothing -- are said in words elsewhere,
* because a reader has no way to tell those from an ordinary low number by colour alone.
* *
* [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent * [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent
* without each converting it first and one of them getting it wrong by a factor of a hundred. * without one of them getting it wrong by a factor of a hundred.
*/ */
@Composable @Composable
fun quotaColor(percent: Double): Color = fun quotaColor(percent: Double): Color =
@@ -185,13 +177,11 @@ private const val OVER_LIMIT_PERCENT = 90.0
/** /**
* The surface verbatim text sits on: a command, a tool's output, a code block in a reply. * The surface verbatim text sits on: a command, a tool's output, a code block in a reply.
* *
* The darkest value in the palette rather than a step up from the page, and that is the whole point * The darkest value in the palette rather than a step up from the page, and that is the point --
* -- everything else on this screen is somebody's prose, and this is what a machine was handed and * everything else on this screen is somebody's prose, and this is what a machine was handed and
* what it said back, character for character. Crust sits *below* Base, so the same colour reads as * what it said back, character for character. Crust sits *below* Base, so the same colour reads as
* one clear step down both on the page, where a reply is drawn, and on a card, where a tool call * one clear step down both on the page and on a card; a tint chosen upwards has to be picked twice
* is; a tint chosen upwards has to be picked twice and still collides with the card it lands on. * and still collides with the card it lands on.
* The renderer's default code background was `surfaceVariant`, which is exactly a card's own fill
* -- so a code block inside a tool call had no background at all.
* *
* One colour for all three, so "this is verbatim" is learnable once. * One colour for all three, so "this is verbatim" is learnable once.
*/ */
@@ -202,11 +192,10 @@ val rawSurface: Color
* Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette]. * Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette].
* *
* Here with the rest of the palette rather than beside the code that highlights: the colours a * Here with the rest of the palette rather than beside the code that highlights: the colours a
* fence is drawn in are the same accents every other coloured thing in the app already uses, and * fence is drawn in are the same accents every other coloured thing already uses.
* splitting them out would make code the one surface whose palette came from somewhere else.
* *
* Not a composable, because [highlight] runs off the drawing thread; these colours never vary with * Not a composable, because [highlight] runs off the drawing thread; these never vary with the
* the theme. * theme.
*/ */
fun catppuccinSyntax(): SyntaxPalette = fun catppuccinSyntax(): SyntaxPalette =
SyntaxPalette( SyntaxPalette(
@@ -227,8 +216,7 @@ fun catppuccinSyntax(): SyntaxPalette =
* already made for every other blue on the screen. * already made for every other blue on the screen.
* *
* Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which * Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which
* is upstream's choice and not an omission here. A program that uses bright red to mean something * is upstream's choice and not an omission here.
* other than red is relying on a distinction its own terminal may not draw either.
* *
* The background is [rawSurface] because that is what a tool's output is drawn on, and reverse * The background is [rawSurface] because that is what a tool's output is drawn on, and reverse
* video needs to know what it is reversing against. * video needs to know what it is reversing against.
@@ -263,14 +251,12 @@ fun ansiPalette(): AnsiPalette =
* *
* The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app * The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app
* draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a * draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a
* code block or a tool's output, on Crust, the same 40% composites to a barely-there smudge, so * code block, on Crust, the same 40% composites to a barely-there smudge, so selecting a line of
* selecting a line of code looks like nothing happened even though the selection is there and * code looks like nothing happened even though it copies correctly.
* copies correctly.
* *
* Fixed and stronger, because "this is selected" is a meaning rather than decoration: a colour that * Fixed and stronger, because "this is selected" is a meaning rather than decoration. Raised only
* means something must carry its own contrast instead of borrowing it from the surface it happens * as far as it takes to read on the darkest of them -- past this the fill starts competing with the
* to land on. Raised only as far as it takes to read on the darkest of them -- past this the fill * syntax colours it sits behind.
* starts competing with the syntax colours it sits behind, which are the thing being read.
*/ */
val AiAppSelectionColors = val AiAppSelectionColors =
TextSelectionColors( TextSelectionColors(
@@ -288,11 +274,10 @@ val linkColor: Color
* A list's markers: the bullets and numbers down its left edge. * A list's markers: the bullets and numbers down its left edge.
* *
* The scheme's secondary accent rather than the text colour, because a marker is structure rather * The scheme's secondary accent rather than the text colour, because a marker is structure rather
* than words: coloured, the items of a list can be counted without reading them, and a nested list * than words: coloured, the items of a list can be counted without reading them. Lavender is not
* reads as a shape before it reads as text. Lavender is not one of the colours that mean something * one of the colours that mean something here, and it is the same at every depth, since depth is
* here -- green, red, peach and yellow are states and actions -- and it is the same at every depth, * said by the glyph and the indent -- a colour per depth would make a difference in degree look
* since depth is said by the glyph and the indent; a colour per depth would make a difference in * like one in kind.
* degree look like one in kind.
*/ */
val listMarkerColor: Color val listMarkerColor: Color
@Composable get() = Mocha.Lavender @Composable get() = Mocha.Lavender
@@ -305,11 +290,9 @@ val overLimitColor: Color
* The composer's buttons, coloured by what pressing one does rather than by where it sits. * The composer's buttons, coloured by what pressing one does rather than by where it sits.
* *
* Green makes something happen now, blue makes it happen later, orange takes back what is in * Green makes something happen now, blue makes it happen later, orange takes back what is in
* flight, red ends the process. The near-collisions with the states above are deliberate and worth * flight, red ends the process. The near-collisions with the states above are deliberate: those are
* naming rather than collapsing: [runningColor] is green because a session is working, * *states*, and these are *actions*. A reader never has to tell them apart, because nothing here is
* [failedColor] is red because one fell over, [awaitingColor] is the same orange because a session * a state and nothing there is pressable.
* is waiting on somebody -- those are *states*, and these are *actions*. A reader never has to tell
* them apart, because nothing here is a state and nothing there is pressable.
*/ */
val sendColor: Color val sendColor: Color
@Composable get() = Mocha.Green @Composable get() = Mocha.Green
@@ -322,9 +305,8 @@ val queueColor: Color
* Interrupting the running turn: the work stops and the session stays. * Interrupting the running turn: the work stops and the session stays.
* *
* Orange rather than red because of how much it takes: only what is in flight. The process is still * Orange rather than red because of how much it takes: only what is in flight. The process is still
* there holding the conversation, and the next message starts a turn as though nothing had * there holding the conversation. Red is spent on [stopColor], which is the same button in the same
* happened. Red is spent on [stopColor], which is the same button in the same place when what it * place when what it would end is the session's process.
* would end is the session's process.
*/ */
val pauseColor: Color val pauseColor: Color
@Composable get() = Mocha.Peach @Composable get() = Mocha.Peach
@@ -347,8 +329,7 @@ val startColor: Color
* *
* The content colour is stated here beside the fill rather than inherited. A semantic colour has to * The content colour is stated here beside the fill rather than inherited. A semantic colour has to
* carry its own contrast: these fills are fixed whatever the surface under them does, so the theme * carry its own contrast: these fills are fixed whatever the surface under them does, so the theme
* will not change to rescue a foreground that stops being readable on one of them. Crust is what * will not change to rescue a foreground that stops being readable on one of them.
* every accent on this palette takes, which is the same reason `onPrimary` is Crust above.
*/ */
@Composable @Composable
fun actionButtonColors(fill: Color): ButtonColors = fun actionButtonColors(fill: Color): ButtonColors =
@@ -16,11 +16,10 @@ import org.json.JSONObject
/** /**
* A tool call's input, read rather than dumped. * A tool call's input, read rather than dumped.
* *
* Every tool's input arrives as JSON, and showing it raw makes the reader parse `{"command":"", * Every tool's input arrives as JSON, and showing it raw makes the reader parse
* "timeout":120000}` themselves to find the one line they care about. So the fields that carry the * `{"command":"","timeout":120000}` themselves to find the one line they care about. So the fields
* meaning are pulled out -- the command a shell will run, what it is for, how long it may take -- * that carry the meaning are pulled out, and anything left over is still shown, because dropping a
* and anything left over is still shown, because dropping a field would be claiming the tool has no * field would be claiming the tool has no other input when it might.
* other input when it might.
*/ */
data class ToolInput( data class ToolInput(
/** The thing that will actually be run or read, if this tool has one. */ /** The thing that will actually be run or read, if this tool has one. */
@@ -30,8 +29,8 @@ data class ToolInput(
/** The tool's own one-line summary, when it wrote one. */ /** The tool's own one-line summary, when it wrote one. */
val description: String?, val description: String?,
/** /**
* How long the call may take, in the largest units it fits ([formatMillis]). Shown apart * How long the call may take, in the largest units it fits. Shown apart because it is a limit
* because it is a limit on the call rather than part of what the call does. * on the call rather than part of what the call does.
*/ */
val timeout: String?, val timeout: String?,
/** Everything else, as `name: value` lines. Never dropped. */ /** Everything else, as `name: value` lines. Never dropped. */
@@ -47,7 +46,7 @@ data class ToolInput(
* *
* A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them * A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them
* from being the special case that gets its own code path. Unknown tools fall through to "no * from being the special case that gets its own code path. Unknown tools fall through to "no
* subject, everything is rest", which is what the card always did. * subject, everything is rest".
*/ */
private val SUBJECTS: Map<String, Pair<String, Language?>> = private val SUBJECTS: Map<String, Pair<String, Language?>> =
mapOf( mapOf(
@@ -68,8 +67,8 @@ fun parseToolInput(tool: String, input: String): ToolInput {
try { try {
JSONObject(input) JSONObject(input)
} catch (_: org.json.JSONException) { } catch (_: org.json.JSONException) {
// Not an object: older transcripts and some tools send a bare // Not an object: older transcripts and some tools send a bare string. It is still the
// string. It is still the input, so it is still shown. // input, so it is still shown.
return ToolInput( return ToolInput(
null, null,
null, null,
@@ -100,9 +99,9 @@ fun parseToolInput(tool: String, input: String): ToolInput {
/** /**
* A tool call's input: its subject highlighted, then whatever else it carried. * A tool call's input: its subject highlighted, then whatever else it carried.
* *
* On the dark surface every verbatim thing in the app sits on -- see [RawBlock]. Drawn as nothing * On the dark surface every verbatim thing in the app sits on. Drawn as nothing at all when the
* at all when the call carried neither, rather than as an empty block: a tinted rectangle with * call carried neither, rather than as an empty block: a tinted rectangle with nothing in it is a
* nothing in it is a rendering fault, and it is the shape a tool with no input actually has. * rendering fault.
* *
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs * The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this. * with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
@@ -113,11 +112,11 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
if (parsed.subject == null && parsed.rest.isEmpty()) return if (parsed.subject == null && parsed.rest.isEmpty()) return
RawBlock(modifier) { RawBlock(modifier) {
parsed.subject?.let { subject -> parsed.subject?.let { subject ->
// Not wrapped: a wrapped command hides where its arguments end, // Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// and the long one is the one being read closely. // one being read closely.
Text( Text(
// Not cached: a tool's subject is one command line, which lexes in microseconds // Not cached: a tool's subject is one command line, which lexes in microseconds --
// -- the cache exists for a fence with two hundred lines in it. // the cache exists for a fence with two hundred lines in it.
remember(subject, parsed.language) { highlight(subject, parsed.language) }, remember(subject, parsed.language) { highlight(subject, parsed.language) },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
@@ -42,14 +42,11 @@ import androidx.compose.ui.unit.dp
* the transcript's own order is what paging and the event stream depend on, and one screen's idea * the transcript's own order is what paging and the event stream depend on, and one screen's idea
* of "these belong together" must not reach back into it. * of "these belong together" must not reach back into it.
* *
* Immutable, and said so, because Compose cannot tell. * Immutable, and said so, because Compose cannot tell: a row is rebuilt from the transcript rather
* * than edited, and two rows describing the same events are equal. Compose infers stability from a
* A row is a value: it is rebuilt from the transcript rather than edited, and two rows describing * class's fields, and a `List` field -- which several of these carry -- makes it assume the worst,
* the same events are equal. Compose infers stability from a class's fields, and a `List` field -- * so a page of history landing recomposed all 148 loaded rows including the markdown inside them,
* which several of these carry -- makes it assume the worst, so every composable taking one * measured as 701 compositions for 148 rows in one scroll.
* recomposed whenever anything above it did. A page of history landing recomposed all 148 loaded
* rows including the markdown inside them, measured as 701 compositions for 148 rows in one scroll,
* and that is what a page landing costs on top of the fetch itself.
* *
* The promise this makes is real and has to stay true: nothing here is mutated after it is built. * The promise this makes is real and has to stay true: nothing here is mutated after it is built.
*/ */
@@ -59,16 +56,12 @@ sealed class TranscriptRow {
* This row's identity in the list, which must survive everything that can happen to the row. * This row's identity in the list, which must survive everything that can happen to the row.
* *
* The list is keyed by this so that inserting a new message at one end, or a page of history at * The list is keyed by this so that inserting a new message at one end, or a page of history at
* the other, moves the rows and not the reader. That makes it the load-bearing value on this * the other, moves the rows and not the reader. When a key changes, the list loses its anchor
* screen: when a key changes, the list loses its anchor and the transcript steps under whoever * and the transcript steps under whoever is reading it.
* is reading it.
* *
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number, * A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
* and it is the *same* value whether the run is drawn as one card or as a group. A lone call * and it is the *same* value whether the run is drawn as one card or as a group. Which value
* that gains a neighbour becomes a group without changing identity, which is the case a * that is belongs to the item ([TranscriptItem.key]), not to a `when` here.
* seq-based key got wrong: the row the reader was looking at was replaced rather than updated.
* Which value that is belongs to the item ([TranscriptItem.key]), not to a `when` here: a row
* is one item and the item is what knows what it is called.
*/ */
abstract val key: Any abstract val key: Any
@@ -76,12 +69,9 @@ sealed class TranscriptRow {
* Where this row starts in the transcript: the sequence number of the oldest event behind it. * Where this row starts in the transcript: the sequence number of the oldest event behind it.
* *
* Separate from [key], and deliberately so. [key] is the list's identity and is a display * Separate from [key], and deliberately so. [key] is the list's identity and is a display
* decision -- a tool row is named after its run, and a run takes its name from whichever call * decision; a seq is the server's own numbering, assigned once and meaning the same thing to
* was first when it was folded, which changes as pages arrive. A seq is the server's own * every device. So anything that has to point at a place in the conversation and still find it
* numbering: it is assigned once, never moves, and means the same thing to every device. So * later -- a saved scroll position -- points with this.
* anything that has to point at a place in the conversation and still find it later -- a saved
* scroll position is the one -- points with this, and anything that has to identify a row
* within one composition uses [key].
*/ */
abstract val startSeq: Long abstract val startSeq: Long
@@ -133,8 +123,7 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
// Grouped by the run each call says it belongs to, not by adjacency worked out here. // Grouped by the run each call says it belongs to, not by adjacency worked out here.
// Adjacency is the same answer most of the time and a worse one at the edges: a call // Adjacency is the same answer most of the time and a worse one at the edges: a call
// arriving next to an existing run, or a page of history arriving in front of one, both // arriving next to an existing run, or a page of history arriving in front of one, both
// change which call is *first*, and a group named after its first member is a different // change which call is *first*.
// group every time that happens.
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) { if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
run += item run += item
} else { } else {
@@ -152,18 +141,14 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
* What says the calls belong together is the surface behind them, which is the one cue rather than * What says the calls belong together is the surface behind them, which is the one cue rather than
* two half-cues -- rounded to the same corner every other card in the app has, so a group reads as * two half-cues -- rounded to the same corner every other card in the app has, so a group reads as
* one object rather than as a square patch behind round things. The calls sit on it inset by * one object rather than as a square patch behind round things. The calls sit on it inset by
* [GROUP_INSET], which is the container's own padding rather than an indent: they are the same rows * [GROUP_INSET], which is the container's own padding rather than an indent.
* they would be on their own, and a rounded corner drawn hard against a rounded corner reads as a
* notch.
* *
* Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so * Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so
* the run reads as one thing broken into its parts; [GROUP_GAP] keeps the parts legible without * the run reads as one thing broken into its parts; see [connectedShape].
* separating them. See [connectedShape].
* *
* It closes from either end. A long group's header scrolls off while its last call is still on * It closes from either end. A long group's header scrolls off while its last call is still on
* screen, and the reader who wants it shut is looking at the bottom, not hunting for the top. The * screen, and the reader who wants it shut is looking at the bottom. The bar at the foot is the
* bar at the foot is the same height as the heading at the top, so the surface the calls sit on is * same height as the heading at the top.
* as thick below them as above.
*/ */
@Composable @Composable
fun ToolGroup( fun ToolGroup(
@@ -171,8 +156,7 @@ fun ToolGroup(
expanded: Boolean, expanded: Boolean,
/** /**
* Where it was pressed is the row's business rather than the control's -- a group has a control * Where it was pressed is the row's business rather than the control's -- a group has a control
* at each end, and only the row knows where its own ends are, so the row records the touch * at each end, and only the row knows where its own ends are.
* itself and this just says that one happened.
*/ */
onToggle: () -> Unit, onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean, isToolExpanded: (String) -> Boolean,
@@ -222,8 +206,8 @@ fun ToolGroup(
) )
} }
} }
// Shutting it from here anchors the other end: the reader is at the bottom of a long // Shutting it from here anchors the other end: the reader is at the bottom of a long group,
// group, and what they are looking at is what follows it. // and what they are looking at is what follows it.
CollapseBar(barHeight, onToggle) CollapseBar(barHeight, onToggle)
} }
} }
@@ -232,8 +216,8 @@ fun ToolGroup(
* The height of a group's heading, and so of the bar at its foot. * The height of a group's heading, and so of the bar at its foot.
* *
* Derived from the type the heading is set in rather than written down, because the two have to * Derived from the type the heading is set in rather than written down, because the two have to
* match and a pair of numbers chosen to look equal stops being equal the moment either the style or * match and a pair of numbers chosen to look equal stops being equal the moment the density
* the density changes. Taking the line height also means the heading cannot be clipped by it. * changes.
*/ */
@Composable @Composable
private fun groupBarHeight(): Dp { private fun groupBarHeight(): Dp {
@@ -242,10 +226,9 @@ private fun groupBarHeight(): Dp {
} }
/** /**
* The bottom half of a group's toggle: an arrow back up to its heading. * The bottom half of a group's toggle: an arrow back up to its heading. Given the heading's height
* * rather than padded to something that looks close, so the surface the calls sit on is the same
* Given the heading's height rather than padded to something that looks close, so the surface the * thickness at both ends.
* calls sit on is the same thickness at both ends. See [groupBarHeight].
*/ */
@Composable @Composable
private fun CollapseBar(height: Dp, onToggle: () -> Unit) { private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
@@ -266,8 +249,7 @@ private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
* does not. * does not.
* *
* Written once and given an index rather than branched at each end, because a stack has three cases * Written once and given an index rather than branched at each end, because a stack has three cases
* that are one rule -- and the middle one is the case a hand-written first/last pair gets wrong * that are one rule -- and the middle one is what a hand-written first/last pair gets wrong.
* when a run turns out to have three calls in it.
*/ */
@Composable @Composable
private fun connectedShape(index: Int, count: Int): CornerBasedShape { private fun connectedShape(index: Int, count: Int): CornerBasedShape {
@@ -294,12 +276,10 @@ private val GROUP_GAP = 2.dp
* One tool call. * One tool call.
* *
* Closed, it is a single line: the tool's name and what the call is for. The command itself is not * Closed, it is a single line: the tool's name and what the call is for. The command itself is not
* on it, because a wrapped command turns one row into four and a run of them into a wall -- and the * on it, because a wrapped command turns one row into four and a run of them into a wall.
* name plus the intent is what somebody scanning the transcript is reading for.
* *
* Open, it shows the command, whatever else the input carried, and the output. The timeout sits at * Open, it shows the command, whatever else the input carried, and the output. The timeout sits at
* the top right: it is a limit on the call rather than part of what the call does, and it is worth * the top right: it is a limit on the call rather than part of what the call does.
* seeing beside the command it constrains rather than buried in the fields below it.
* *
* A call waiting on permission is shown open whatever the reader last chose, since the command is * A call waiting on permission is shown open whatever the reader last chose, since the command is
* the thing being decided and a row saying only "Bash" cannot be decided on. * the thing being decided and a row saying only "Bash" cannot be decided on.
@@ -342,10 +322,9 @@ fun ToolCard(
) )
} ?: Spacer(Modifier.weight(1f)) } ?: Spacer(Modifier.weight(1f))
} }
// A spinner says the machine is working. While this call is waiting on an // A spinner says the machine is working. While this call is waiting on an answer
// answer the machine is doing nothing at all -- the turn is stopped on the // the machine is doing nothing at all -- the turn is stopped on the person reading
// person reading it -- so it says whose move it is instead, in the colour this // it -- so it says whose move it is instead.
// app uses everywhere for that.
if (deciding) { if (deciding) {
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
@@ -370,9 +349,9 @@ fun ToolCard(
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
} }
// Everything AskUserQuestion carries is the questions, and those are drawn // Everything AskUserQuestion carries is the questions, and those are drawn below as
// below as something answerable; dumping the same JSON above them would be the // something answerable; dumping the same JSON above them would be the decision
// decision stated twice, once unreadably. // stated twice, once unreadably.
if (tool.tool != ASK_USER_QUESTION) { if (tool.tool != ASK_USER_QUESTION) {
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp)) ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
} }
@@ -381,14 +360,12 @@ fun ToolCard(
Text("Output", style = MaterialTheme.typography.labelSmall) Text("Output", style = MaterialTheme.typography.labelSmall)
// What the tool printed, on the surface everything verbatim gets and in the // What the tool printed, on the surface everything verbatim gets and in the
// face it was written for: this is column-aligned far more often than it is // face it was written for: this is column-aligned far more often than it is
// prose -- a directory listing, a diff, a table of numbers -- and a // prose, and a proportional font silently destroys the alignment that carried
// proportional font silently destroys the alignment that carried the meaning. // the meaning.
// //
// Its terminal styling applied and the rest of the escapes taken out, since // Its terminal styling applied and the rest of the escapes taken out: colour is
// what a shell prints is written for a terminal: colour is often the whole of // often the whole of what a diff or a test run is saying. Remembered against
// what a diff or a test run is saying, and the sequences that carry it are // the text, so a card that is open through a scroll parses once.
// unreadable drawn verbatim. Remembered against the text, so a card that is
// open through a scroll parses once. See [ansiStyled].
val palette = remember { ansiPalette() } val palette = remember { ansiPalette() }
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) } val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
RawBlock(Modifier.padding(top = 2.dp)) { RawBlock(Modifier.padding(top = 2.dp)) {
@@ -400,10 +377,8 @@ fun ToolCard(
} }
} }
} }
// Shown open or closed. A call that produced a picture is one // Shown open or closed. A call that produced a picture is one whose result *is* the
// whose result *is* the picture, and a row that hides it says // picture, and a row that hides it says less than the one line it replaced.
// less than the one line it replaced -- unlike a command, which
// is what the closed line already summarises.
tool.images.forEach { ref -> image(ref) } tool.images.forEach { ref -> image(ref) }
if (tool.asks.isNotEmpty()) { if (tool.asks.isNotEmpty()) {
if (tool.tool == ASK_USER_QUESTION) { if (tool.tool == ASK_USER_QUESTION) {
@@ -428,11 +403,10 @@ private fun PermissionAsk(
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit, onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
) { ) {
// What was pressed, before the answer has been round-tripped. Two bare words with no submit // What was pressed, before the answer has been round-tripped. Two bare words with no submit
// step -- unlike a question card, where the answer is several choices and worth reviewing -- // step -- unlike a question card, where the answer is worth reviewing -- so the press has to be
// so the press has to be its own acknowledgement or the row sits unchanged for a round trip // its own acknowledgement or the row sits unchanged for a round trip. Cleared when the request
// and reads as having missed the tap. Cleared when the request settles: by then either the // settles: by then either the answer is in `ask.answers`, or it failed and the buttons come
// answer is in `ask.answers` and the mark stands on a measurement, or it failed and the // back.
// buttons come back rather than leaving a decision marked that nothing recorded.
var pressed by remember(ask.id) { mutableStateOf<String?>(null) } var pressed by remember(ask.id) { mutableStateOf<String?>(null) }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
@@ -442,8 +416,7 @@ private fun PermissionAsk(
) )
// Answered or not, the options stay and the one that was taken is marked -- see // Answered or not, the options stay and the one that was taken is marked -- see
// [AskedQuestion], which is the same rule on the question card. A permission is where it // [AskedQuestion], which is the same rule on the question card. A permission is where it
// matters most: "Answered: Deny" alone does not say that Allow was the alternative, and // matters most: "Answered: Deny" alone does not say that Allow was the alternative.
// whether a tool was allowed or refused is the thing a reader comes back to this row for.
val settled = ask.answers.isNotEmpty() val settled = ask.answers.isNotEmpty()
AnswerOptions( AnswerOptions(
ask.options, ask.options,
@@ -0,0 +1,589 @@
package com.example.aiapp
import android.util.Log
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.io.RandomAccessFile
/**
* This phone's copy of the transcripts it has already been sent, so reopening a session does not
* download it again.
*
* What is stored is the server's own JSON for one event per line, in transcript order. Reading the
* cache means running the same [parseSeqEvent] the network path runs, so a cached transcript and a
* fetched one cannot draw differently, and an event type this build does not know keeps every field
* it arrived with for the build that will. Rows are deliberately *not* what is stored: a row is a
* rendering, and a cache of rows would need throwing away on every update that touched `foldEvent`.
*
* See TRANSCRIPT_CACHE.md for the design. Four rules run through all of it:
* 1. what is on screen is what the server's transcript says, in order, with nothing missing -- the
* cache is a copy and is never inferred, folded or edited here;
* 2. a cached line is never ahead of the live cursor, and the cursor never ahead of the cache;
* 3. the cache is never load-bearing -- missing, evicted, damaged or unwritable all degrade to a
* cold open, never to a blank or a wrong screen; 4. a line already on the phone is not fetched
* again.
*
* A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs
* under the JVM unit tests. That is also why there is no JSON parser here: what it needs off a line
* is the sequence number and whether the line is a streamed delta, both read with a regex. A line
* it cannot read that way is treated as damage. [warn] is where failures are said for the same
* reason.
*/
class TranscriptCache(
private val root: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) {
/** The cache for one session, whether or not anything has been stored for it yet. */
fun session(id: String): SessionCache = SessionCache(File(root, id), warn)
/**
* Deletes every session directory not in [ids], called after a successful list fetch. The path
* out for a session deleted on another device: nothing here would otherwise hear about it, and
* unlike a draft's few bytes what it leaves behind is megabytes.
*/
fun retainOnly(ids: Set<String>) =
guardIo(Unit, warn) {
sessionDirs().forEach { if (it.name !in ids) it.deleteRecursively() }
}
/**
* Deletes least-recently-touched session directories, never [keep], until the whole of this
* server's cache is under [budget]. Least-recently-touched rather than largest: what a reader
* is likely to open again is what they opened last, and evicting the big ones first would empty
* the cache for exactly the conversations it exists for.
*/
fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) =
guardIo(Unit, warn) {
val dirs = sessionDirs().sortedBy { it.lastModified() }
var total = dirs.sumOf { sizeOf(it) }
for (dir in dirs) {
if (total <= budget) break
if (dir.name == keep) continue
val was = sizeOf(dir)
if (dir.deleteRecursively()) total -= was
}
}
fun purgeAll() = guardIo(Unit, warn) { root.deleteRecursively() }
private fun sessionDirs(): List<File> = root.listFiles()?.filter { it.isDirectory }.orEmpty()
}
/**
* How much of this phone's cache directory all of one server's transcripts may take. A dozen of the
* largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small fraction of a phone.
* A number to revisit against real use rather than a measurement of anything.
*/
const val CACHE_BUDGET_BYTES: Long = 256L * 1000 * 1000
/**
* What the newest cached line says, which is what the probe checks against the server. Both halves
* are wanted together: the seq is what the request asks about, and the line is what its answer is
* compared with.
*/
data class CachedTail(val seq: Long, val line: String)
/**
* One session's cached lines, as a directory of chunks.
*
* A chunk is a set of lines *and a claim about what they cover*, and the two are not the same
* thing: a coalesced page joins each run of streamed deltas into one event carrying the seq of the
* run's oldest delta, so a page whose newest event is seq 1,200 may cover everything up to the
* 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open range
* in the file's name:
* ```
* <first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
* <first>-<end>.raw.jsonl an uncoalesced page, or a closed live run <first>-open.raw.jsonl the
* live run; end is its last line's seq + 1
* ```
*
* Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run ending
* at the newest chunk -- the **suffix** -- is ever served: chunks behind a gap are kept, because
* the gap is usually closed by paging back through it, but nothing is served across one.
*
* **The newest chunk is always raw**, which is what makes the stream cursor and the probe well
* defined. It holds by construction (the opening window and every stream frame are raw) and is
* checked on read: a `.rows` chunk at the newest end can only mean this app died between closing
* one live run and opening the next, and it discards the session.
*
* Nothing here is load-bearing. Every operation that touches the disk answers as though the cache
* were empty when it cannot, and a write failure disables writing for the rest of this instance's
* life so that a full disk costs one log line rather than one per delta.
*
* Every operation is synchronized, because two of them really do run at once: the stream appends
* live events from its own IO thread while a reader scrolling back reads pages from another. What
* it buys is that the open chunk's name, its end and its writer are never read half-rotated.
*/
class SessionCache(
private val dir: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) {
/** Set by the first write that fails: a second would fail the same way, once per delta. */
private var disabled = false
/**
* The open chunk's writer, its file, and the seq that chunk now ends at.
*
* Buffered, and flushed on [flush], because a delta is a hundred bytes and arrives dozens of
* times a second while a reply streams. What that costs is the unflushed tail on a crash, which
* is safe: a shorter cache is a longer catch-up, never a wrong one.
*/
private var writer: BufferedWriter? = null
private var openFile: File? = null
private var openEnd: Long = 0
/**
* The newest line of the suffix, or null when there is none or the newest chunk is not raw.
*
* This is the cursor the live stream would resume from, so it is also what has to be shown to
* still be the server's own line before anything is resumed from it.
*/
@Synchronized
fun tail(): CachedTail? =
guard(null) {
val newest = suffix().lastOrNull() ?: return@guard null
var found: CachedTail? = null
eachLine(newest) { line ->
found = CachedTail(seqOf(line)!!, line)
false
}
found
}
/** The newest [limit] lines of the suffix, oldest first -- the opening window. */
@Synchronized
fun newest(limit: Int): List<String> =
guard(emptyList()) {
val taken = ArrayDeque<String>()
for (chunk in suffix().asReversed()) {
if (taken.size >= limit) break
eachLine(chunk) { line ->
taken.addFirst(line)
taken.size < limit
}
}
taken.toList()
}
/**
* The page of lines before [before], oldest first, or null when the cache cannot answer.
*
* Null is a miss -- the suffix does not cover the ground immediately below [before] -- and
* means the server has to be asked. Deliberately not an empty list: an empty page is how the
* screen is told it has reached the start of the conversation, and a cache saying that of
* history it merely does not hold would stop the transcript scrolling back for good.
*
* [before] is anywhere inside the suffix, not only at a chunk boundary. The cursor a warm open
* leaves behind is in the middle of the live run, so a cache that could only answer at a
* boundary would send the very first backwards page to the server and, since that page would
* overlap the run, keep none of it.
*
* With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`.
* The deltas are not joined here -- `foldEvent` does that, and the joined row keeps the seq of
* its first delta either way.
*/
@Synchronized
fun page(before: Long, limit: Int, rows: Boolean): List<String>? =
guard(null) {
val suffix = suffix()
val newest = suffix.lastOrNull() ?: return@guard null
// Above what is held, or at or below where it starts: either way the run the caller is
// scrolling into is not continuous with this one, and only the server has it.
if (before > newest.end || before <= suffix.first().first) return@guard null
val taken = ArrayDeque<String>()
var counted = 0
var inRun = false
var wanting = true
for (chunk in suffix.asReversed()) {
if (!wanting) break
if (chunk.first >= before) continue
eachLine(chunk) { line ->
// The page is what is *before* the cursor; the rows at or above it are already
// on screen.
if (seqOf(line)!! >= before) return@eachLine true
if (rows) {
val delta = isDelta(line)
// Stop only between rows: a delta continuing the run being gathered is part
// of a row already counted, and breaking on it would drop the half of that
// row already taken.
if (counted >= limit && !(delta && inRun)) wanting = false
else {
if (!delta || !inRun) counted++
inRun = delta
}
} else if (taken.size >= limit) {
wanting = false
}
if (wanting) taken.addFirst(line)
wanting
}
}
taken.toList()
}
/**
* The `end` of the nearest chunk at or below [before], which is the floor a fetched page is
* asked with so that it stops where this phone's copy starts. Null when there is no such chunk.
*
* Any chunk, not only the suffix's: the whole point is to reach the run behind a gap, so that
* the gap is closed with exactly the bytes it is wide.
*/
@Synchronized
fun coveredUpTo(before: Long): Long? =
guard(null) { chunks().map { it.end }.filter { it <= before }.maxOrNull() }
/**
* Stores a fetched page covering `[first, end)`; false when it was not stored.
*
* Refused when it overlaps a chunk already here, because there is no clean cut: a coalesced
* event cannot be split at a seq inside its own delta run. `TranscriptSource` keeps that from
* arising by bounding what it fetches, and this is the guard for a page that arrives anyway.
* Such a page is still drawn; it is only not kept.
*
* The newest chunk is never stored through here: the opening window and every live frame go
* through [append], which is what keeps the newest chunk raw and open.
*/
@Synchronized
fun storePage(lines: List<String>, first: Long, end: Long, rows: Boolean): Boolean =
guard(false) {
if (disabled || lines.isEmpty() || end <= first) return@guard false
if (chunks().any { first < it.end && it.first < end }) return@guard false
dir.mkdirs()
val kind = if (rows) "rows" else "raw"
File(dir, "$first-$end.$kind.jsonl").writeText(lines.joinToString("\n", postfix = "\n"))
true
}
/**
* Appends one live event, which is also how a freshly fetched opening window is stored.
*
* A seq equal to the open chunk's end extends it. A larger one is a gap -- which is what a
* `reset` looks like from here -- and closes the open chunk under the end it turned out to
* have. A smaller one is already covered and is ignored; the SSE contract is `seq > after`.
*/
@Synchronized
fun append(line: String, seq: Long) =
guard(Unit) {
if (disabled) return@guard
val writer = writerFor(seq) ?: return@guard
// Written as it arrived. A newline inside it would split one event into two unreadable
// halves, but neither source can produce one: SSE framing forbids it, and a page's
// elements are re-serialized compactly, which escapes it.
writer.write(line)
writer.write("\n")
openEnd = seq + 1
}
/**
* Flushes what [append] has buffered. Called on each `Status` event -- the boundaries of a
* turn, which is the granularity a crash may as well lose -- and when the stream closes.
*/
@Synchronized fun flush() = guard(Unit) { writer?.flush() }
/** What [purge] would discard, for the reload row in session settings. */
@Synchronized fun bytes(): Long = guard(0L) { sizeOf(dir) }
/** Marks this session as visited, which is what eviction ranks by. */
@Synchronized
fun touch() =
guard(Unit) { if (dir.isDirectory) dir.setLastModified(System.currentTimeMillis()) }
@Synchronized
fun purge() =
guard(Unit) {
closeWriter()
dir.deleteRecursively()
}
// -- chunks ------------------------------------------------------------------------------
private data class Chunk(val file: File, val first: Long, val end: Long, val open: Boolean) {
val rows: Boolean
get() = file.name.endsWith(".rows.jsonl")
}
/**
* Every chunk on disk, oldest first. A name this does not recognise is not ours and is ignored.
* Recomputed per operation rather than kept: another operation may have changed the directory.
*/
private fun chunks(): List<Chunk> {
writer?.flush()
return dir.listFiles()
.orEmpty()
.mapNotNull { file ->
val match = CHUNK_NAME.matchEntire(file.name) ?: return@mapNotNull null
val first = match.groupValues[1].toLongOrNull() ?: return@mapNotNull null
val open = match.groupValues[2] == "open"
val end = if (open) openEndOf(file, first) else match.groupValues[2].toLongOrNull()
// A chunk covering nothing is one that was created and never written to -- an
// append whose very first write failed. It says nothing, so it is not a chunk.
if (end == null || end <= first) null else Chunk(file, first, end, open)
}
.sortedBy { it.first }
}
/**
* The open chunk's end: its last line's seq plus one, or the in-memory end while this instance
* is the one writing it.
*
* An open chunk whose last line cannot be read is this app having died mid-write. That line is
* dropped and the file truncated to the last good one, which is the one place damage is
* repaired rather than discarded: the tail of an append-only file is the only place a partial
* line can be.
*/
private fun openEndOf(file: File, first: Long): Long {
if (openFile == file && openEnd > 0) return openEnd
repairTail(file)
var end = first
eachLineBackwards(file) { _, line ->
seqOf(line)?.let { end = it + 1 }
false
}
return end
}
/**
* The contiguous run of adjacent chunks ending at the newest one, oldest first.
*
* A newest chunk that is not raw cannot happen while this code is the only writer, and means
* the directory is not to be trusted -- so the session is discarded.
*/
private fun suffix(): List<Chunk> {
val all = chunks()
var index = all.size - 1
val newest = all.lastOrNull() ?: return emptyList()
if (newest.rows) throw Damaged(newest.file)
val run = ArrayDeque<Chunk>()
run.addFirst(newest)
while (index > 0 && all[index - 1].end == run.first().first) {
index--
run.addFirst(all[index])
}
return run.toList()
}
/**
* Each line of [chunk], newest first, until [take] says stop.
*
* Backwards and lazily, because every question this cache is asked is about the newest end and
* a live run grows to the size of the conversation. Reading the file whole to answer with
* eighty lines of it is the cost the server's own reader was rewritten to stop paying.
*
* Damage anywhere but at the tail of the open chunk was not written by this code, and there is
* no honest way to say what a chunk covers with a line of it unreadable -- so it discards the
* session rather than serving what it can read.
*/
private fun eachLine(chunk: Chunk, take: (String) -> Boolean) {
eachLineBackwards(chunk.file) { _, line ->
if (seqOf(line) == null) throw Damaged(chunk.file)
take(line)
}
}
// -- writing -----------------------------------------------------------------------------
/** The writer for the chunk [seq] belongs in, opening or rotating one as it has to. */
private fun writerFor(seq: Long): BufferedWriter? {
writer?.let { held ->
if (seq == openEnd) return held
if (seq < openEnd) return null
// A gap: what this instance has written covers up to `openEnd`, and that is the name
// the chunk gets before a new one starts at the arriving seq.
closeOpenChunk(openEnd)
}
dir.mkdirs()
// An open chunk left by an earlier instance, or by an earlier screen.
chunks()
.lastOrNull { it.open }
?.let { existing ->
if (seq < existing.end) return null
if (seq == existing.end) {
openFile = existing.file
openEnd = existing.end
return FileWriter(existing.file, true).buffered().also { writer = it }
}
rename(existing.file, existing.first, existing.end)
}
// A chunk that was created and never written to would otherwise be left behind under a name
// a second one is about to want; it covers nothing, so nothing is lost with it.
dir.listFiles().orEmpty().forEach {
if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L)
it.delete()
}
val file = File(dir, "$seq-open.raw.jsonl")
openFile = file
openEnd = seq
return FileWriter(file, false).buffered().also { writer = it }
}
/** Renames the open chunk to the range it turned out to cover, so it stops being open. */
private fun closeOpenChunk(end: Long) {
val file = openFile
closeWriter()
if (file == null) return
val first = CHUNK_NAME.matchEntire(file.name)?.groupValues?.get(1)?.toLongOrNull()
if (first != null) rename(file, first, end)
}
private fun rename(file: File, first: Long, end: Long) {
file.renameTo(File(dir, "$first-$end.raw.jsonl"))
}
private fun closeWriter() {
try {
writer?.close()
} catch (_: IOException) {
// Nothing left to do about it: the file is what it is, and the read path repairs a
// half-written tail.
}
writer = null
openFile = null
openEnd = 0
}
// -- failure -----------------------------------------------------------------------------
/** A chunk that cannot be read as what its name claims. */
private class Damaged(val file: File) : RuntimeException()
/**
* Runs [body], answering [ifBroken] when the directory cannot give a real answer.
*
* None of this is reported on screen: none of it changes what the screen shows -- every read
* here has a network path beside it producing the same result -- and the reader has nothing to
* do about it. Damage discards this session's cache, which makes the next open an ordinary cold
* one.
*/
private fun <T> guard(ifBroken: T, body: () -> T): T =
// A disk that refused once will refuse again, once per delta, so the first refusal is also
// the last: this instance stops writing rather than logging a line a token.
guardIo(
ifBroken,
warn,
onFailure = {
disabled = true
closeWriter()
},
) {
try {
body()
} catch (e: Damaged) {
warn("transcript cache damaged at ${e.file}; discarding ${dir.name}")
closeWriter()
dir.deleteRecursively()
ifBroken
}
}
}
/** `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is not ours. */
private val CHUNK_NAME = Regex("""^(\d+)-(\d+|open)\.(rows|raw)\.jsonl$""")
private val SEQ_IN_LINE = Regex(""""seq"\s*:\s*(\d+)""")
private val TYPE_IN_LINE = Regex(""""type"\s*:\s*"([^"]*)"""")
/**
* One line's sequence number, or null when the line is not one of ours.
*
* A regex rather than a JSON parse, so that this file carries no parser and runs under the JVM
* tests: the seq is the first field the server writes, so the first match is the top-level one.
*/
private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull()
/** Whether a line is one streamed piece of a reply, which is what makes a run of them one row. */
private fun isDelta(line: String): Boolean =
TYPE_IN_LINE.find(line)?.groupValues?.get(1) == "assistantText"
/**
* How much of a file is read at a time when walking it backwards. One block covers a page of a
* transcript comfortably, and the walk stops as soon as the caller has what it asked for.
*/
private const val READ_BLOCK = 64 * 1024
/**
* Calls [onLine] with each non-blank line of [file], **newest first**, along with the byte offset
* it starts at, until [onLine] answers false.
*
* Every question the cache is asked is about the newest end of a chunk, and a live run reaches the
* size of the conversation, so reading forwards means reading a transcript to answer with the last
* eighty lines of it.
*
* Splitting on bytes is safe because the separator is `\n`, which cannot occur inside a multi-byte
* UTF-8 sequence; each line is decoded whole. A missing file yields nothing.
*/
private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) {
if (!file.isFile) return
RandomAccessFile(file, "r").use { handle ->
// Bytes below `unread` have not been looked at; `pending` is the oldest line so far, which
// is incomplete until a newline is found before it in an older block.
var unread = handle.length()
var pending = ByteArray(0)
while (unread > 0) {
val take = minOf(READ_BLOCK.toLong(), unread).toInt()
val start = unread - take
val block = ByteArray(take)
handle.seek(start)
handle.readFully(block)
val buffer = if (pending.isEmpty()) block else block + pending
var lineEnd = buffer.size
var at = buffer.size - 1
while (at >= 0) {
if (buffer[at] == NEWLINE) {
val line = String(buffer, at + 1, lineEnd - at - 1, Charsets.UTF_8)
if (line.isNotBlank() && !onLine(start + at + 1, line)) return
lineEnd = at
}
at--
}
pending = buffer.copyOfRange(0, lineEnd)
unread = start
}
// The first line of a file has no newline before it to be found.
val first = String(pending, Charsets.UTF_8)
if (first.isNotBlank()) onLine(0, first)
}
}
private const val NEWLINE = '\n'.code.toByte()
/**
* Drops a final line that is not one of ours, by truncating the file to where it starts.
*
* This app having died mid-write is the one kind of damage that is repaired rather than discarded:
* the tail of an append-only file is the only place a partial line can be. A second bad line is not
* this, and is left for the read path to notice.
*/
private fun repairTail(file: File) {
var truncateTo = -1L
eachLineBackwards(file) { offset, line ->
if (seqOf(line) == null) truncateTo = offset
false
}
if (truncateTo >= 0) RandomAccessFile(file, "rw").use { it.setLength(truncateTo) }
}
private fun sizeOf(file: File): Long =
if (file.isDirectory) file.listFiles().orEmpty().sumOf { sizeOf(it) } else file.length()
/**
* The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance.
* [onFailure] is what the caller does about it beyond answering [ifBroken].
*/
private fun <T> guardIo(
ifBroken: T,
warn: (String) -> Unit,
onFailure: () -> Unit = {},
body: () -> T,
): T =
try {
body()
} catch (e: IOException) {
warn("transcript cache unusable: ${e.message}")
onFailure()
ifBroken
} catch (e: SecurityException) {
warn("transcript cache unreadable: ${e.message}")
onFailure()
ifBroken
}
@@ -5,9 +5,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The * What the transcript renders: the event stream folded into displayable rows (see [foldEvent]).
* stream is the only data source -- opening a session screen replays from seq 0, and a reconnect *
* resumes from the last seq seen, so there is no separate history fetch to drift from it. * Events are the only data source, and there is deliberately no second shape for history to drift
* from: a page fetched backwards, a live frame, and a line read out of this phone's own cache are
* all the same events through the same parser. [TranscriptCache] stores the server's lines rather
* than these rows for exactly that reason -- a row is a rendering, and its shape changes whenever
* this file does.
*/ */
@Immutable @Immutable
sealed class TranscriptItem { sealed class TranscriptItem {
@@ -19,10 +23,8 @@ sealed class TranscriptItem {
* list is addressed by position: whatever somebody had scrolled to keeps its index while the * list is addressed by position: whatever somebody had scrolled to keeps its index while the
* content underneath it slides, which reads as the view scrolling on its own. * content underneath it slides, which reads as the view scrolling on its own.
* *
* A seq is the right identity because it is what the transcript itself is ordered by, it never * A row built from several events keeps the seq of the first, so it holds still while the rest
* changes, and it is already carried by every event. A row built from several events -- a * of it arrives.
* streaming message, a tool call and its result -- keeps the seq of the first, so it holds
* still while the rest of it arrives.
*/ */
abstract val seq: Long abstract val seq: Long
@@ -30,10 +32,8 @@ sealed class TranscriptItem {
* This item's identity on screen, which is its [seq] for everything that has one of its own. * This item's identity on screen, which is its [seq] for everything that has one of its own.
* *
* Here rather than in [TranscriptRow.Single] because the two items that need something else are * Here rather than in [TranscriptRow.Single] because the two items that need something else are
* the two that know why: a tool call is named after its run, and a peer note is *sorted* by the * the two that know why. Asking each item what it is called is also what stops the next such
* turn it started rather than by where it arrived. Asking each item what it is called is also * item being missed -- a `when` over concrete types would have to gain a case, silently.
* what stops the next such item being missed -- a `when` over concrete types in the row would
* have to gain a case, silently, and nothing says when it did not.
*/ */
open val key: Any open val key: Any
get() = seq get() = seq
@@ -54,15 +54,13 @@ sealed class TranscriptItem {
* What it buys is the split. [transcriptUnits] keeps the newest reply whole because a * What it buys is the split. [transcriptUnits] keeps the newest reply whole because a
* streaming reply's text changes per delta and splitting a changing text is a parse per * streaming reply's text changes per delta and splitting a changing text is a parse per
* delta -- but "newest" outlives the turn, so a session that ends on a long reply was * delta -- but "newest" outlives the turn, so a session that ends on a long reply was
* drawing it as one item indefinitely, with every node of it alive. Measured on a Pixel 9 * drawing it as one item indefinitely. Measured on a Pixel 9 Pro XL: one 34,996px reply on
* Pro XL: one 34,996px reply on screen put the frame's draw phase at 13.8ms, 79% of it the * screen put the frame's draw phase at 13.8ms, 79% of it framework bookkeeping.
* framework's own bookkeeping, which grows with alive nodes.
* *
* Folded from the status event that ended the turn, rather than read off the screen's * Folded from the status event that ended the turn rather than read off the screen's
* status, because rows only change through the held-events gate: the split changes the * status, because rows only change through the held-events gate: the split changes the
* newest row's list identity, and doing that from a status flip while somebody is reading * newest row's list identity, and doing that from a status flip while somebody is reading
* inside that reply would step the list under them. An event has to wait for the reader to * inside that reply would step the list under them.
* be at the newest end; a screen state does not.
*/ */
val settled: Boolean = false, val settled: Boolean = false,
) : TranscriptItem() ) : TranscriptItem()
@@ -74,11 +72,10 @@ sealed class TranscriptItem {
* The run of adjacent calls this one belongs to, named once when the call is folded in and * The run of adjacent calls this one belongs to, named once when the call is folded in and
* never recomputed. * never recomputed.
* *
* Carried rather than derived because a run can gain members at *either* end -- a new call * Carried rather than derived because a run can gain members at *either* end, so no
* arriving beside it, or a page of history arriving in front of it -- so no function of its * function of its current members is stable. It is the first call's id at the moment the
* current members is stable. It is the first call's id at the moment the run started, which * run started, which is a name rather than a description: [joinPages] hands it to older
* is a name rather than a description: [joinPages] hands it to older calls that turn out to * calls that turn out to belong to the same run.
* belong to the same run, instead of renaming the run they joined.
*/ */
val runId: String, val runId: String,
val tool: String, val tool: String,
@@ -89,19 +86,16 @@ sealed class TranscriptItem {
* The questions this call is waiting on, in the order they were asked. * The questions this call is waiting on, in the order they were asked.
* *
* On the call's own row rather than beside it: an ask used to arrive as a second card * On the call's own row rather than beside it: an ask used to arrive as a second card
* repeating the input verbatim, so the reader saw the same command twice and had to work * repeating the input verbatim, so the reader saw the same command twice. The backend says
* out that it was one event. The backend says which call a question is about, so this is a * which call a question is about, so this is a fact rather than a match on the input.
* fact rather than a match on the input.
* *
* A list because AskUserQuestion asks up to four at once, and they are one decision to make * A list because AskUserQuestion asks up to four at once, and a permission is the case of
* -- a permission is the case of exactly one, not a different shape. * exactly one rather than a different shape.
*/ */
val asks: List<QuestionCard> = emptyList(), val asks: List<QuestionCard> = emptyList(),
/** /**
* Images this call's result carried, drawn under it. * Images this call's result carried, drawn under it. Beside it they had to be paired by
* * position, and position is what a page boundary breaks.
* Beside it they had to be paired by position, and position is the thing a page boundary
* breaks -- a screenshot loaded on one page and its call on the next read as unrelated.
*/ */
val images: List<String> = emptyList(), val images: List<String> = emptyList(),
) : TranscriptItem() { ) : TranscriptItem() {
@@ -129,9 +123,8 @@ sealed class TranscriptItem {
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem() data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
/** /**
* A message another agent sent this session. * A message another agent sent this session. Its own row rather than a [UserMsg]: see
* * [PeerMessageRow] for why the voice matters.
* Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters.
*/ */
data class PeerNote( data class PeerNote(
override val seq: Long, override val seq: Long,
@@ -140,11 +133,9 @@ sealed class TranscriptItem {
/** /**
* The seq of the event this note came in on, which is what makes it itself. * The seq of the event this note came in on, which is what makes it itself.
* *
* [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began at * [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began
* so the note is drawn above the reply it caused. Two messages that arrive during one turn * at. Two messages that arrive during one turn therefore share a seq -- and sharing an
* therefore share a seq -- and sharing an identity as well killed the app, because the * identity as well killed the app, because the list refuses two items with one key.
* transcript list refuses two items with one key. Two agents writing to a session mid-turn
* is an ordinary afternoon, not a corner.
*/ */
val arrived: Long = seq, val arrived: Long = seq,
) : TranscriptItem() { ) : TranscriptItem() {
@@ -153,10 +144,9 @@ sealed class TranscriptItem {
} }
/** /**
* A command the session ran on itself -- `/compact`, `/rename`. * A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather
* * than only shown while it waits, because it explains what follows: a conversation that
* Kept in the transcript rather than only shown while it waits, because it explains what * suddenly has half the context, or a session with a new name.
* follows: a conversation that suddenly has half the context, or a session with a new name.
*/ */
data class CommandRow(override val seq: Long, val text: String) : TranscriptItem() data class CommandRow(override val seq: Long, val text: String) : TranscriptItem()
@@ -165,7 +155,6 @@ sealed class TranscriptItem {
/** /**
* A clear that happened: everything above it left the session's context and stayed on screen. * A clear that happened: everything above it left the session's context and stayed on screen.
*
* Carries only its position, because that is all it means. * Carries only its position, because that is all it means.
*/ */
data class ClearedNote(override val seq: Long) : TranscriptItem() data class ClearedNote(override val seq: Long) : TranscriptItem()
@@ -174,12 +163,12 @@ sealed class TranscriptItem {
* A compaction that happened, and what it recovered. * A compaction that happened, and what it recovered.
* *
* In the transcript rather than only in the status line, because the status is gone the moment * In the transcript rather than only in the status line, because the status is gone the moment
* it finishes and this is the part worth keeping: it is the explanation for a gap in the * it finishes and this is the part worth keeping: the explanation for a gap in the
* conversation, and for a minute or two in which the session was busy with nothing to show. * conversation.
* *
* The wire also says what triggered it, and this deliberately does not carry that: the row says * The wire also says what triggered it, and this deliberately does not carry that -- the row
* the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would * says the two sizes and nothing else, so keeping the trigger would be a field nothing can
* be a field nothing can read. * read.
*/ */
data class CompactedNote( data class CompactedNote(
override val seq: Long, override val seq: Long,
@@ -192,16 +181,13 @@ sealed class TranscriptItem {
* The run a call joins: the one it lands next to, or a new one named after itself. * The run a call joins: the one it lands next to, or a new one named after itself.
* *
* Only ever consulted when the call is first folded in. That is what makes the name stable -- a run * Only ever consulted when the call is first folded in. That is what makes the name stable -- a run
* keeps whatever it was called when it started, however many calls arrive at either end of it * keeps whatever it was called when it started, however many calls arrive at either end afterwards.
* afterwards.
* *
* A question to the reader is in a run of its own, which is what puts it on the transcript as a row * A question to the reader is in a run of its own, which is what puts it on the transcript as a row
* rather than inside a collapsed "Called 6 tools" card. Two things follow from being alone: it is * rather than inside a collapsed "Called 6 tools" card. Two things follow: it is always visible,
* always visible, since a run of one is drawn as itself rather than as a group; and the calls * since a run of one is drawn as itself; and the calls around it fall into a group before it and a
* around it fall into a group before it and a group after it, so where the reader was asked * group after it, so where the reader was asked something is legible in the shape of the transcript
* something is legible in the shape of the transcript without opening anything. It ends the run * without opening anything.
* before it as well as starting a fresh one after -- the moment somebody was asked is a boundary in
* the work, not a gap in the middle of one run.
*/ */
private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String { private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String {
val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id
@@ -214,31 +200,23 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
* boundary cut in two. * boundary cut in two.
* *
* Two things straddle a boundary: a tool call separated from its result, and a message separated * Two things straddle a boundary: a tool call separated from its result, and a message separated
* from the rest of itself. Both were one thing before the transcript was cut into pages, and both * from the rest of itself. Both were one thing before the transcript was cut into pages.
* have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn
* twice, arriving from the same cause.
* *
* A boundary lands wherever it lands, and roughly half the time that is between a call and its * A boundary lands wherever it lands, and roughly half the time that is between a call and its
* result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws * result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws
* as a row of its own -- correctly, because a call that renders as nothing is indistinguishable * as a row of its own -- correctly, because a call that renders as nothing is indistinguishable
* from one that never happened. When the older page arrives it brings the real `ToolStart`, and * from one that never happened. When the older page arrives it brings the real `ToolStart`, and
* concatenating the two lists left *both*: the same call twice, once as a proper card and once as a * concatenating the two lists left *both*: the same call twice.
* nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than
* the miscount -- the extra row is at the join, so it also moves everything the reader was looking
* at.
* *
* Merged by the call's own id rather than by position, because position is exactly what a page * Merged by the call's own id rather than by position, because position is exactly what a page
* boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the * boundary destroys. The older row wins on what a start knows and the newer on what an end knows,
* newer on what an end knows (the output, and whether it finished), which is the only way round * which is the only way round that loses nothing.
* that loses nothing.
* *
* The third thing is the *run*, and it is the one that used to be missed. Every page ends up here, * The third thing is the *run*, and it is the one that used to be missed. Every page ends up here,
* but [adoptRun] only ran on the path where a split call had been found -- so the boundary that * but [adoptRun] only ran on the path where a split call had been found -- so the boundary that
* falls cleanly between two finished calls, which is most of them, went straight to concatenation * falls cleanly between two finished calls, which is most of them, left the older page's calls
* and left the older page's calls under the run name they were folded with. On screen: one run of * under the run name they were folded with. On screen: one run of tool calls drawn as two groups,
* tool calls drawn as two groups, with the seam wherever the reader happened to have paged. The two * with the seam wherever the reader happened to have paged.
* early returns were an optimisation on a list the size of one page, and they were skipping work
* rather than saving it.
*/ */
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> { fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val (older, newer) = healSplitMessage(earlier, later) val (older, newer) = healSplitMessage(earlier, later)
@@ -271,15 +249,13 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
/** /**
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate. * Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
* *
* [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas * [foldEvent] never leaves two assistant messages next to each other inside one page, so two
* accumulate into the message before them -- so two meeting at a join are always the two halves of * meeting at a join are always the two halves of one reply, and leaving them apart drew a single
* one reply, and leaving them apart drew a single answer as two, with a paragraph break through the * answer as two with a paragraph break through the middle of a sentence.
* middle of a sentence.
* *
* The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on * The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older
* screen, and renaming that is how the list loses its anchor. It grows by what the older half * half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
* brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded, * loaded, so the growth extends off the top of the screen.
* so the growth extends off the top of the screen, away from the row the list anchors to.
*/ */
private fun healSplitMessage( private fun healSplitMessage(
earlier: List<TranscriptItem>, earlier: List<TranscriptItem>,
@@ -297,19 +273,18 @@ private fun healSplitMessage(
* Hands the older calls at the join the name of the run they are joining. * Hands the older calls at the join the name of the run they are joining.
* *
* The two pages were folded separately, so a run split by the boundary came back as two runs with * The two pages were folded separately, so a run split by the boundary came back as two runs with
* two names. Naming the joined run after the *older* half would be the obvious way round and is the * two names. Naming the joined run after the *older* half would be the obvious way round and is
* wrong one: the newer half is the part already on screen, and renaming it is renaming the row the * wrong: the newer half is the part already on screen, and renaming it is renaming the row the
* reader is looking at, which is how a list loses its anchor and steps under them. So the arriving * reader is looking at, which is how a list loses its anchor.
* calls take the name of the ones already there, and nothing visible changes identity.
*/ */
private fun adoptRun( private fun adoptRun(
earlier: List<TranscriptItem>, earlier: List<TranscriptItem>,
later: List<TranscriptItem>, later: List<TranscriptItem>,
): List<TranscriptItem> { ): List<TranscriptItem> {
val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier
// A question is in a run of its own on both sides of the join, the same as it would be had // A question is in a run of its own on both sides of the join, the same as it would be had the
// the two pages been folded as one -- see `runIdFor`. Without this the heal would merge a // two pages been folded as one. Without this the heal would merge a group straight through the
// group straight through the row the reader was asked something on. // row the reader was asked something on.
if (first.tool == ASK_USER_QUESTION) return earlier if (first.tool == ASK_USER_QUESTION) return earlier
val joining = first.runId val joining = first.runId
val tail = earlier.takeLastWhile { val tail = earlier.takeLastWhile {
@@ -324,10 +299,8 @@ private fun adoptRun(
* A peer message goes above the turn it started, not where it happened to arrive. * A peer message goes above the turn it started, not where it happened to arrive.
* *
* The live Claude Code path cannot record it in place: the CLI says nothing about a peer message * The live Claude Code path cannot record it in place: the CLI says nothing about a peer message
* until the turn's `result`, so the event lands below the whole reply it caused -- the answer * until the turn's `result`, so the event lands below the whole reply it caused. The server stamps
* printed above the question. The server stamps it with where that turn began * it with where that turn began and the note takes that seq.
* ([SessionEvent.PeerMessage.turnStart]) and the note takes that seq, so it sorts into the list
* where it belongs rather than being drawn out of order at the end.
* *
* Taking the turn's opening seq as its own is also what keeps the list sorted, which anchors and * Taking the turn's opening seq as its own is also what keeps the list sorted, which anchors and
* paging both depend on. It is only a *position*, though, and the note keeps its own arrival seq as * paging both depend on. It is only a *position*, though, and the note keeps its own arrival seq as
@@ -335,8 +308,7 @@ private fun adoptRun(
* seq belongs to a status change and a status draws no row -- true, and it answered the wrong * seq belongs to a status change and a status draws no row -- true, and it answered the wrong
* question: what two notes stamped with the same turn collide with is each other. * question: what two notes stamped with the same turn collide with is each other.
* *
* Without a stamp -- a message replayed out of a session file, which is already in the right place * Without a stamp -- a message replayed out of a session file -- it stays where it arrived.
* -- it stays where it arrived.
*/ */
private fun placePeerNote( private fun placePeerNote(
items: List<TranscriptItem>, items: List<TranscriptItem>,
@@ -355,15 +327,14 @@ private fun placePeerNote(
* The calls the note now sits in front of, renamed if they were sharing a run with the calls behind * The calls the note now sits in front of, renamed if they were sharing a run with the calls behind
* it. * it.
* *
* A run is named from what a call landed next to (see [runIdFor]), and nothing there knows about * A run is named from what a call landed next to, and nothing there knows about turns -- so a turn
* turns -- so a turn opening with a tool call, straight after one that ended with one, folds them * opening with a tool call, straight after one that ended with one, folds them into a single run.
* into a single run. Left alone, [groupToolRuns] would flush at the note and hand both halves the * Left alone, [groupToolRuns] would flush at the note and hand both halves the same name: two rows
* same name: two rows with one key, which a keyed list cannot draw at all. * with one key, which a keyed list cannot draw at all.
* *
* The later half is the one renamed, which is the opposite of a page join ([adoptRun]) and right * The later half is the one renamed, which is the opposite of a page join ([adoptRun]) and right
* for the opposite reason. There the two halves were always one run and the newer was already on * for the opposite reason: there the two halves were always one run, here they were never one
* screen; here they were never one turn's work, and both halves change appearance at the same * turn's work.
* moment the note appears between them.
*/ */
private fun splitRun(tail: List<TranscriptItem>, behind: String?): List<TranscriptItem> { private fun splitRun(tail: List<TranscriptItem>, behind: String?): List<TranscriptItem> {
val first = tail.firstOrNull() as? TranscriptItem.ToolRun ?: return tail val first = tail.firstOrNull() as? TranscriptItem.ToolRun ?: return tail
@@ -401,12 +372,10 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
) )
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) } is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
is SessionEvent.ToolEnd -> is SessionEvent.ToolEnd ->
// Created when its start is not here, rather than dropped. A // Created when its start is not here, rather than dropped. A fold that only ever
// fold that only ever *updates* loses the whole call when the // *updates* loses the whole call when the start fell outside the loaded window, and a
// start fell outside the loaded window, and a tool call that // tool call that renders as nothing is indistinguishable from one that never happened.
// renders as nothing is indistinguishable from one that never // Loading the page before this one replaces the row with the real thing.
// happened. The name is unknown from an end alone; loading the
// page before this one replaces the row with the real thing.
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) { if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
updateTool(items, event.id) { it.copy(output = event.output, done = true) } updateTool(items, event.id) { it.copy(output = event.output, done = true) }
} else { } else {
@@ -414,9 +383,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
TranscriptItem.ToolRun( TranscriptItem.ToolRun(
entry.seq, entry.seq,
event.id, event.id,
// The name is not known from an end alone, so a call that was an ask // The name is not known from an end alone, so a call that was an ask cannot
// cannot be recognised as one here; loading the page before this // be recognised as one here; the page before this replaces the row.
// replaces the row with the real thing, which is when it splits out.
runIdFor(items, event.id, "tool"), runIdFor(items, event.id, "tool"),
"tool", "tool",
"", "",
@@ -435,9 +403,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
event.multiSelect, event.multiSelect,
emptyList(), emptyList(),
) )
// A question with no tool behind it -- AskUserQuestion, or an ask // A question with no tool behind it -- AskUserQuestion, or an ask whose call fell
// whose call fell outside the loaded window -- is a card of its // outside the loaded window -- is a card of its own.
// own, which is what every question was before this.
if ( if (
event.about != null && event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about } items.any { it is TranscriptItem.ToolRun && it.id == event.about }
@@ -448,9 +415,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
} }
} }
is SessionEvent.Answered -> is SessionEvent.Answered ->
// Resolved wherever it is drawn: a card of its own, or a tool // Resolved wherever it is drawn: a card of its own, or a tool row's ask. Missing the
// row's ask. Missing the second left an Allow/Deny pair live on // second left an Allow/Deny pair live on a question already answered from another
// a question already answered from another device. // device.
items.map { items.map {
when { when {
it is TranscriptItem.QuestionCard && it.id == event.id -> it is TranscriptItem.QuestionCard && it.id == event.id ->
@@ -470,20 +437,19 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text) is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
// Screen-level state, not transcript rows -- see SessionScreen. // Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.CommandQueued -> items is SessionEvent.CommandQueued -> items
// No row of its own: a message that is still waiting is drawn as a pending bubble below // No row of its own: a message that is still waiting is drawn as a pending bubble below the
// the transcript, and becomes an ordinary one where the session read it. // transcript, and becomes an ordinary one where the session read it.
is SessionEvent.MessageQueued -> items is SessionEvent.MessageQueued -> items
// The bubble goes away and nothing takes its place: the message was never read, so there // The bubble goes away and nothing takes its place: the message was never read, so there is
// is nothing it belongs above. // nothing it belongs above.
is SessionEvent.MessageDropped -> items is SessionEvent.MessageDropped -> items
is SessionEvent.Settings -> items is SessionEvent.Settings -> items
is SessionEvent.Status -> settleReply(items, event.state) is SessionEvent.Status -> settleReply(items, event.state)
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message) is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image -> is SessionEvent.Image ->
// Under the call that produced it when there is one, and a row of // Under the call that produced it when there is one, and a row of its own when there is
// its own when there is not -- a person's own attachment belongs // not -- a person's own attachment belongs to no call, and neither does one whose call
// to no call, and neither does one whose call fell outside the // fell outside the loaded window.
// loaded window.
if ( if (
event.about != null && event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about } items.any { it is TranscriptItem.ToolRun && it.id == event.about }
@@ -503,9 +469,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
/** /**
* A status saying the session stopped working is the moment its newest reply is finished. * A status saying the session stopped working is the moment its newest reply is finished.
* *
* See [TranscriptItem.AssistantMsg.settled] for what the mark buys and why it is made here in the * See [TranscriptItem.AssistantMsg.settled]. Status changes are transcript events with seqs of
* fold. Status changes are transcript events with seqs of their own, so a replayed session settles * their own, so a replayed session settles its replies the same way a live one does.
* its replies the same way a live one does.
*/ */
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> { private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
if (sessionWorking(state)) return items if (sessionWorking(state)) return items
@@ -528,8 +493,7 @@ private fun updateTool(
* The default dispatcher sizes itself to the machine, which is right for work somebody is waiting * The default dispatcher sizes itself to the machine, which is right for work somebody is waiting
* on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and * on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and
* taking every core for them leaves the thread that draws the frame queueing behind one -- measured * taking every core for them leaves the thread that draws the frame queueing behind one -- measured
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile, which is the frame failing to * on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile.
* *start* rather than taking too long once it had.
*/ */
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
private val parsingThreads = Dispatchers.Default.limitedParallelism(2) private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
@@ -538,37 +502,33 @@ private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
* Parses the markdown among [rows], off whatever thread is drawing. * Parses the markdown among [rows], off whatever thread is drawing.
* *
* Called where a page of transcript is folded rather than where a row is composed, which is the * Called where a page of transcript is folded rather than where a row is composed, which is the
* whole point: the work happens seconds before the reader reaches the rows it was done for. See * whole point: the work happens seconds before the reader reaches the rows it was done for.
* [ParsedReplies].
* *
* What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer * What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer
* message, every one of them whole, since every piece of a message is drawn from its one parse -- * message -- because a string warmed under a key no row ever looks up is a miss that nothing
* because a string warmed under a key no row ever looks up is a miss that nothing reports; see * reports; see [transcriptUnits], which is the flatten this has to agree with. It reads the same
* [transcriptUnits], which is the flatten this has to agree with. It reads the same
* [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages * [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages
* hand it back through here, while the whole loaded transcript crosses this on every page. * hand it back through here.
* *
* Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer * Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer
* message was missing: this used to filter for assistant replies alone, so the one row type nobody * message was missing: this used to filter for assistant replies alone, so the one row type nobody
* had thought about paid its whole parse in the frame it appeared in, with no counter saying which * had thought about paid its whole parse in the frame it appeared in.
* row it was.
*/ */
suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) { suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
withContext(parsingThreads) { withContext(parsingThreads) {
val texts = rows.flatMap { row -> val texts = rows.flatMap { row ->
when (row) { when (row) {
is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text } is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text }
// A message from another agent is markdown too, and it is the longest thing // A message from another agent is markdown too, and it is the longest thing in a
// in a transcript often enough that leaving it out was the whole of why one // transcript often enough that leaving it out was the whole of why one cost a fifth
// cost a fifth of a second to open: it was the only markdown in the app // of a second to open.
// parsed on the thread that draws.
is TranscriptItem.PeerNote -> listOf(row.text) is TranscriptItem.PeerNote -> listOf(row.text)
else -> emptyList() else -> emptyList()
} }
} }
if (texts.isNotEmpty()) replies.warm(texts) if (texts.isNotEmpty()) replies.warm(texts)
// After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's // After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's licence
// licence to draw these as blocks on the composing thread. // to draw these as blocks on the composing thread.
rows.forEach { if (it is TranscriptItem.AssistantMsg) replies.markSplitReady(it.text) } rows.forEach { if (it is TranscriptItem.AssistantMsg) replies.markSplitReady(it.text) }
} }
} }
@@ -25,31 +25,24 @@ import androidx.compose.ui.unit.dp
* zero is the newest content and sits at the bottom, so a message arriving extends the end the * zero is the newest content and sits at the bottom, so a message arriving extends the end the
* viewport is pinned to and following it is not an effect -- and a page of older history lands at * viewport is pinned to and following it is not an effect -- and a page of older history lands at
* indices past everything visible, which moves nothing on screen. The keyboard is the same case * indices past everything visible, which moves nothing on screen. The keyboard is the same case
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge. A * from the other side: the viewport shrinks and the anchored item stays against its bottom edge.
* conversation shorter than the screen stacks from the bottom, hanging from the composer.
* *
* The lazy list is also the whole of the windowing. Only what is near the viewport is composed and * The lazy list is also the whole of the windowing. Only what is near the viewport is composed and
* alive, so the per-frame cost is bounded by the screen rather than by how much is loaded -- the * alive, so the per-frame cost is bounded by the screen rather than by how much is loaded -- the
* property a plain column here had to approximate with retained ranges and stand-in spacers, each * property a plain column here had to approximate with retained ranges and stand-in spacers, each
* of which was a way to flicker. An item the framework composes is drawn the same frame it is * of which was a way to flicker.
* placed, and an item off screen is not a node at all.
* *
* What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a * What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a
* reply, and its parse is already made by [warm] before the fold that introduces it -- so entering * reply, and its parse is already made by [warm] before the fold that introduces it.
* composition costs laying out one paragraph, not parsing a message.
* *
* The whole list sits in a [SelectionContainer], which is what makes every word in the transcript * The whole list sits in a [SelectionContainer], which is what makes every word selectable by the
* selectable by the platform's own press-and-hold. Here rather than at each place text is drawn: a * platform's own press-and-hold. Here rather than at each place text is drawn: a transcript is one
* transcript is one body of text to a reader, and a container per row would mean a selection could * body of text to a reader, and a container per row would mean a selection could never cross from a
* never cross from a reply into the tool output that follows it -- and would leave whatever was * reply into the tool output that follows it -- and would leave whatever was drawn without one
* drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep * silently unselectable. Rows keep their tap handlers: selection is a long press.
* their tap handlers: selection is a long press, and the container passes an ordinary click through
* to the card under it.
* *
* [selection] is the container's own state, held by the caller rather than made here, because the * [selection] is the container's own state, held by the caller rather than made here, because the
* rows have to be able to ask whether anything is selected before they act on a tap -- a tap whose * rows have to be able to ask whether anything is selected before they act on a tap.
* job is to put a selection away is not also a tap on the card under it. See the caller's
* `expanding`.
*/ */
@Composable @Composable
fun TranscriptList( fun TranscriptList(
@@ -69,8 +62,7 @@ fun TranscriptList(
modifier = modifier =
// Timed in two halves because the frame's draw phase is where Compose's measurement // Timed in two halves because the frame's draw phase is where Compose's measurement
// lands, and "draw is high while nothing is being recorded" does not say which // lands, and "draw is high while nothing is being recorded" does not say which
// half; // half. Measure includes composing the items that scrolled in.
// see [drawAccounting]. Measure includes composing the items that scrolled in.
modifier modifier
.layout { measurable, constraints -> .layout { measurable, constraints ->
val started = System.nanoTime() val started = System.nanoTime()
@@ -104,8 +96,7 @@ fun TranscriptList(
} }
// Standing in for everything not fetched yet. Only here while there is more -- its // Standing in for everything not fetched yet. Only here while there is more -- its
// appearance at the top edge is also roughly when the next page is asked for, so what // appearance at the top edge is also roughly when the next page is asked for, so what
// it // it reports is a fetch in flight rather than an end reached.
// reports is a fetch in flight rather than an end reached.
if (moreHistory) { if (moreHistory) {
item(key = "history", contentType = "history") { item(key = "history", contentType = "history") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) { Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
@@ -0,0 +1,177 @@
package com.example.aiapp
import android.content.Context
import java.io.File
import java.util.concurrent.atomic.AtomicReference
/**
* Where the session screen gets a transcript from: this phone's copy first, the server for the
* rest.
*
* One seam rather than a cache the screen has to remember to consult. Everything it fetched before
* is asked of this, and everything the server sends is written into the cache on the way past, so
* the screen never learns which side answered. What it does learn, through [DebugStats], is how
* often each one did.
*
* See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind: the cache is never load-bearing.
* Every read has a network path beside it producing the same result.
*/
class TranscriptSource(
private val settings: ServerSettings,
private val sessionId: String,
val cache: SessionCache,
) {
private val stream = AtomicReference<EventStream?>(null)
/**
* The cached opening window, or null when there is nothing usable to draw.
*
* Drawn *before* [probe] returns, which is the whole point of the feature: the rows are on
* screen while the check that they are still the server's rows is in flight, and a failed check
* replaces them exactly as a `reset` does.
*/
fun cachedOpening(limit: Int = OPENING_WINDOW): List<SeqEvent>? {
if (cache.tail() == null) return null
val lines = cache.newest(limit)
if (lines.isEmpty()) return null
return try {
lines.map { parseSeqEvent(it) }
} catch (e: org.json.JSONException) {
// Lines this build cannot read at all, which the cache's own checks cannot see: it
// reads a seq off a line, not an event. Nothing to serve, so a cold open.
cache.purge()
null
}
}
/**
* Whether the server's event at the cached cursor is still the cached one.
*
* The screen must not resume a stream from a cached seq unless it is the same conversation. A
* transcript is append-only in ordinary use, but the file can be replaced or truncated -- a
* sandbox re-seeded with the same ids, a backup restored, a session re-imported -- and the
* server's catch-up on such a file would hand this phone a continuation of a *different*
* conversation, spliced onto the cached one with no seam. Caught with one request of a few
* hundred bytes, in the slot the opening page's request used to be in.
*
* False purges the cache and means "open cold". A throw is the server not being askable, which
* is neither: the cached rows stay on screen and the caller tries again on the reconnect
* schedule.
*
* What this cannot see is a line changed in the middle of the file with the tail intact. That
* is what the Reload button in session settings is for.
*/
suspend fun probe(): Boolean {
val tail = cache.tail() ?: return false
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
// the cursor when the server still has one there.
val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1)
val matches =
answer.size == 1 &&
try {
answer[0].second == parseSeqEvent(tail.line)
} catch (e: org.json.JSONException) {
false
}
if (!matches) cache.purge()
return matches
}
/**
* Today's opening fetch, kept as the start of the live run. Only called when the cache has
* nothing to open with, or when [probe] said what it had was not the server's.
*/
suspend fun fetchOpening(): List<SeqEvent> {
DebugStats.count("transcript page from server")
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW)
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
cache.flush()
return page.map { it.second }
}
/**
* The page before [before]: from the cache when it holds it, otherwise from the server bounded
* by what the cache already has.
*
* The bound is what keeps the cache worth having. A coalesced page reaches back as far as its
* row count takes it -- a single reply is hundreds of lines -- so a page fetched after the
* reader has been away would run straight past the cached run and overlap it, and an
* overlapping page cannot be stored. Told where this phone's copy starts, the server stops
* there instead.
*/
suspend fun page(before: Long, limit: Int, coalesce: Boolean): List<SeqEvent> {
cache.page(before, limit, rows = coalesce)?.let { lines ->
DebugStats.count("transcript page from cache")
return lines.map { parseSeqEvent(it) }
}
DebugStats.count("transcript page from server")
val page =
fetchTranscript(
settings,
sessionId,
before = before,
limit = limit,
coalesce = coalesce,
after = cache.coveredUpTo(before)?.minus(1),
)
if (page.isNotEmpty()) {
// `before` rather than the newest line's seq: a coalesced page covers everything up to
// the cursor it was asked with, and nothing in its lines says so.
cache.storePage(page.map { it.first }, page.first().second.seq, before, rows = coalesce)
}
return page.map { it.second }
}
/**
* [EventStream.run], with every frame written to the cache before [onEvent] sees it.
*
* Before, so that an event held back for a reader who is scrolled away is already on disk --
* what the cache holds is what the server sent, not what the screen has got round to drawing.
* Flushed on each status change, which is a turn's boundary and the granularity a crash may as
* well lose.
*/
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
val opened = EventStream(settings, sessionId)
stream.getAndSet(opened)?.close()
try {
opened.run(after, onOpen, onReset) { raw, entry ->
cache.append(raw, entry.seq)
if (entry.event is SessionEvent.Status) cache.flush()
onEvent(entry)
}
} finally {
cache.flush()
}
}
/** Ends the stream, from any thread, and leaves the cache with everything it was given. */
fun close() {
stream.getAndSet(null)?.close()
cache.flush()
}
}
/**
* How many events the screen opens with, cached or fetched.
*
* The server's own default for a page, named here because the cached opening has to be the same
* size as the fetched one -- a reader must not get a shorter first screen for having been here
* before.
*/
private const val OPENING_WINDOW = 80
/**
* Where this server's cached transcripts live.
*
* Under `cacheDir` because that is exactly what it is for: bytes the phone can regenerate from the
* server, which Android may delete under storage pressure without asking. Keyed by host and port
* because two servers can hold a session with the same id, and a line from one shown against the
* other is the whole invariant broken. `v1` is the layout's version.
*/
fun cacheRoot(context: Context, settings: ServerSettings): File {
val transcripts = File(context.cacheDir, "transcripts")
transcripts.listFiles()?.forEach { if (it.name != CACHE_VERSION) it.deleteRecursively() }
return File(transcripts, "$CACHE_VERSION/${settings.host}_${settings.port}")
}
private const val CACHE_VERSION = "v1"
@@ -12,8 +12,7 @@ import androidx.compose.ui.unit.dp
* at the moment it scrolls into view, and that cost is proportional to the item -- a reply can be * at the moment it scrolls into view, and that cost is proportional to the item -- a reply can be
* twenty-five screens of markdown, which as one item is a hundred-millisecond frame exactly when * twenty-five screens of markdown, which as one item is a hundred-millisecond frame exactly when
* the list is moving fastest. A *block* is a paragraph, a fence, a table: bounded, so the worst * the list is moving fastest. A *block* is a paragraph, a fence, a table: bounded, so the worst
* frame is bounded. This is the piece that was missing when a lazy list was last tried here; the * frame is bounded. This is the piece that was missing when a lazy list was last tried here.
* block splitting existed only inside the row, where the list could not see it.
* *
* Everything else about the row model is unchanged: rows come from [groupToolRuns], and a unit * Everything else about the row model is unchanged: rows come from [groupToolRuns], and a unit
* points back at its row. The list draws units; anchors and paging still speak seq. * points back at its row. The list draws units; anchors and paging still speak seq.
@@ -27,10 +26,9 @@ sealed class TranscriptUnit {
abstract val seq: Long abstract val seq: Long
/** /**
* This unit's position within its row, counted from the row's oldest end. * This unit's position within its row, counted from the row's oldest end. What a saved scroll
* * position carries besides the seq: a reply split into forty blocks needs more than "somewhere
* What a saved scroll position carries besides the seq: a reply split into forty blocks needs * in this row" to put a reader back where they stopped.
* more than "somewhere in this row" to put a reader back where they stopped.
*/ */
abstract val ordinal: Int abstract val ordinal: Int
@@ -66,15 +64,13 @@ sealed class TranscriptUnit {
* *
* A peer message is the one row whose *opened* size is unbounded -- these are the longest * A peer message is the one row whose *opened* size is unbounded -- these are the longest
* things a transcript holds -- so it is flattened the same way a settled reply is, and for the * things a transcript holds -- so it is flattened the same way a settled reply is, and for the
* same reason: as one item, every block of it is composed, measured, placed and kept alive * same reason. Measured on the emulator, opening a 43KB one took the transcript's share of the
* while any part of it is on screen. Measured on the emulator, opening a 43KB one took the * draw phase from 0.81ms a frame to 3.85ms, and the framework's own per-frame bookkeeping from
* transcript's share of the draw phase from 0.81ms a frame to 3.85ms, and the framework's own * 0.39ms to 3.15ms.
* per-frame bookkeeping -- which grows with how many nodes are *alive* -- from 0.39ms to
* 3.15ms.
* *
* The card is drawn in pieces rather than given up: a filled Material card is elevation zero, * The card is drawn in pieces rather than given up: a filled Material card is elevation zero,
* so it has no shadow to break, and each piece paints the same fill with only the corners it * so it has no shadow to break, and each piece paints the same fill with only the corners it
* owns. See [PeerHeadRow] and [PeerBlockRow]. * owns.
*/ */
data class PeerHead( data class PeerHead(
override val seq: Long, override val seq: Long,
@@ -84,8 +80,7 @@ sealed class TranscriptUnit {
) : TranscriptUnit() { ) : TranscriptUnit() {
/** /**
* The note's own key, so opening and shutting does not change what the list is anchored on * The note's own key, so opening and shutting does not change what the list is anchored on
* -- and so two notes stamped with one turn's seq are still two items. See * -- and so two notes stamped with one turn's seq are still two items.
* [TranscriptItem.PeerNote].
*/ */
override val key: Any override val key: Any
get() = item.key get() = item.key
@@ -119,8 +114,7 @@ sealed class TranscriptUnit {
* *
* A user message is plain text, so cutting it costs a scan rather than a parse -- but the * A user message is plain text, so cutting it costs a scan rather than a parse -- but the
* reason is the same as for a settled reply: as one item, a pasted log is a hundred thousand * reason is the same as for a settled reply: as one item, a pasted log is a hundred thousand
* pixels of `Text` whose layout lands in the frame the row scrolls into. Measured as the * pixels of `Text` whose layout lands in the frame the row scrolls into.
* `measure: the whole transcript ... 112.1ms worst` in an otherwise smooth report.
*/ */
data class UserChunk( data class UserChunk(
override val seq: Long, override val seq: Long,
@@ -152,14 +146,11 @@ sealed class TranscriptUnit {
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the * The rows flattened into list units, newest first -- index zero is the item at the bottom of the
* screen, which is what a reversed lazy list calls the start. * screen, which is what a reversed lazy list calls the start.
* *
* Every settled reply is cut into its pieces ([pieces], via the caches on [replies] so a message is * Every settled reply is cut into its pieces (via the caches on [replies] so a message is only ever
* only ever cut once), and so is an *opened* peer message -- [openNotes] is which ones those are, * cut once), and so is an *opened* peer message -- [openNotes] is which ones those are. A shut one
* which is why the flatten needs it. A shut one is a single heading and cannot be worth splitting. * is a single heading and cannot be worth splitting. The reply still arriving stays whole: its text
* The reply still arriving -- the newest row, until the status event that ends its turn marks it * changes with every delta, and splitting it here would parse the whole message per delta on
* [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, and * whichever thread is composing. Once settled it splits like every other reply, which is what
* splitting it here would parse the whole message per delta on whichever thread is composing.
* [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the
* live message a layer per piece. Once settled it splits like every other reply, which is what
* bounds the newest row's cost after a session ends on a long one. * bounds the newest row's cost after a session ends on a long one.
* *
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm * Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
@@ -200,8 +191,8 @@ fun transcriptUnits(
} }
} }
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) { } else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like // A scan, not a parse, so it is cheap enough for the fold path -- and cached like the
// the markdown splits so the scan too happens once per message, not once per fold. // markdown splits so the scan happens once per message rather than once per fold.
val chunks = replies.chunksOf(item.text) val chunks = replies.chunksOf(item.text)
chunks.forEachIndexed { at, chunk -> chunks.forEachIndexed { at, chunk ->
units += units +=
@@ -252,8 +243,8 @@ fun transcriptUnits(
} }
units.reverse() units.reverse()
reportDuplicateKeys(units) reportDuplicateKeys(units)
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy" // Timed because this runs per fold on the composing thread: "loading messages feels bumpy" is
// is this number growing, and it was invisible until it was written down. // this number growing, and it was invisible until it was written down.
DebugStats.record("units flattened", System.nanoTime() - started) DebugStats.record("units flattened", System.nanoTime() - started)
return units return units
} }
@@ -261,9 +252,8 @@ fun transcriptUnits(
/** /**
* Whether this reply should be drawn as blocks: settled, or anywhere but the newest row. * Whether this reply should be drawn as blocks: settled, or anywhere but the newest row.
* *
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two * Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two are
* questions are separate because they are answered by different things: this one by the fold, the * answered by different things: this one by the fold, the other by whether [warm] has run.
* other by whether [warm] has run for the text. [unwarmedReplies] is the gap between them.
*/ */
private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) = private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) =
item.settled || index != lastIndex item.settled || index != lastIndex
@@ -272,8 +262,7 @@ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex
* The replies among [rows] that should draw as blocks but whose parses are not made yet. * The replies among [rows] that should draw as blocks but whose parses are not made yet.
* *
* Normally empty: every page's rows are warmed before the fold lands. The one row that can be cold * Normally empty: every page's rows are warmed before the fold lands. The one row that can be cold
* is the reply that just finished streaming -- nothing warms live deltas, so at the moment its turn * is the reply that just finished streaming -- nothing warms live deltas. The session screen warms
* ends its split would cost a whole-message parse on the composing thread. The session screen warms
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against * what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
* ready parses. * ready parses.
*/ */
@@ -12,19 +12,15 @@ import androidx.compose.runtime.Composable
* on the main thread -- so it is not an error the screen can show, it closes the app. That is a * on the main thread -- so it is not an error the screen can show, it closes the app. That is a
* disproportionate answer to a list with a repeat in it, and it lands on the reader rather than on * disproportionate answer to a list with a repeat in it, and it lands on the reader rather than on
* whoever produced the repeat: on 2026-08-31 the import list crashed on a Claude Code session id * whoever produced the repeat: on 2026-08-31 the import list crashed on a Claude Code session id
* recorded under two project directories, which is an ordinary state of a machine and not something * recorded under two project directories, which is an ordinary state of a machine.
* the phone did.
* *
* Every list in this app keyed on an id keyed it on an id *the server chose*, so all of them shared * Every list in this app keyed on an id keyed it on an id *the server chose*, so all of them shared
* the hazard and none of them could rule it out locally. Hence one function they all go through * the hazard and none could rule it out locally. Hence one function they all go through.
* rather than a `distinctBy` remembered at each call site.
* *
* Dropping the repeat is the right answer here because the key is the whole identity: two rows with * Dropping the repeat is right here because the key is the whole identity: two rows with one id are
* one id are two rows every action would treat as the same thing, so there is nothing to show about * two rows every action would treat as the same thing. Where the duplicate means something, the fix
* the second that the first is not already showing. Where the duplicate means something -- the * belongs at the source, and this is only what stops a data problem from being a crash. It is
* import list's did -- the fix belongs at the source, and this is only what stops a data problem * counted so the render report says it happened rather than leaving a silently shorter list.
* from being a crash. It is counted so the render report says it happened rather than leaving a
* silently shorter list.
* *
* The transcript's own list is deliberately not on this: its keys are made here rather than * The transcript's own list is deliberately not on this: its keys are made here rather than
* received, and it is the one list where an extra pass over the items is measurable. * received, and it is the one list where an extra pass over the items is measurable.
@@ -27,17 +27,14 @@ import java.time.OffsetDateTime
* A dialog rather than a screen. Usage is something you check *against* what you were reading -- * A dialog rather than a screen. Usage is something you check *against* what you were reading --
* "can I start this" is asked with the transcript still on screen -- and pushing a whole screen for * "can I start this" is asked with the transcript still on screen -- and pushing a whole screen for
* it took the session away to answer a question about the session. It also has no navigation of its * it took the session away to answer a question about the session. It also has no navigation of its
* own: there is nothing here to open, so the only thing its Back could ever have meant was "put * own, so the only thing its Back could ever have meant was "put this away".
* this away", which is what dismissing does. The system back gesture dismisses it, since a `Dialog`
* handles that itself.
*/ */
@Composable @Composable
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) { fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the // A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
// gaps between its title, its content and its buttons at sizes meant for a sentence of prose // between its title, content and buttons at sizes meant for a sentence of prose and a decision;
// and a decision; this is a dense read-out, and those gaps left a band of empty dialog above // this is a dense read-out, and those gaps left a band of empty dialog above Close that was
// Close that was taller than a bar. Everything else here is what AlertDialog would have // taller than a bar.
// drawn -- the same container colour, the same corner -- so nothing about it looks foreign.
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
Surface( Surface(
shape = MaterialTheme.shapes.extraLarge, shape = MaterialTheme.shapes.extraLarge,
@@ -49,11 +46,9 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
// Deliberately not subtitled with the provider this was opened from. These // Deliberately not subtitled with the provider this was opened from. These
// numbers belong to an account on a particular machine, reported by whichever // numbers belong to an account on a particular machine -- naming the session's
// paid service answered there -- naming the session's provider here made an // provider here made an echo session's screen read "echo" above a line reading
// echo session's screen read "echo" above a line reading "claude", which is a // "claude". Each machine names itself and the service it came from.
// claim about echo that nothing measured. Each machine names itself and the
// service it came from, which is the true scope.
Text( Text(
"Usage", "Usage",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
@@ -69,10 +64,9 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
} }
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Scrolls rather than being trimmed: a machine can report any number of windows // Scrolls rather than being trimmed: a machine can report any number of windows and
// and there can be any number of machines, and a dialog is the one place where // there can be any number of machines, and a dialog is the one place where running
// running out of room is silent. `fill = false` so a short read-out keeps a short // out of room is silent. `fill = false` so a short read-out keeps a short dialog.
// dialog instead of stretching to the window.
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
UsageBody(feed.snapshots) UsageBody(feed.snapshots)
} }
@@ -93,8 +87,8 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> is LoadState.Loaded ->
if (current.value.isEmpty()) { if (current.value.isEmpty()) {
// Not an error and not a blank screen: no machine offers a paid service, // Not an error and not a blank screen: no machine offers a paid service, so
// so there is genuinely nothing to report and saying so is the answer. // there is genuinely nothing to report and saying so is the answer.
Text( Text(
"No machine here runs anything with usage limits.", "No machine here runs anything with usage limits.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -103,17 +97,16 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
} else { } else {
// No card around each machine. A card is a step up the surface ladder, and // No card around each machine. A card is a step up the surface ladder, and
// inside a dialog -- itself a raised surface -- the step barely renders while // inside a dialog -- itself a raised surface -- the step barely renders while
// costing 16dp of padding on every side. What separates one machine from the // costing 16dp on every side. What separates one machine from the next is the
// next is the line naming it, which is enough for a list this short. // line naming it.
current.value.forEachIndexed { index, snapshot -> current.value.forEachIndexed { index, snapshot ->
if (index > 0) { if (index > 0) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
} }
// Machine and service on one line: which account these numbers belong to // Machine and service on one line: which account these numbers belong to is
// is decided by both together, and stacked as a heading over a subtitle // decided by both together, and stacked as a heading over a subtitle they
// they read as a section of their own rather than as the label they are. // read as a section of their own. Small and quiet, because the numbers
// Small and quiet, because the numbers below are what somebody opened // below are what somebody opened this to see.
// this to see.
Text( Text(
"${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}", "${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -121,8 +114,8 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
) )
SnapshotState(snapshot) SnapshotState(snapshot)
snapshot.windows.forEachIndexed { windowIndex, window -> snapshot.windows.forEachIndexed { windowIndex, window ->
// Between the bars, not after the last one: a trailing gap here is // Between the bars, not after the last one: a trailing gap here is what
// what put a band of empty dialog above the Close button. // put a band of empty dialog above the Close button.
if (windowIndex > 0) { if (windowIndex > 0) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
@@ -139,8 +132,7 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
* *
* The distinction the old single message could not draw. A machine nobody has logged in on is * The distinction the old single message could not draw. A machine nobody has logged in on is
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be * working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
* the interface nagging about a decision already made, and would dilute the marks that do mean * the interface nagging about a decision already made. Only the two faults are coloured as faults.
* something. Only the two faults are coloured as faults.
*/ */
@Composable @Composable
private fun SnapshotState(snapshot: UsageSnapshot) { private fun SnapshotState(snapshot: UsageSnapshot) {
@@ -152,8 +144,8 @@ private fun SnapshotState(snapshot: UsageSnapshot) {
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
// Reached but refused, versus never reached at all: different things to go and do, // Reached but refused, versus never reached at all: different things to go and do, so they
// so they say different things rather than sharing one "unavailable". // say different things rather than sharing one "unavailable".
"failed" -> "failed" ->
Text( Text(
snapshot.detail ?: "Couldn't read the limits from this machine.", snapshot.detail ?: "Couldn't read the limits from this machine.",
@@ -200,10 +192,9 @@ private fun WindowBar(window: UsageWindow) {
/** /**
* "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing. * "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing.
* *
* Null for a window that is not running, which is the case this row has always drawn as nothing and * Null for a window that is not running: there is no end to report. What this used to get wrong is
* is right to: there is no end to report. What it used to get wrong is the other missing case, a * the other missing case, a timestamp that arrived and could not be read -- printed raw, so a parse
* timestamp that arrived and could not be read: that was printed raw, so a parse failure appeared * failure appeared as an ISO string in a sentence written for a person. Both are named in
* as an ISO string in the middle of a sentence written for a person. Both cases are named in
* [WindowEnd], and the session bar words them the same way. * [WindowEnd], and the session bar words them the same way.
*/ */
private fun resetLine(window: UsageWindow): String? = private fun resetLine(window: UsageWindow): String? =
@@ -0,0 +1,310 @@
package com.example.aiapp
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.junit.jupiter.api.io.TempDir
/**
* The cache's file logic, which is the half of the transcript cache that can be wrong without
* anything on screen saying so: a page served short, a chunk served across a gap, or a run of lines
* whose recorded coverage does not match what is in it.
*
* Lines here are the shape the server writes -- `{"seq":N,"ts":T,"type":...}` -- because that is
* what the cache reads its two facts off. Nothing parses JSON on either side.
*/
class TranscriptCacheTest {
@field:TempDir lateinit var temp: File
private val said = mutableListOf<String>()
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
private fun session(id: String = "s") = cache().session(id)
private fun line(seq: Long, type: String = "toolStart") =
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
private fun delta(seq: Long) = line(seq, "assistantText")
private fun dirOf(id: String = "s") = File(temp, "v1/host_8443/$id")
private fun names(id: String = "s") = dirOf(id).list().orEmpty().sorted()
private fun write(name: String, lines: List<String>, id: String = "s") {
dirOf(id).mkdirs()
File(dirOf(id), name).writeText(lines.joinToString("\n", postfix = "\n"))
}
private fun seqs(lines: List<String>?) = lines?.map {
Regex("\"seq\":(\\d+)").find(it)!!.groupValues[1].toLong()
}
@Test
fun an_appended_run_is_one_open_chunk_and_its_newest_line_is_the_tail() {
val cache = session()
(1L..3L).forEach { cache.append(line(it), it) }
cache.flush()
assertEquals(listOf("1-open.raw.jsonl"), names())
assertEquals(CachedTail(3, line(3)), cache.tail())
assertEquals(listOf(line(2), line(3)), cache.newest(2))
// More than there is is what there is, which is a short opening window and not a failure.
assertEquals(3, cache.newest(80).size)
}
@Test
fun a_gap_in_the_stream_closes_the_open_chunk_under_the_end_it_turned_out_to_have() {
val cache = session()
(1L..3L).forEach { cache.append(line(it), it) }
// What a `reset` looks like from here: the next event is not the one after the last.
cache.append(line(90), 90)
cache.flush()
assertEquals(listOf("1-4.raw.jsonl", "90-open.raw.jsonl"), names())
// Nothing is served across the gap: the suffix is the newest chunk alone.
assertEquals(listOf(line(90)), cache.newest(80))
assertEquals(CachedTail(90, line(90)), cache.tail())
}
@Test
fun an_event_already_covered_is_not_written_again() {
val cache = session()
(1L..3L).forEach { cache.append(line(it), it) }
cache.append(line(2), 2)
cache.flush()
assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80)))
}
@Test
fun an_adjacent_page_extends_the_suffix_and_a_gap_stops_it() {
val cache = session()
(100L..102L).forEach { cache.append(line(it), it) }
cache.flush()
// Adjacent: its end is the open chunk's first.
assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true))
assertEquals(listOf(98L, 99L), seqs(cache.page(before = 100, limit = 2, rows = false)))
assertEquals(60L, seqs(cache.newest(80))?.first())
// Behind a gap: kept on disk, because paging usually closes the gap, but never served
// across it.
assertTrue(cache.storePage((1L..9L).map { line(it) }, 1, 10, rows = true))
assertNull(cache.page(before = 10, limit = 5, rows = false))
assertEquals(60L, seqs(cache.newest(200))?.first())
}
@Test
fun a_page_that_overlaps_what_is_here_is_not_stored() {
val cache = session()
cache.append(line(100), 100)
cache.flush()
assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true))
assertFalse(cache.storePage((50L..79L).map { line(it) }, 50, 80, rows = true))
assertFalse(cache.storePage(emptyList(), 40, 60, rows = true))
assertEquals(listOf("100-open.raw.jsonl", "60-100.rows.jsonl"), names())
}
@Test
fun a_miss_is_null_and_never_an_empty_page() {
val cache = session()
(100L..102L).forEach { cache.append(line(it), it) }
cache.flush()
// At or below where the run starts, so what the reader is scrolling into is the server's.
// An empty list here would be read as the start of the conversation and would stop the
// transcript scrolling back at all.
assertNull(cache.page(before = 100, limit = 40, rows = true))
assertNull(cache.page(before = 40, limit = 40, rows = true))
assertNull(session("never-visited").page(before = 100, limit = 40, rows = true))
}
@Test
fun a_page_starts_from_anywhere_inside_the_run_not_only_at_a_boundary() {
val cache = session()
(1L..10L).forEach { cache.append(line(it), it) }
cache.flush()
// Where a warm open leaves the cursor: in the middle of the live run, because the screen
// drew the newest lines of it. A cache that could only answer at a chunk boundary would
// send this to the server -- and the page that came back would overlap the run and be
// thrown away, so the whole of the scroll back would be fetched again on every visit.
assertEquals(listOf(5L, 6L, 7L), seqs(cache.page(before = 8, limit = 3, rows = false)))
assertEquals((1L..7L).toList(), seqs(cache.page(before = 8, limit = 99, rows = false)))
}
@Test
fun a_page_counted_in_rows_folds_each_delta_run_into_one_and_cuts_only_between_rows() {
val cache = session()
// Two replies of three deltas each, split by a tool call: the same fixture as the
// server's `coalescing_counts_rows_and_joins_delta_runs`.
val lines =
listOf(delta(1), delta(2), delta(3), line(4), delta(5), delta(6), delta(7), line(8))
write("1-9.raw.jsonl", lines)
cache.append(line(9), 9)
cache.flush()
// Three rows: the tool call at 8, the run 5..7, and the tool call at 4. The cut lands
// between rows, so the older run is not started.
assertEquals(
listOf(4L, 5L, 6L, 7L, 8L),
seqs(cache.page(before = 9, limit = 3, rows = true)),
)
// One row is one whole run, however many deltas it is made of.
assertEquals(listOf(8L), seqs(cache.page(before = 9, limit = 1, rows = true)))
// A page of lines counts lines, which is what the anchor restore asks for.
assertEquals(listOf(7L, 8L), seqs(cache.page(before = 9, limit = 2, rows = false)))
}
@Test
fun a_row_page_crosses_a_chunk_boundary_and_stops_short_at_the_oldest_chunk() {
val cache = session()
write("5-9.raw.jsonl", listOf(delta(5), delta(6), line(7), delta(8)))
cache.append(delta(9), 9)
cache.append(line(10), 10)
cache.flush()
// A run straddling the boundary is one row, as it will be once folded.
assertEquals(listOf(8L, 9L, 10L), seqs(cache.page(before = 11, limit = 2, rows = true)))
// Asking for more rows than the suffix holds is a short page, not a failure and not a
// claim that the conversation starts here.
assertEquals((5L..10L).toList(), seqs(cache.page(before = 11, limit = 40, rows = true)))
}
@Test
fun the_floor_for_a_fetch_is_the_nearest_chunk_at_or_below_it() {
val cache = session()
write("1-10.rows.jsonl", (1L..9L).map { line(it) })
write("10-40.rows.jsonl", (10L..39L).map { line(it) })
cache.append(line(90), 90)
cache.flush()
// The run behind the gap, which is what makes the fetched page adjacent to it: a page
// fetched before 90 with a floor of 39 stops at 40 and closes the gap exactly.
assertEquals(40L, cache.coveredUpTo(90))
assertEquals(40L, cache.coveredUpTo(41))
assertEquals(10L, cache.coveredUpTo(10))
// Nothing at or below the oldest chunk's start, so the page is bounded only by its limit.
assertNull(cache.coveredUpTo(9))
}
@Test
fun a_newest_chunk_that_is_not_raw_discards_the_session() {
val cache = session()
write("1-10.rows.jsonl", (1L..9L).map { line(it) })
// Only reachable by dying between closing one live run and opening the next, and there is
// no cursor to be read off a coalesced line -- so the open is a cold one.
assertNull(cache.tail())
assertFalse(dirOf().exists())
}
@Test
fun a_half_written_last_line_is_dropped_and_the_file_repaired() {
val cache = session()
dirOf().mkdirs()
File(dirOf(), "1-open.raw.jsonl").writeText(line(1) + "\n" + line(2) + "\n" + """{"se""")
assertEquals(CachedTail(2, line(2)), cache.tail())
assertEquals(line(1) + "\n" + line(2) + "\n", File(dirOf(), "1-open.raw.jsonl").readText())
// And the run continues from where the good tail left off.
cache.append(line(3), 3)
cache.flush()
assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80)))
}
@Test
fun damage_anywhere_else_discards_the_session_when_a_read_reaches_it() {
val cache = session()
write("1-open.raw.jsonl", listOf(line(1), "not ours", line(3)))
// Not seen by the tail, which reads the newest line and stops -- reading a chunk from its
// end is exactly not reading the rest of it, and that is what keeps a warm open cheap on
// a conversation of tens of megabytes.
assertEquals(CachedTail(3, line(3)), cache.tail())
// Reached by a read that walks past it, and there is no honest way to say what a chunk
// covers with a line of it unreadable -- so what is served is nothing, and the session
// opens cold from here on.
assertEquals(emptyList(), cache.newest(80))
assertFalse(dirOf().exists())
assertTrue(said.any { it.contains("damaged") })
}
@Test
fun a_name_this_does_not_recognise_is_ignored() {
val cache = session()
write("notes.txt", listOf("hello"))
write("1-open.raw.jsonl", listOf(line(1)))
assertEquals(CachedTail(1, line(1)), cache.tail())
}
@Test
fun a_chunk_larger_than_one_read_block_is_walked_across_the_boundaries() {
val cache = session()
// Well past the 64 kB block the backwards reader takes at a time, so a page has to be
// stitched across several of them -- including a line that straddles a boundary, which
// is the case nothing else here would notice going wrong.
val padding = "x".repeat(300)
val lines = (1L..500L).map { """{"seq":$it,"ts":1.5,"type":"toolStart","id":"$padding"}""" }
write("1-open.raw.jsonl", lines)
assertEquals(500L, cache.tail()!!.seq)
assertEquals(lines.takeLast(80), cache.newest(80))
assertEquals(lines.subList(0, 400), cache.page(before = 401, limit = 999, rows = false))
// And a non-ASCII line, whose bytes a naive split could cut through a character.
val accented = """{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"""
cache.append(accented, 501)
cache.flush()
assertEquals(accented, cache.tail()!!.line)
}
@Test
fun eviction_takes_the_least_recently_touched_and_never_the_one_on_screen() {
val cache = cache()
listOf("old", "middle", "open").forEachIndexed { at, id ->
write("1-open.raw.jsonl", List(50) { line(it + 1L) }, id = id)
dirOf(id).setLastModified(1_000_000L + at * 1000L)
}
val each = dirOf("old").walkTopDown().filter { it.isFile }.sumOf { it.length() }
// Room for two of the three, so the oldest goes -- and the session being read never does,
// however long ago it was last touched.
cache.evictToBudget(keep = "open", budget = each * 2)
assertEquals(listOf("middle", "open"), File(temp, "v1/host_8443").list()!!.sorted())
cache.evictToBudget(keep = "open", budget = 0)
assertEquals(listOf("open"), File(temp, "v1/host_8443").list()!!.sorted())
}
@Test
fun retaining_deletes_exactly_the_sessions_the_server_no_longer_lists() {
val cache = cache()
listOf("a", "b", "c").forEach { write("1-open.raw.jsonl", listOf(line(1)), id = it) }
cache.retainOnly(setOf("a", "c"))
assertEquals(listOf("a", "c"), File(temp, "v1/host_8443").list()!!.sorted())
}
@Test
fun size_and_purge_are_the_two_halves_of_the_reload_button() {
val cache = session()
assertEquals(0L, cache.bytes())
(1L..5L).forEach { cache.append(line(it), it) }
cache.flush()
assertTrue(cache.bytes() > 0)
cache.purge()
assertEquals(0L, cache.bytes())
assertNull(cache.tail())
// And the session is usable again straight afterwards, which is what a reload does next.
cache.append(line(9), 9)
cache.flush()
assertEquals(listOf(9L), seqs(cache.newest(80)))
}
}
+7 -1
View File
@@ -23,7 +23,13 @@ tokio-stream = { version = "0.1", features = ["sync"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" # `float_roundtrip` because this server hands out the same transcript line two ways -- the
# `/transcript` page and the SSE backlog both parse it out of the file and serialize it again --
# and serde_json's default float parser is not correctly rounded. Measured 2026-09-04: a `ts` of
# 1788546972.6030757 in the file came back as ...0755, so the two answers to "what is line 30"
# differed in the last bit while looking identical. What made that visible was the phone's
# transcript cache, which compares a line it already holds against the server's own answer.
serde_json = { version = "1", features = ["float_roundtrip"] }
# The config file's format. Not JSON, because this file is written and read # The config file's format. Not JSON, because this file is written and read
# by hand and RON says a sum type as syntax. The two house rules both # by hand and RON says a sum type as syntax. The two house rules both
# projects write it under live in wg-app-link; this is here for the error # projects write it under live in wg-app-link; this is here for the error
+8 -10
View File
@@ -1,17 +1,15 @@
//! Bearer-token auth for the entire HTTP surface. //! Bearer-token auth for the entire HTTP surface.
//! //!
//! This server's API *is* remote code execution, so the token gates every //! This server's API *is* remote code execution, so the token gates every route
//! route with zero unauthenticated endpoints -- the middleware is applied //! with zero unauthenticated endpoints -- the middleware is applied once around
//! once around the whole router (including the fallback) in `main.rs`, //! the whole router (including the fallback) in `main.rs`, never per-route, so a
//! never per-route, so a new route can't forget it. See PLAN.md's security //! new route can't forget it. See PLAN.md's security section for the threat
//! section for the threat model; the short version is that the token gates //! model.
//! LAN/tunnel-reachable RCE and is rotatable, and WireGuard makes it
//! defense in depth rather than the sole gate.
//! //!
//! Nothing in this module -- and nothing anywhere else -- may log the //! Nothing in this module -- and nothing anywhere else -- may log the
//! Authorization header or the token; the test below holds a tripwire //! Authorization header or the token; the test below is a tripwire against a
//! against a logging change silently starting to. It is one test covering //! logging change silently starting to. It is one test covering both gating and
//! both gating and logging on purpose -- see the note in it. //! logging on purpose -- see the note in it.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
+127 -192
View File
@@ -1,20 +1,17 @@
//! The server's persistent state: the enrolled token hashes and the //! The server's persistent state: the enrolled token hashes and the sessions
//! sessions that exist. //! that exist.
//! //!
//! Written whole and atomically (temp file + rename) rather than appended //! Written whole and atomically (temp file + rename) rather than appended to:
//! to: it is small, and a half-written config would take the server down on //! it is small, and a half-written config would take the server down on next
//! next start with no obvious way to recover from a phone. Every mutation //! start with no obvious way to recover from a phone. Every mutation funnels
//! funnels through `SessionManager` (the registry pattern), so in-memory //! through `SessionManager`, so in-memory and on-disk state can't come apart.
//! and on-disk state can't come apart.
//! //!
//! The file is RON, in the shape [`wg_app_link::format`] describes -- the //! The file is RON, in the shape [`wg_app_link::format`] describes -- the same
//! same format, and the same two house rules, as the sibling dev-updater //! two house rules as dev-updater's config, because both are read and written
//! project's config, because both are written and read by hand, and both //! by hand.
//! now read and write them through the one module.
//! //!
//! Transcripts do NOT live here -- each session's events are an append-only //! Transcripts do NOT live here: each session's events are an append-only JSONL
//! JSONL file in its own directory (see `session::transcript`); this file //! file in its own directory.
//! holds only the metadata needed to list and respawn sessions.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -27,24 +24,20 @@ use wg_app_link::format;
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)] #[serde(rename_all = "camelCase", default)]
pub struct Config { pub struct Config {
/// Enrolled device tokens, hashes only -- a leaked config doesn't leak /// Enrolled device tokens, hashes only -- a leaked config doesn't leak the
/// the credential. A list (of one, today) so per-device tokens with /// credential. A list (of one, today) so per-device tokens with individual
/// individual revocation are a config entry later, not a migration. /// revocation are a config entry later, not a migration.
pub tokens: Vec<TokenEntry>, pub tokens: Vec<TokenEntry>,
/// Every machine this server can run something on, and what each of
/// them can run. See [`SetupConfig`].
pub setups: Vec<SetupConfig>, pub setups: Vec<SetupConfig>,
pub sessions: Vec<SessionConfig>, pub sessions: Vec<SessionConfig>,
} }
/// A machine, and the things it can run. /// A machine, and the things it can run.
/// ///
/// This is the unit a session is spawned against: pick a setup, then one /// This is the unit a session is spawned against. Grouping providers under the
/// of its providers. Grouping them this way is what stops the spawn /// machine they exist on is what stops the spawn screen offering combinations
/// screen offering combinations that cannot work -- a provider only /// that cannot work; the previous model let any provider be paired with any
/// exists on a machine where that program is installed, and the previous /// host and offered the whole cross-product.
/// model, which let any provider be paired with any host, offered the
/// whole cross-product including the impossible parts of it.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SetupConfig { pub struct SetupConfig {
@@ -53,15 +46,12 @@ pub struct SetupConfig {
/// renaming a machine on the phone does not orphan its sessions -- /// renaming a machine on the phone does not orphan its sessions --
/// which is the whole reason the two are separate fields. /// which is the whole reason the two are separate fields.
pub id: String, pub id: String,
/// The label a person reads and may edit.
pub name: String, pub name: String,
/// How to reach it, absent for this machine. A setup with no `ssh` is /// How to reach it, absent for this machine.
/// where the server itself runs.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<SshConfig>, pub ssh: Option<SshConfig>,
/// What can be spawned here. Names are unique within a setup, and only /// What can be spawned here. Names are unique within a setup, and only
/// within it: two machines may each have a `claude-cli`, which is the /// within it: two machines may each have a `claude-cli`, which is the point.
/// point.
#[serde(default)] #[serde(default)]
pub providers: Vec<ProviderConfig>, pub providers: Vec<ProviderConfig>,
} }
@@ -88,12 +78,10 @@ pub struct ProviderConfig {
pub models: Vec<String>, pub models: Vec<String>,
} }
/// How to reach a setup that isn't this machine, with the system `ssh` /// How to reach a setup that isn't this machine, with the system `ssh` client
/// client -- so `~/.ssh/config`, agents, and jump hosts all keep working, /// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
/// and there is one place to configure connections (PLAN.md, rule 23). /// one place to configure connections. A remote session is the identical
/// /// command with `ssh host …` in front, and nothing downstream knows.
/// A remote session is the identical command with `ssh host …` in front,
/// and nothing downstream of the spawn knows the difference.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SshConfig { pub struct SshConfig {
@@ -120,59 +108,49 @@ pub struct SshConfig {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub models_dir: Option<PathBuf>, pub models_dir: Option<PathBuf>,
/// Where a file attached from the phone is put on this machine so the /// Where a file attached from the phone is put on this machine so the
/// session can read it. Absent means the session's own working /// session can read it. Absent means the session's own working directory,
/// directory, or the login home for a session that has none. A `~` /// or the login home for a session that has none. A `~` prefix is the
/// prefix is the remote home. /// remote home.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub attachments_dir: Option<PathBuf>, pub attachments_dir: Option<PathBuf>,
} }
/// Which translator runs a session. A new one is a new driver behind the /// Which translator runs a session. A new one is a new driver behind the same
/// same trait -- never a branch in shared code. /// trait -- never a branch in shared code.
/// ///
/// Snake case, which is both Rust's and RON's: this is written into a /// Snake case, which is both Rust's and RON's: this is written into a config a
/// config a person edits by hand, and a hyphen is not a RON identifier, so /// person edits by hand, and a hyphen is not a RON identifier, so kebab case
/// kebab case cost the file a `kind: r#claude-cli` escape to say a name /// cost the file a `kind: r#claude-cli` escape. The same string is what the
/// nobody would type that way. The same string is what the phone compares /// phone compares against, so the two move together.
/// against (`SpawnScreen.kt`), so the two move together.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum DriverKind { pub enum DriverKind {
/// The phase-1 fake: echoes messages back as streamed events. Proves /// The fake driver: echoes messages back as streamed events, proving the
/// the pipe (spawn, SSE, transcript cursors, questions) with no AI /// pipe with no AI involved. Always available as a built-in provider.
/// involved, and stays useful as a connectivity check that costs no
/// tokens. Always available as a built-in provider.
Echo, Echo,
/// A GGUF model served by llama.cpp's `llama-server` (see /// A GGUF model served by llama.cpp's `llama-server`. The model is one this
/// `session::llama`). The model itself is one this machine has /// machine has downloaded; the provider's command is the server binary.
/// downloaded; the provider's command is the server binary.
LlamaCpp, LlamaCpp,
/// The Claude Code CLI over stream-json (see `session::claude`). /// The Claude Code CLI over stream-json. Named for the CLI specifically:
/// Named for the CLI specifically: bare "claude" would suggest the /// bare "claude" would suggest the credit-billed API, which this is not.
/// credit-billed API, which this is not.
ClaudeCli, ClaudeCli,
} }
impl DriverKind { impl DriverKind {
/// The longest edge, in pixels, an image should have when it reaches /// The longest edge, in pixels, an image should have when it reaches this
/// this kind of session -- `None` where nothing here has a limit worth /// kind of session -- `None` where nothing here has a limit worth enforcing.
/// enforcing.
/// ///
/// Reported to the phone rather than applied here, so the bytes are made /// Reported to the phone rather than applied here, so the bytes are made
/// small before they cross the tunnel instead of after: a modern phone /// small before they cross the tunnel instead of after: a modern phone photo
/// photo is several megabytes and twelve megapixels, and every one of /// is several megabytes, and every one of them was being uploaded over
/// those bytes was being uploaded over WireGuard only to be rejected at /// WireGuard only to be rejected at the other end. What decides the number
/// the other end. What decides the number is the provider, which is why /// is the provider, which is why it lives beside the kind rather than in the
/// it lives beside the kind rather than in the app -- a phone that knew /// app.
/// each provider's limits would be a second place to update when one
/// changes.
/// ///
/// 1568 for the Claude CLI because that is the longest edge the API /// 1568 for the Claude CLI because that is the longest edge the API itself
/// itself resizes to; anything larger is charged the same and spends the /// resizes to; anything larger is charged the same and spends the upload for
/// upload for nothing, and far larger is refused outright, which is what /// nothing, and far larger is refused outright -- which is what "sending an
/// "sending an image is broken" turned out to be. The others take images /// image is broken" turned out to be.
/// through no path that cares, so they get no limit rather than a made-up
/// one.
pub fn max_image_edge(self) -> Option<u32> { pub fn max_image_edge(self) -> Option<u32> {
match self { match self {
DriverKind::ClaudeCli => Some(1568), DriverKind::ClaudeCli => Some(1568),
@@ -180,30 +158,22 @@ impl DriverKind {
} }
} }
/// Which paid service meters a session of this kind, and `None` for /// Which paid service meters a session of this kind, and `None` for one
/// one that costs nothing. /// that costs nothing.
/// ///
/// The rate-limit bars answer a question about an *account*, and what /// What decides which account -- if any -- a rate-limit bar is about is the
/// decides which account -- if any -- is the provider a session runs, /// provider a session runs, not the machine it runs on: an echo session on
/// not the machine it runs on. Those were the same thing only for as /// a machine that also has the Claude CLI was drawn with that CLI's
/// long as a machine ran one kind of session: an echo session on a /// five-hour window, a quota it cannot spend.
/// laptop that also has the Claude CLI was drawn with that CLI's
/// five-hour window under its header, reporting a quota it cannot
/// spend and could not run down. A llama.cpp session is the same
/// story with the model on the far side.
/// ///
/// [`DriverKind::Echo`] names a meter of its own, which exists only /// Echo names a meter of its own that exists only when a test has asked for
/// when a test has asked for one (`/usage` in `session::echo`). That /// one (`/usage` in `session::echo`), which is how the bar's states are
/// is what makes the bar's states -- a number, a machine nobody /// reached without an account. With none set there is no snapshot, and the
/// logged into, one that could not be reached -- reachable without an /// phone draws nothing.
/// account and without spending a turn on somebody else's. With no
/// fixture set there is no snapshot for it, which the phone draws as
/// nothing at all.
/// ///
/// The string is a [`crate::usage::UsageProvider::name`], and it is /// The string is a [`crate::usage::UsageProvider::name`], and it is what
/// what pairs a session with one of the snapshots `GET /usage` /// pairs a session with one of `GET /usage`'s snapshots -- so
/// returns; the two lists have to agree, so `usage::providers_for` /// `usage::providers_for` reads this rather than matching on kinds again.
/// reads this rather than matching on kinds a second time.
pub fn usage_provider(self) -> Option<&'static str> { pub fn usage_provider(self) -> Option<&'static str> {
match self { match self {
Self::ClaudeCli => Some(crate::usage::CLAUDE), Self::ClaudeCli => Some(crate::usage::CLAUDE),
@@ -212,21 +182,17 @@ impl DriverKind {
} }
} }
/// Whether the conversation exists outside this app, so that deleting /// Whether the conversation exists outside this app, so that deleting the
/// the session here does not end it. /// session here does not end it.
/// ///
/// The Claude Code CLI owns its own transcript under /// The Claude Code CLI owns its own transcript and is resumable from it
/// `~/.claude/projects/` and is resumable from it whatever started /// whatever started it, so a session this app spawned is every bit as
/// it -- so a session this app spawned is every bit as recoverable as /// recoverable as one it imported. Echo has nothing to keep, and a llama
/// one it imported, and the difference between those two is only how /// session's conversation is folded out of *this* app's transcript.
/// it got here. Echo has nothing to keep, and a llama session's
/// conversation is folded out of *this* app's transcript, so for both
/// of those a delete is the end of it.
/// ///
/// Asked before warning somebody that a deletion cannot be undone, /// Asked before warning somebody that a deletion cannot be undone, which is
/// which is the one sentence that has to be true: said of a session /// the one sentence that has to be true: said of a session that can in fact
/// that can in fact be brought back, it spends the credibility the /// be brought back, it spends the credibility the warning needs.
/// warning needs on the sessions where it is real.
pub fn keeps_own_transcript(self) -> bool { pub fn keeps_own_transcript(self) -> bool {
match self { match self {
Self::ClaudeCli => true, Self::ClaudeCli => true,
@@ -238,11 +204,9 @@ impl DriverKind {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TokenEntry { pub struct TokenEntry {
/// Which device this token belongs to, for the human rotating it.
pub name: String, pub name: String,
/// Hex SHA-256 of the token. A plain hash is enough: the token is 256 /// Hex SHA-256 of the token. A plain hash is enough: the token is 256 bits
/// bits from the OS CSPRNG, so there is nothing to dictionary-attack /// from the OS CSPRNG, so there is nothing to dictionary-attack.
/// and no stretching needed.
pub sha256: String, pub sha256: String,
} }
@@ -251,73 +215,56 @@ pub struct TokenEntry {
pub struct SessionConfig { pub struct SessionConfig {
/// Stable identifier; names the session's directory and its routes. /// Stable identifier; names the session's directory and its routes.
pub id: String, pub id: String,
/// Id of the [`SetupConfig`] this session runs on -- the id, not the /// Id of the [`SetupConfig`] this session runs on -- the id, not the label,
/// label, so the machine can be renamed without losing its sessions. /// so the machine can be renamed without losing its sessions.
pub setup: String, pub setup: String,
/// Name of the provider within that setup. Both stored by name rather /// Name of the provider within that setup. Both stored by name rather than
/// than resolved, so an edited setup (a new command path, another /// resolved, so an edited setup takes effect on the next relaunch; a session
/// model) takes effect on the next relaunch; a session whose setup or /// whose setup or provider is gone reports as exited and can still be
/// provider is gone reports as exited and can still be deleted. /// deleted.
pub provider: String, pub provider: String,
pub title: String, pub title: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>, pub model: Option<String>,
/// Working directory the session's process runs in.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>, pub cwd: Option<PathBuf>,
/// Claude permission mode chosen at spawn. Meaningless for other /// Claude permission mode chosen at spawn. Kept as a string because it is
/// kinds, and kept as a string because it is passed straight to the /// passed straight to `--permission-mode` rather than interpreted here, so
/// CLI's `--permission-mode` rather than interpreted here -- so the /// the CLI stays the one authority on which modes exist.
/// CLI stays the one authority on which modes exist, and a new one
/// needs no change on this side.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>, pub permission_mode: Option<String>,
/// Settings the driver interprets, chosen at spawn. /// Settings the driver interprets, chosen at spawn.
/// ///
/// Deliberately untyped here: what a temperature or a context size /// Deliberately untyped: what a temperature or a context size means is the
/// means is the driver's business, and giving this schema a field per /// driver's business, and a field per driver is how a shared model starts
/// driver is how a shared model starts carrying one dialect's /// carrying one dialect's vocabulary. `permission_mode` above predates this
/// vocabulary. `permission_mode` above predates this and should fold /// and should fold into it. BTreeMap so the file's order is stable.
/// into it. A map rather than a list so the phone can send exactly
/// what a person changed, and BTreeMap so the file's order is stable
/// across writes.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")] #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub params: BTreeMap<String, String>, pub params: BTreeMap<String, String>,
/// Whether a phone should be told when this session wants attention. /// Whether a phone should be told when this session wants attention.
/// ///
/// Stored here rather than on the phone because it is a fact about the /// Stored here rather than on the phone because it is a fact about the
/// session: one that runs unattended overnight should be quiet on /// session: one that runs unattended overnight should be quiet on every
/// every device, and answering that question again on each new phone /// device.
/// is how two devices come to disagree about which sessions matter.
/// ///
/// Defaults to on, and on for a config written before this field /// Defaults to on. Silent-unless-asked makes the feature invisible to
/// existed. The alternative -- silent unless asked -- makes the /// anyone who does not go looking, and a notification nobody wanted is
/// feature invisible to anyone who does not go looking for it, and a /// turned off in one tap where one that never arrived is not diagnosable.
/// notification nobody wanted is turned off in one tap where one that
/// never arrived is not diagnosable at all.
#[serde(default = "notify_default")] #[serde(default = "notify_default")]
pub notify: bool, pub notify: bool,
/// Whether this session's process is stopped when the server exits, /// Whether this session's process is stopped when the server exits, instead
/// instead of being left running for the next start to adopt. /// of being left running for the next start to adopt.
/// ///
/// A fact about the session rather than about the run that spawned it, /// A fact about the session rather than about the run that spawned it, which
/// which is why it is persisted: whichever server is running when the /// is why it is persisted: whichever server is running when the time comes
/// time comes is the one that has to act on it, and a session nobody /// is the one that has to act on it.
/// meant to keep should not depend on the same server still being up
/// to clean it away.
/// ///
/// Written by a server started with `--throwaway-sessions`, which is /// Written by a server started with `--throwaway-sessions`, the default in a
/// the default in a debug build. A session spawned while testing is /// debug build. Under the ordinary rule a test session's `claude` outlives
/// one nobody means to keep, and under the ordinary rule its `claude` /// every server that ever knew about it -- twelve accumulated on this
/// outlives every server that ever knew about it -- twelve of them /// machine in a day. Absent means false.
/// accumulated on this machine in a day, each holding a conversation
/// open.
///
/// Absent means false: every session written before this existed, and
/// every one spawned by a release build.
#[serde(default, skip_serializing_if = "not_set")] #[serde(default, skip_serializing_if = "not_set")]
pub throwaway: bool, pub throwaway: bool,
/// Epoch seconds when the session was spawned.
pub created: f64, pub created: f64,
} }
@@ -326,29 +273,25 @@ fn notify_default() -> bool {
} }
/// Keeps the ordinary case out of the file entirely -- see /// Keeps the ordinary case out of the file entirely -- see
/// [`SessionConfig::throwaway`], which is false for every session a /// [`SessionConfig::throwaway`].
/// production build writes.
fn not_set(flag: &bool) -> bool { fn not_set(flag: &bool) -> bool {
!*flag !*flag
} }
/// The name of the echo provider, and of the setup this machine gets on /// The name of the echo provider, and of the setup this machine gets on first
/// first run. /// run.
/// ///
/// Echo is seeded into the config rather than conjured at read time the /// Echo is seeded into the config rather than conjured at read time. An
/// way it used to be. An implicit provider is one a person cannot see in /// implicit provider is one a person cannot see in the file or edit from the
/// the file or edit from the phone, and the point of this app is that /// phone; if somebody deletes it, that was a choice.
/// configuration is visible and editable; if somebody deletes it, that was
/// a choice.
pub const ECHO_PROVIDER: &str = "echo"; pub const ECHO_PROVIDER: &str = "echo";
pub const LOCAL_SETUP: &str = "this machine"; pub const LOCAL_SETUP: &str = "this machine";
/// The id of the setup a fresh install seeds. Fixed rather than random so /// The id of the setup a fresh install seeds. Fixed rather than random so a
/// a hand-written config can name it without looking one up. /// hand-written config can name it without looking one up.
pub const LOCAL_SETUP_ID: &str = "local"; pub const LOCAL_SETUP_ID: &str = "local";
/// Where `ai-server --enroll-link` leaves a token for the running server /// Where `ai-server --enroll-link` leaves a token for the running server to
/// to adopt: beside the config, since it is config in transit. See /// adopt: beside the config, since it is config in transit.
/// `wg_app_link::enroll::spool_pending`.
pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf { pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf {
config_path.with_file_name("pending-enrollments") config_path.with_file_name("pending-enrollments")
} }
@@ -358,22 +301,19 @@ impl Config {
self.setups.iter().find(|setup| setup.id == id) self.setups.iter().find(|setup| setup.id == id)
} }
/// A setup by the label a person sees, for messages and for the one /// A setup by the label a person sees, for messages and for the one place a
/// place a name still arrives from outside: nothing else should look /// name still arrives from outside. Nothing else should look one up this
/// one up this way, since labels are editable and ids are not. /// way, since labels are editable and ids are not.
pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> { pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> {
self.setups.iter().find(|setup| setup.name == name) self.setups.iter().find(|setup| setup.name == name)
} }
/// This machine, offering whatever was found on it. /// This machine, offering whatever was found on it.
/// ///
/// The providers are passed in rather than written here because they /// The providers are passed in rather than written here because they have to
/// have to be *discovered*: a hardcoded list is a claim about what is /// be *discovered*: a hardcoded list is a claim about what is installed, and
/// installed, and this one was wrong -- every fresh install asserted a /// this one was wrong -- every fresh install asserted a `claude-cli`
/// `claude-cli` provider whether or not `claude` existed, which on a /// provider whether or not `claude` existed.
/// machine without it is a spawn option that cannot work and a
/// statement the server never checked. Providers are discovered by
/// asking the machine, here exactly as for any other setup.
pub fn seed(providers: Vec<ProviderConfig>) -> SetupConfig { pub fn seed(providers: Vec<ProviderConfig>) -> SetupConfig {
SetupConfig { SetupConfig {
id: LOCAL_SETUP_ID.to_string(), id: LOCAL_SETUP_ID.to_string(),
@@ -383,12 +323,9 @@ impl Config {
} }
} }
/// The one provider that needs no discovery, and the floor to fall /// The one provider that needs no discovery, and the floor to fall back to
/// back to when discovery itself fails. /// when discovery itself fails. Echo runs in-process, so it exists exactly
/// /// where this server does and nowhere else.
/// Echo runs in-process, so it exists exactly where this server does
/// and nowhere else -- there is nothing to probe for, and offering it
/// on a remote machine would be a choice that changes nothing.
pub fn echo_provider() -> ProviderConfig { pub fn echo_provider() -> ProviderConfig {
ProviderConfig { ProviderConfig {
name: ECHO_PROVIDER.to_string(), name: ECHO_PROVIDER.to_string(),
@@ -402,8 +339,8 @@ impl Config {
match std::fs::read_to_string(path) { match std::fs::read_to_string(path) {
Ok(text) => format::parse(&text) Ok(text) => format::parse(&text)
.with_context(|| format!("{} is not valid config RON", path.display())), .with_context(|| format!("{} is not valid config RON", path.display())),
// A first run has no config -- the normal starting state; a // A first run has no config -- the normal starting state; a token is
// token is generated and saved on that first start. // generated and saved on that first start.
Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
warn_about_a_config_left_behind(path); warn_about_a_config_left_behind(path);
Ok(Self::default()) Ok(Self::default())
@@ -414,12 +351,10 @@ impl Config {
/// Writes the config, owner-readable only. /// Writes the config, owner-readable only.
/// ///
/// The token hashes here are verifiers, not secrets -- a 256-bit /// The token hashes here are verifiers rather than secrets, but the file
/// random token can't be recovered from its SHA-256 -- but the file /// also names every host this backend can reach and every session it is
/// also names every host this backend can reach and every session it /// running. The mode is set on the temporary file *before* the rename, so
/// is running, which is nobody else's business on a shared machine. /// the config is never briefly world-readable at its real path.
/// The mode is set on the temporary file *before* the rename, so the
/// config is never briefly world-readable at its real path.
pub fn save(&self, path: &Path) -> Result<()> { pub fn save(&self, path: &Path) -> Result<()> {
format::write(path, self) format::write(path, self)
} }
+104 -130
View File
@@ -1,71 +1,58 @@
//! Reading and changing files on the machine a setup names. //! Reading and changing files on the machine a setup names.
//! //!
//! Every operation here is one small POSIX shell script handed to //! Every operation here is one small POSIX shell script handed to `Transport`,
//! `Transport`, exactly the way `setups::discover` and `import::list` //! the way `setups::discover` and `import::list` already ask a machine a
//! already ask a machine a question. That is what makes the local and the //! question. That is what makes the local and the ssh case one implementation:
//! ssh case one implementation: a second one written against `std::fs` //! a second one written against `std::fs` would be the one that gets tested,
//! would be the one that gets tested, and the remote half -- the ordering //! and the remote half -- the ordering of entries, what a symlink reports, how
//! of entries, what a symlink reports, how a permission error reads -- //! a permission error reads -- would drift until it shipped broken. The cost is
//! would drift until it shipped broken. The cost is an `sh` process per //! an `sh` process per operation here, which is under a millisecond.
//! operation on this machine, which is under a millisecond.
//! //!
//! The scripts assume GNU coreutils and findutils (`find -printf`, //! The scripts assume GNU coreutils and findutils, which is what
//! `stat -c`, `sha256sum`, `chmod --reference`), which is what //! `session::import` already assumes. A machine without them fails with that
//! `session::import` already assumes and what both machines here run. One //! tool's own message, which names what is missing.
//! without them fails with that tool's own message, which names what is
//! missing.
//! //!
//! **The phone names a path, and that is deliberate** -- see PLAN.md's //! **The phone names a path, and that is deliberate** -- see PLAN.md's Security
//! Security section. The enrolled token already spawns an agent in any //! section. What is *not* given up: no route here accepts a command. Listing,
//! directory on any machine a setup names, and that agent reads and writes //! reading and writing are the fixed scripts below, and the phone chooses only
//! every file its user can; this is a shorter path to authority the token //! the path and the bytes.
//! already holds. What is *not* given up: no route here accepts a command.
//! Listing, reading and writing are the fixed scripts below, and the phone
//! chooses only the path and the bytes.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::Serialize; use serde::Serialize;
use crate::session::transport::{Input, Launch, Transport}; use crate::session::transport::{Input, Launch, Transport};
/// The most of a file that crosses the tunnel, in bytes. /// The most of a file that crosses the tunnel, in bytes. Checked on the far
/// /// machine before anything reads the file, so a 2 GB log costs a `stat` rather
/// Checked on the far machine before anything reads the file, so a 2 GB /// than a transfer. A file over it is [`FileRead::TooBig`] with its size,
/// log costs a `stat` rather than a transfer. A file over it is reported /// because "we did not read this" and "this is empty" must not look the same.
/// as [`FileRead::TooBig`] with its size, because "we did not read this"
/// and "this is empty" must not look the same on the phone.
pub const FILE_LIMIT: u64 = 1024 * 1024; pub const FILE_LIMIT: u64 = 1024 * 1024;
/// The prelude every script here starts with: the path arrives as `$1`, /// The prelude every script here starts with: the path arrives as `$1`, and
/// and this is where a leading `~` becomes that machine's own home. /// this is where a leading `~` becomes that machine's own home.
/// ///
/// The path is a **positional argument** and never text spliced into the /// The path is a **positional argument** and never text spliced into the script
/// script -- the rule `import::find` follows with `"$1"`, for the reason /// -- the rule `import::find` follows, for the reason `ssh::quote` exists: a
/// `ssh::quote` exists: a path is attacker-adjacent input in a server /// path is attacker-adjacent input in a server whose job is running commands,
/// whose job is running commands, and interpolated it would be syntax /// and interpolated it would be syntax rather than data.
/// rather than data.
/// ///
/// `~` is the one character that costs something for it. A shell expands a /// `~` is the one character that costs something for it. A shell expands a
/// tilde in *text*, so a path handed over as an argument arrives with a /// tilde in *text*, so a path handed over as an argument arrives with a literal
/// literal one; expanding it here, once, gives it the same meaning /// one; expanding it here gives it the same meaning `ssh::quote_path` gives it
/// `ssh::quote_path` and `ssh::expand_home` give it everywhere else, and /// everywhere else, and it is the *far* machine's `$HOME`. `~user` stays
/// it is the *far* machine's `$HOME` -- the only one that could be right. /// literal and fails with the shell's own message.
/// `~user` stays literal here too, and fails with the shell's own message.
/// ///
/// Everything below uses `$p` for the path and `$2` for whatever else it /// Everything below uses `$p` for the path and `$2` for whatever else.
/// was given.
const PATH_PRELUDE: &str = r#"p=$1; case $p in "~") p=$HOME;; "~/"*) p=$HOME/${p#"~/"};; esac; "#; const PATH_PRELUDE: &str = r#"p=$1; case $p in "~") p=$HOME;; "~/"*) p=$HOME/${p#"~/"};; esac; "#;
/// What a directory turned out to be, and what is in it. /// What a directory turned out to be, and what is in it.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Listing { pub struct Listing {
/// `pwd -P` of the directory that was listed. /// `pwd -P` of the directory that was listed. Answered by the machine
/// /// rather than worked out here, so the phone navigates on a resolved
/// Answered by the machine rather than worked out here, so the phone /// absolute path: the parent of one of these is a string operation, and a
/// navigates on a resolved absolute path: the parent of one of these /// `~` a session was spawned with is shown as what it turned out to be.
/// is a string operation, and a `~` a session was spawned with is
/// shown as what it turned out to be.
pub path: String, pub path: String,
pub entries: Vec<Entry>, pub entries: Vec<Entry>,
} }
@@ -74,14 +61,13 @@ pub struct Listing {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Entry { pub struct Entry {
pub name: String, pub name: String,
/// What tapping it does, which for a symlink is decided by its /// What tapping it does, which for a symlink is decided by its *target* --
/// *target* -- a link to a directory navigates. /// a link to a directory navigates.
pub kind: EntryKind, pub kind: EntryKind,
pub size: u64, pub size: u64,
/// Seconds since the epoch.
pub modified: i64, pub modified: i64,
/// Whether the entry itself is a symlink, whatever [`Entry::kind`] /// Whether the entry itself is a symlink, whatever [`Entry::kind`] says
/// says its target is. /// its target is.
pub link: bool, pub link: bool,
} }
@@ -90,18 +76,18 @@ pub struct Entry {
pub enum EntryKind { pub enum EntryKind {
Directory, Directory,
File, File,
/// A socket, a device, a fifo -- and a symlink whose target is missing /// A socket, a device, a fifo -- and a symlink whose target is missing or
/// or loops, which `find` reports the same way. Shown, because a /// loops, which `find` reports the same way. Shown, because a directory
/// directory that hid what it held would be lying about being empty. /// that hid what it held would be lying about being empty.
Other, Other,
} }
/// What reading a file produced -- four answers, not content-or-error. /// What reading a file produced -- four answers, not content-or-error.
/// ///
/// A binary file drawn as text and a big file cut off silently are both /// A binary file drawn as text and a big file cut off silently are both wrong
/// wrong in ways the reader cannot see, and "couldn't read it" must not /// in ways the reader cannot see, and "couldn't read it" must not look like
/// look like "it is empty". A file that is genuinely empty is /// "it is empty". A genuinely empty file is [`FileRead::Text`] with nothing in
/// [`FileRead::Text`] with nothing in it, which is what it is. /// it, which is what it is.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")] #[serde(tag = "kind", rename_all = "camelCase")]
pub enum FileRead { pub enum FileRead {
@@ -114,8 +100,8 @@ pub enum FileRead {
}, },
/// Not UTF-8. Its size is reported; nothing is shown. /// Not UTF-8. Its size is reported; nothing is shown.
Binary { size: u64, modified: i64 }, Binary { size: u64, modified: i64 },
/// Over [`FILE_LIMIT`]. Its size is reported, so the reader knows what /// Over [`FILE_LIMIT`]. Its size is reported, so the reader knows what they
/// they are looking at rather than only that they cannot have it. /// are looking at rather than only that they cannot have it.
TooBig { size: u64, modified: i64 }, TooBig { size: u64, modified: i64 },
} }
@@ -129,18 +115,16 @@ pub struct Written {
pub sha256: String, pub sha256: String,
} }
/// The exit code the write script uses for "this is not the file you /// The exit code the write script uses for "this is not the file you read",
/// read", which the route turns into a 409. Distinct from every other /// which the route turns into a 409. Distinct from every other failure, which
/// failure, which is a message from the machine. /// is a message from the machine.
pub const STALE: i32 = 3; pub const STALE: i32 = 3;
/// A path the phone may name: absolute, or home-relative on that machine. /// A path the phone may name: absolute, or home-relative on that machine.
/// ///
/// Shared with `POST /sessions/{id}/cwd`, which asks the same question for /// Shared with `POST /sessions/{id}/cwd`, which asks the same question for the
/// the same reason -- a relative path is relative to something nobody /// same reason -- a relative path is relative to something nobody looking at
/// looking at the screen can see, so it is refused rather than resolved /// the screen can see, so it is refused rather than resolved against a guess.
/// against a guess. Returns the path with the whitespace a phone keyboard
/// adds taken off.
pub fn check_path(path: &str) -> Result<String> { pub fn check_path(path: &str) -> Result<String> {
let path = path.trim(); let path = path.trim();
if path.is_empty() { if path.is_empty() {
@@ -160,8 +144,8 @@ fn launch(script: String, path: &str, extra: Option<&str>) -> Launch {
let mut args = vec![ let mut args = vec![
"-c".to_string(), "-c".to_string(),
script, script,
// `$0`, which is what `sh` names itself in a message about the // `$0`, which is what `sh` names itself in a message about the script;
// script; the path is `$1`. // the path is `$1`.
"sh".to_string(), "sh".to_string(),
path.to_string(), path.to_string(),
]; ];
@@ -169,11 +153,10 @@ fn launch(script: String, path: &str, extra: Option<&str>) -> Launch {
Launch::new("sh", args, None) Launch::new("sh", args, None)
} }
/// Everything in `path`, and what `path` resolved to. /// Everything in `path`, and what `path` resolved to. Entries are separated by
/// /// `\0` and their fields by `\t`, so a filename with a newline or a tab in it
/// Entries are separated by `\0` and their fields by `\t`, so a filename /// survives -- both are legal, and a listing that lost one would quietly show
/// with a newline or a tab in it survives -- both are legal, and a listing /// the wrong thing.
/// that lost one would quietly show the wrong thing.
pub async fn list(transport: &Transport, path: &str) -> Result<Listing> { pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
let script = format!( let script = format!(
"{PATH_PRELUDE}cd -- \"$p\" && pwd -P && \ "{PATH_PRELUDE}cd -- \"$p\" && pwd -P && \
@@ -193,11 +176,9 @@ pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
}) })
} }
/// The `find` output above, as rows. /// The `find` output above, as rows. A record without all five fields is
/// /// dropped rather than guessed at: it can only come from a `find` that printed
/// A record that does not have all five fields is dropped rather than /// something else, and half a row is worse than no row.
/// guessed at: it can only come from a `find` that printed something else,
/// and half a row is worse than no row.
fn parse_entries(text: &str) -> Vec<Entry> { fn parse_entries(text: &str) -> Vec<Entry> {
text.split('\0') text.split('\0')
.filter(|record| !record.is_empty()) .filter(|record| !record.is_empty())
@@ -208,8 +189,7 @@ fn parse_entries(text: &str) -> Vec<Entry> {
let own = fields.next()?; let own = fields.next()?;
let target = fields.next()?; let target = fields.next()?;
let size = fields.next()?.parse().ok()?; let size = fields.next()?.parse().ok()?;
// `%T@` is seconds with a fractional part; the phone shows a // `%T@` is seconds with a fractional part; the phone shows a date.
// date, so the fraction is dropped rather than carried.
let modified = fields.next()?.split('.').next()?.parse().ok()?; let modified = fields.next()?.split('.').next()?.parse().ok()?;
let name = fields.next()?; let name = fields.next()?;
Some(Entry { Some(Entry {
@@ -229,14 +209,13 @@ fn parse_entries(text: &str) -> Vec<Entry> {
/// One file's content, or the reason there is none to show. /// One file's content, or the reason there is none to show.
/// ///
/// The size is checked on the far machine *before* anything reads the /// The size is checked on the far machine *before* anything reads the file, so
/// file, so a file over [`FILE_LIMIT`] costs a `stat` rather than a /// a file over [`FILE_LIMIT`] costs a `stat` rather than a transfer. `stat -L`
/// transfer. `stat -L` and `sha256sum` both follow symlinks, as `cat` /// and `sha256sum` both follow symlinks, as `cat` does.
/// does, so a link to a file reports the file.
pub async fn read(transport: &Transport, path: &str) -> Result<FileRead> { pub async fn read(transport: &Transport, path: &str) -> Result<FileRead> {
// Two header lines, then the bytes: `<size> <mtime>`, then either // Two header lines, then the bytes: `<size> <mtime>`, then either `tooBig`
// `tooBig` or the digest. A header rather than a JSON envelope because // or the digest. A header rather than a JSON envelope because the content
// the content is bytes and may not be text at all. // is bytes and may not be text at all.
let script = format!( let script = format!(
"{PATH_PRELUDE}set -e; \ "{PATH_PRELUDE}set -e; \
h=$(stat -L -c '%s %Y' -- \"$p\"); \ h=$(stat -L -c '%s %Y' -- \"$p\"); \
@@ -281,24 +260,21 @@ fn split_read(out: &[u8]) -> Result<(u64, i64, &str, &[u8])> {
)) ))
} }
/// Replaces `path`'s contents, but only while it still hashes to /// Replaces `path`'s contents, but only while it still hashes to `expected`.
/// `expected`.
/// ///
/// Agents edit files while people read them, so a stale copy landing on /// Agents edit files while people read them, so a stale copy landing on top of
/// top of somebody else's edit is the common case rather than the exotic /// somebody else's edit is the common case rather than the exotic one. The
/// one. The digest the reader was shown is compared on the machine, and a /// digest the reader was shown is compared on the machine, and a file that has
/// file that has moved on comes back as [`STALE`] rather than being /// moved on comes back as [`STALE`] rather than being overwritten.
/// overwritten.
/// ///
/// A temp file and a rename, so a connection dropped mid-write leaves the /// A temp file and a rename, so a connection dropped mid-write leaves the old
/// old file whole rather than a truncated one, and `chmod --reference` so /// file whole, and `chmod --reference` so the mode survives -- an executable
/// the mode survives -- an executable script written as a fresh file would /// script written as a fresh file would stop being one. What that trades away:
/// stop being one. What that trades away: the inode changes, so a hard /// the inode changes, so a hard link elsewhere stops being the same file.
/// link elsewhere stops being the same file. Editors do the same.
/// ///
/// The check and the write are **not** atomic against a writer landing /// The check and the write are **not** atomic against a writer landing between
/// between them -- a window of microseconds on that machine. Accepted: the /// them -- a window of microseconds on that machine. Accepted: the alternative
/// alternative is a lock this has no way to make every other writer take. /// is a lock this has no way to make every other writer take.
pub async fn write( pub async fn write(
transport: &Transport, transport: &Transport,
path: &str, path: &str,
@@ -334,17 +310,16 @@ pub async fn write(
})) }))
} }
/// The file is not the one that was read. Its own type rather than an /// The file is not the one that was read. Its own type rather than an error
/// error string, because the route answers it with a different status and /// string, because the route answers it with a different status and the phone
/// the phone with a different question. /// with a different question.
#[derive(Debug)] #[derive(Debug)]
pub struct Stale; pub struct Stale;
/// Creates an empty file, refusing to truncate one that is already there. /// Creates an empty file, refusing to truncate one that is already there.
/// /// `set -C` is the shell's own noclobber, so an existing name fails with the
/// `set -C` is the shell's own noclobber, so an existing name fails with /// shell's own message rather than with a check that could race the redirection
/// the shell's own message rather than with a check that could race the /// it is guarding.
/// redirection it is guarding.
pub async fn create_file(transport: &Transport, path: &str) -> Result<()> { pub async fn create_file(transport: &Transport, path: &str) -> Result<()> {
let script = format!("{PATH_PRELUDE}set -C; : > \"$p\""); let script = format!("{PATH_PRELUDE}set -C; : > \"$p\"");
transport transport
@@ -354,9 +329,9 @@ pub async fn create_file(transport: &Transport, path: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Creates a directory. Plain `mkdir`, not `-p`, for the same reason /// Creates a directory. Plain `mkdir`, not `-p`, for the reason
/// [`create_file`] sets noclobber: a name that exists is something the /// [`create_file`] sets noclobber: a name that exists is something the person
/// person typing it should be told about. /// typing it should be told about.
pub async fn create_dir(transport: &Transport, path: &str) -> Result<()> { pub async fn create_dir(transport: &Transport, path: &str) -> Result<()> {
let script = format!("{PATH_PRELUDE}mkdir -- \"$p\""); let script = format!("{PATH_PRELUDE}mkdir -- \"$p\"");
transport transport
@@ -376,8 +351,8 @@ fn text(captured: crate::session::transport::Captured) -> Result<String> {
mod tests { mod tests {
use super::*; use super::*;
/// The names a listing has to survive. All four are legal, and each /// The names a listing has to survive. All four are legal, and each one
/// one broke a listing somewhere before it was separated with `\0`. /// broke a listing somewhere before it was separated with `\0`.
#[test] #[test]
fn a_listing_survives_the_names_a_filesystem_allows() { fn a_listing_survives_the_names_a_filesystem_allows() {
let record = |own: &str, target: &str, size: &str, time: &str, name: &str| { let record = |own: &str, target: &str, size: &str, time: &str, name: &str| {
@@ -406,8 +381,8 @@ mod tests {
assert_eq!(entries[0].modified, 1756900000); assert_eq!(entries[0].modified, 1756900000);
assert_eq!(entries[2].kind, EntryKind::Directory); assert_eq!(entries[2].kind, EntryKind::Directory);
assert_eq!(entries[2].size, 4096); assert_eq!(entries[2].size, 4096);
// The kind is the target's, so a link to a directory navigates -- // The kind is the target's, so a link to a directory navigates -- and
// and one whose target is gone is neither a file nor a directory. // one whose target is gone is neither a file nor a directory.
assert!(entries[3].link); assert!(entries[3].link);
assert_eq!(entries[3].kind, EntryKind::Directory); assert_eq!(entries[3].kind, EntryKind::Directory);
assert_eq!(entries[4].kind, EntryKind::Other); assert_eq!(entries[4].kind, EntryKind::Other);
@@ -431,9 +406,9 @@ mod tests {
assert!(refused.contains("start it with / or ~"), "{refused}"); assert!(refused.contains("start it with / or ~"), "{refused}");
} }
/// The scripts, against a real tree, through the transport that runs /// The scripts, against a real tree, through the transport that runs them
/// them here -- which is cheap, because `sh` is wherever `cargo test` /// here -- cheap, because `sh` is wherever `cargo test` is. The remote
/// is. The remote transport runs the identical text. /// transport runs the identical text.
fn tree() -> tempfile::TempDir { fn tree() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hello.txt"), "one\ntwo\n").unwrap(); std::fs::write(dir.path().join("hello.txt"), "one\ntwo\n").unwrap();
@@ -454,8 +429,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
// `pwd -P`, so a temp directory reached through a symlinked /tmp // `pwd -P`, so a temp directory reached through a symlinked /tmp
// answers with what it really is -- which is the path the phone // answers with what it really is.
// then navigates on.
assert!(listing.path.starts_with('/'), "{}", listing.path); assert!(listing.path.starts_with('/'), "{}", listing.path);
let mut names: Vec<&str> = listing.entries.iter().map(|e| e.name.as_str()).collect(); let mut names: Vec<&str> = listing.entries.iter().map(|e| e.name.as_str()).collect();
names.sort_unstable(); names.sort_unstable();
@@ -498,8 +472,8 @@ mod tests {
std::fs::write(dir.path().join("big"), vec![b'x'; FILE_LIMIT as usize + 1]).unwrap(); std::fs::write(dir.path().join("big"), vec![b'x'; FILE_LIMIT as usize + 1]).unwrap();
assert!(matches!(read_at("big").await, FileRead::TooBig { .. })); assert!(matches!(read_at("big").await, FileRead::TooBig { .. }));
// Empty is text with nothing in it, which is what it is -- not a // Empty is text with nothing in it -- not a fourth state, and not the
// fourth state and not the same as any of the three above. // same as any of the three above.
std::fs::write(dir.path().join("empty"), "").unwrap(); std::fs::write(dir.path().join("empty"), "").unwrap();
assert!(matches!( assert!(matches!(
read_at("empty").await, read_at("empty").await,
@@ -592,9 +566,9 @@ mod tests {
); );
} }
/// A path that tries to close the quote and start a command of its /// A path that tries to close the quote and start a command of its own. It
/// own. It is an argument rather than syntax, so it stays one absurd /// is an argument rather than syntax, so it stays one absurd filename -- the
/// filename -- the same property `ssh.rs` tests for the remote side. /// same property `ssh.rs` tests for the remote side.
#[tokio::test] #[tokio::test]
async fn a_path_full_of_shell_crosses_as_data() { async fn a_path_full_of_shell_crosses_as_data() {
let dir = tree(); let dir = tree();
@@ -614,8 +588,8 @@ mod tests {
); );
} }
/// The tilde is the one character the prelude gives a meaning, and it /// The tilde is the one character the prelude gives a meaning, and it is the
/// is the *machine's* home -- here, this one. /// *machine's* home -- here, this one.
#[tokio::test] #[tokio::test]
async fn a_leading_tilde_means_the_machine_s_own_home() { async fn a_leading_tilde_means_the_machine_s_own_home() {
let Some(home) = std::env::home_dir() else { let Some(home) = std::env::home_dir() else {
+71 -94
View File
@@ -1,14 +1,12 @@
//! A phone interface to AI coding sessions -- the backend. See PLAN.md for //! A phone interface to AI coding sessions -- the backend. See PLAN.md for the
//! the whole picture; this is the entry point: config + session registry, //! whole picture; this is the entry point: config + session registry, token
//! token bootstrap, and the one TLS listener. //! bootstrap, and the one TLS listener.
//! //!
//! The listener binds the WireGuard interface's address only, and fails //! The listener binds the WireGuard interface's address only, and fails closed
//! closed -- if `wg0` is down the server refuses to start rather than //! -- if `wg0` is down the server refuses to start rather than falling back to
//! falling back to `0.0.0.0`, because this API *is* remote code execution //! `0.0.0.0`, because this API *is* remote code execution and the tunnel is
//! and the tunnel is what keeps its pre-auth surface (TLS handshake, HTTP //! what keeps its pre-auth surface off the open internet. `--bind` overrides
//! parsing, auth middleware) off the open internet. `--bind` overrides //! explicitly for development; a deliberate, logged choice, never a fallback.
//! explicitly for development; that is a deliberate, logged choice, never a
//! fallback.
//! //!
//! There is no plaintext listener at all, so the bearer token can't travel //! There is no plaintext listener at all, so the bearer token can't travel
//! unencrypted by misconfiguration -- even inside the tunnel. //! unencrypted by misconfiguration -- even inside the tunnel.
@@ -50,10 +48,9 @@ struct Args {
#[arg(long, default_value_t = DEFAULT_PORT)] #[arg(long, default_value_t = DEFAULT_PORT)]
port: u16, port: u16,
/// Address to bind instead of the wg0 interface's -- a development /// Address to bind instead of the wg0 interface's -- a development override
/// override (e.g. 127.0.0.1 for curl, or a LAN address for a phone /// (127.0.0.1 for curl, or a LAN address for a phone before the tunnel
/// before the tunnel exists). Production runs without it and fails /// exists). Production runs without it and fails closed when wg0 is absent.
/// closed when wg0 is absent.
#[arg(long)] #[arg(long)]
bind: Option<IpAddr>, bind: Option<IpAddr>,
@@ -83,44 +80,34 @@ struct Args {
rotate_token: bool, rotate_token: bool,
/// Enroll one more device without touching the running server: mint a /// Enroll one more device without touching the running server: mint a
/// token, print its enrollment link (one line, stdout, nothing else) /// token, print its enrollment link (one line, stdout, nothing else) and
/// and exit. The server adopts the token the first time that device /// exit. The server adopts the token the first time that device uses it.
/// uses it. For a tool -- Dev Updater -- that opens the link on the /// For a tool that opens the link on the phone, where a QR printed here
/// phone, where a QR printed here cannot be scanned. /// cannot be scanned.
#[arg(long)] #[arg(long)]
enroll_link: bool, enroll_link: bool,
/// Hold every response back by this many milliseconds. /// Hold every response back by this many milliseconds.
/// ///
/// A development aid, and a specific one: over the tunnel a phone's /// A development aid, and a specific one: over the tunnel a phone's requests
/// requests take tens to hundreds of milliseconds, and several faults /// take tens to hundreds of milliseconds, and several faults live entirely
/// live entirely in what the app does *while* one is outstanding -- /// in what the app does *while* one is outstanding. On a loopback server
/// a page of history landing mid-fling, a screen drawn before its /// those windows close before anything can be observed and the bug looks
/// first answer arrives. On a loopback server every response is back /// like it is not there.
/// within a millisecond or two, so those windows close before
/// anything can be observed and the bug looks like it is not there.
/// This reopens them on demand rather than by unplugging something.
#[arg(long, default_value_t = 0, value_name = "MS")] #[arg(long, default_value_t = 0, value_name = "MS")]
delay: u64, delay: u64,
/// Mark every session spawned here as throwaway: its process is /// Mark every session spawned here as throwaway: its process is stopped
/// stopped when this server exits, instead of being left running for /// when this server exits, instead of being left running for the next start
/// the next start to adopt. On by default in a debug build. /// to adopt. On by default in a debug build.
/// ///
/// Sessions outlive the backend on purpose, which is right for the /// Sessions outlive the backend on purpose, which is right for the ones
/// ones somebody is using and wrong for the ones a test made: a /// somebody is using and wrong for the ones a test made -- twelve of those
/// session spawned to check something leaves a `claude` behind that /// accumulated on this machine in a day, each holding a conversation open.
/// every later server adopts, and they accumulate silently -- twelve
/// of them on this machine in a day, each holding a conversation open.
/// So a development build cleans up after itself unless told not to
/// (`--throwaway-sessions=false`), and a release build never does
/// unless asked.
/// ///
/// The flag decides only what *new* sessions are marked as. What /// The flag decides only what *new* sessions are marked as. What happens on
/// happens on the way out is decided by the mark, which is written /// the way out is decided by the mark, which outlives the server that made
/// into the session and outlives the server that made it -- so /// it.
/// sessions spawned without it keep running, whichever server is up
/// when one exits.
#[arg( #[arg(
long, long,
default_value_t = cfg!(debug_assertions), default_value_t = cfg!(debug_assertions),
@@ -134,18 +121,16 @@ struct Args {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Both rustls crypto providers are in the dependency graph (ureq // Both rustls crypto providers are in the dependency graph (ureq brings
// brings ring, axum-server brings aws-lc-rs), so rustls refuses to // ring, axum-server brings aws-lc-rs), so rustls refuses to pick one itself.
// pick one itself; choose before anything touches TLS.
rustls::crypto::aws_lc_rs::default_provider() rustls::crypto::aws_lc_rs::default_provider()
.install_default() .install_default()
.expect("no other TLS crypto provider is installed before main"); .expect("no other TLS crypto provider is installed before main");
// `info` unless RUST_LOG says otherwise. Written as a *fallback* rather than as the filter, // `info` unless RUST_LOG says otherwise. Written as a *fallback* rather than
// because `with_env_filter("info")` is a fixed directive that never reads the environment -- // as the filter, because `with_env_filter("info")` is a fixed directive that
// so the per-request diagnostics that AGENTS.md tells you to turn on with // never reads the environment -- so `RUST_LOG=ai_server=debug` printed
// `RUST_LOG=ai_server=debug` printed nothing, and the switch looked like the code it was // nothing, and the switch looked like the code it was meant to instrument.
// meant to instrument being wrong.
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
@@ -157,11 +142,10 @@ async fn main() -> Result<()> {
let config_path = args let config_path = args
.config .config
.unwrap_or_else(|| config_home("ai-app").join("config.ron")); .unwrap_or_else(|| config_home("ai-app").join("config.ron"));
// Before the manager exists, on purpose: constructing it and seeding // Before the manager exists, on purpose: constructing it and seeding setups
// setups touches sessions and subprocesses this invocation has no // touches sessions and subprocesses this invocation has no business with
// business with while another instance is serving. Only the hash // while another instance is serving. Only the hash reaches disk, in the
// reaches disk, in the spool `auth.rs` reads; the link itself goes to // spool `auth.rs` reads; the link goes to stdout alone.
// stdout alone, because the caller opens whatever this prints.
if args.enroll_link { if args.enroll_link {
let bind_ip = match args.bind { let bind_ip = match args.bind {
Some(ip) => ip, Some(ip) => ip,
@@ -182,9 +166,9 @@ async fn main() -> Result<()> {
let data_dir = args let data_dir = args
.data_dir .data_dir
.unwrap_or_else(|| data_home("ai-app").join("sessions")); .unwrap_or_else(|| data_home("ai-app").join("sessions"));
// Beside the session data rather than under it: models outlive every // Beside the session data rather than under it: models outlive every session
// session and are shared by all of them, so deleting a session must // and are shared by all of them, so deleting a session must never take a
// never take a multi-gigabyte download with it. // multi-gigabyte download with it.
let models_dir = args let models_dir = args
.models_dir .models_dir
.unwrap_or_else(|| data_home("ai-app").join("models")); .unwrap_or_else(|| data_home("ai-app").join("models"));
@@ -200,9 +184,9 @@ async fn main() -> Result<()> {
server exits rather than left running (--throwaway-sessions=false to keep them)" server exits rather than left running (--throwaway-sessions=false to keep them)"
); );
} }
// After construction rather than inside it: seeding asks this machine // After construction rather than inside it: seeding asks this machine what
// what it has, which is I/O, and a constructor that quietly runs a // it has, and a constructor that quietly runs a subprocess is a surprise to
// subprocess is a surprise to every caller including the tests. // every caller including the tests.
manager.seed_setup().await?; manager.seed_setup().await?;
tracing::info!("config: {}", config_path.display()); tracing::info!("config: {}", config_path.display());
@@ -210,9 +194,9 @@ async fn main() -> Result<()> {
for setup in manager.setups() { for setup in manager.setups() {
match &setup.ssh { match &setup.ssh {
Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address), Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address),
// No parenthetical naming the local machine: the default // No parenthetical naming the local machine: the default setup is
// setup is *called* "this machine", and the line read // *called* "this machine", and the line read "setup this machine
// "setup this machine (this machine)". // (this machine)".
None => tracing::info!(" setup \"{}\" runs here", setup.name), None => tracing::info!(" setup \"{}\" runs here", setup.name),
} }
for provider in &setup.providers { for provider in &setup.providers {
@@ -228,10 +212,9 @@ async fn main() -> Result<()> {
); );
} }
// Before the interface check below, deliberately: the certificates are // Before the interface check below, deliberately: the certificates are also
// also what the phone app embeds at build time, so they need to be // what the phone app embeds at build time, so they need to be obtainable on
// obtainable on a machine whose tunnel isn't up yet. The leaf is // a machine whose tunnel isn't up yet. The leaf is reissued on every start.
// reissued on every start, so once wg0 exists the next start covers it.
let certs_dir = args let certs_dir = args
.certs .certs
.unwrap_or_else(|| config_home("ai-app").join("certs")); .unwrap_or_else(|| config_home("ai-app").join("certs"));
@@ -256,9 +239,8 @@ async fn main() -> Result<()> {
None => netif::wg_address("ai-server")?, None => netif::wg_address("ai-server")?,
}; };
// Token bootstrap: first run generates one; --rotate-token replaces // Token bootstrap: first run generates one; --rotate-token replaces whatever
// whatever exists. Either way the plaintext appears exactly once, in // exists. Either way the plaintext appears exactly once, in the QR.
// the QR printed here.
if args.rotate_token || manager.tokens().is_empty() { if args.rotate_token || manager.tokens().is_empty() {
let rotating = args.rotate_token && !manager.tokens().is_empty(); let rotating = args.rotate_token && !manager.tokens().is_empty();
let token = enroll::generate_token(); let token = enroll::generate_token();
@@ -279,18 +261,15 @@ async fn main() -> Result<()> {
.await .await
.context("failed to load TLS cert/key")?; .context("failed to load TLS cert/key")?;
// No providers listed here any more: which machines can be asked, and // No providers listed here any more: which machines can be asked, and about
// about what, comes from the setups at the moment the screen is opened // what, comes from the setups at the moment the screen is opened -- so a
// -- so a machine added from the phone reports its limits without a // machine added from the phone reports its limits without a restart.
// restart, and the backend's own account stops standing in for every // The fixture is the manager's, because that is where the `/usage` command
// machine's. // that sets it is typed; the monitor is what serves it.
// The fixture is the manager's, because that is where the `/usage`
// command that sets it is typed; the monitor is what serves it.
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture())); let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture()));
// The bearer-token middleware wraps the entire router -- routes and // The bearer-token middleware wraps the entire router -- routes and fallback
// fallback alike -- here and only here, so a new route can't forget // alike -- here and only here, so a new route can't forget auth.
// auth. Zero unauthenticated endpoints.
let app = routes::router(Arc::clone(&manager)) let app = routes::router(Arc::clone(&manager))
.merge(routes::usage_router(monitor, Arc::clone(&manager))) .merge(routes::usage_router(monitor, Arc::clone(&manager)))
.merge(routes::models_router(Arc::clone(&models))) .merge(routes::models_router(Arc::clone(&models)))
@@ -299,9 +278,9 @@ async fn main() -> Result<()> {
auth::require_token, auth::require_token,
)); ));
// Outside the auth layer, so an unauthenticated request is refused at // Outside the auth layer, so an unauthenticated request is refused at the
// the speed it always was: this is here to slow the app down, not to // speed it always was: this is here to slow the app down, not to widen the
// widen the window on anything guessing at tokens. // window on anything guessing at tokens.
let app = match args.delay { let app = match args.delay {
0 => app, 0 => app,
ms => { ms => {
@@ -318,13 +297,11 @@ async fn main() -> Result<()> {
let addr = SocketAddr::new(bind_ip, args.port); let addr = SocketAddr::new(bind_ip, args.port);
tracing::info!("serving https://{addr}"); tracing::info!("serving https://{addr}");
// Let go of the sessions on the way out rather than stopping them: // Let go of the sessions on the way out rather than stopping them: their
// their processes are meant to outlive this one, so restarting the // processes are meant to outlive this one. Each is recorded in its session
// backend does not end a turn somebody is waiting on. Each is recorded // directory and adopted again on the way back up. The exception is the
// in its session directory and adopted again on the way back up (see // sessions marked throwaway, which are stopped first. Both signals, because
// `session::process`). The exception is the sessions marked throwaway, // systemd and OpenRC send TERM while a terminal sends INT.
// which are stopped first -- see `--throwaway-sessions`. Both signals,
// because systemd and OpenRC send TERM while a terminal sends INT.
let serving = axum_server::bind_rustls(addr, tls_config) let serving = axum_server::bind_rustls(addr, tls_config)
.serve(app.into_make_service_with_connect_info::<SocketAddr>()); .serve(app.into_make_service_with_connect_info::<SocketAddr>());
let mut terminate = signal(SignalKind::terminate()).context("listening for SIGTERM")?; let mut terminate = signal(SignalKind::terminate()).context("listening for SIGTERM")?;
@@ -333,9 +310,9 @@ async fn main() -> Result<()> {
_ = terminate.recv() => tracing::info!("SIGTERM -- letting go of sessions"), _ = terminate.recv() => tracing::info!("SIGTERM -- letting go of sessions"),
_ = tokio::signal::ctrl_c() => tracing::info!("interrupted -- letting go of sessions"), _ = tokio::signal::ctrl_c() => tracing::info!("interrupted -- letting go of sessions"),
} }
// Stopped before the rest are let go of, and on every way out of the // Stopped before the rest are let go of, and on every way out of the select
// select above: a throwaway session is one nobody meant to keep, and // above: a throwaway session is one nobody meant to keep, and the whole point
// the whole point is that nothing has to remember to clean it up. // is that nothing has to remember to clean it up.
manager.stop_throwaway_sessions(); manager.stop_throwaway_sessions();
manager.detach_all(); manager.detach_all();
+84 -118
View File
@@ -1,28 +1,22 @@
//! GGUF models on this machine, and the downloads that produce them. //! GGUF models on this machine, and the downloads that produce them.
//! //!
//! The registry pattern again (see `session`): one owner, one lock, so what //! The registry pattern again: one owner, one lock, so what is on disk and what
//! is on disk and what this server believes cannot come apart. //! this server believes cannot come apart. Three things shape the design, all
//! //! consequences of a model file being gigabytes rather than kilobytes:
//! Three things shape the design, all of them consequences of a model file
//! being gigabytes rather than kilobytes:
//! //!
//! **A download belongs to the model, not to whoever asked for it.** It is //! **A download belongs to the model, not to whoever asked for it.** It is
//! keyed by the model it produces and lives here, so any device can watch //! keyed by the model it produces and lives here, so any device can watch it --
//! it -- including one that did not start it, and one that opened the app //! including one that did not start it. State in a per-connection channel would
//! after it finished. State in a per-connection channel would not survive //! not survive the phone locking its screen, which for an hour-long download is
//! the phone locking its screen, which for an hour-long download is the //! the normal case.
//! normal case rather than an edge one.
//! //!
//! **Every run has an id, and its outcome outlives it.** Without those, //! **Every run has an id, and its outcome outlives it.** Without those, "not
//! "not downloading" is three different answers at once -- it finished, //! downloading" is three answers at once -- it finished, it never started, or a
//! it never started, or a different run finished while you were away -- //! different run finished while you were away.
//! and over an hour that ambiguity is certain to be hit. A device compares
//! the run it was watching against the run reported now.
//! //!
//! **Progress is measured, never estimated.** `total` is whatever //! **Progress is measured, never estimated.** `total` is whatever
//! `Content-Length` said and nothing else; when the server does not send //! `Content-Length` said and nothing else; when the server does not send one it
//! one it stays `None` and the phone shows that it does not know, rather //! stays `None` and the phone shows that it does not know.
//! than a bar drawn from how long the last download took.
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom, Write}; use std::io::{Read, Seek, SeekFrom, Write};
@@ -42,35 +36,33 @@ use crate::session::transport::{Launch, Transport};
const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION")); const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION"));
/// Read size per loop iteration. Big enough that the syscall overhead is /// Read size per loop iteration. Big enough that the syscall overhead is
/// nothing against a multi-gigabyte file, small enough that a cancel is /// nothing against a multi-gigabyte file, small enough that a cancel is noticed
/// noticed promptly -- the flag is only checked between chunks. /// promptly -- the flag is only checked between chunks.
const CHUNK: usize = 256 * 1024; const CHUNK: usize = 256 * 1024;
/// A model file sitting on this machine, ready to run. /// A model file sitting on this machine, ready to run.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct LocalModel { pub struct LocalModel {
/// `owner/repo/file.gguf` -- the HuggingFace coordinates, which are /// `owner/repo/file.gguf` -- the HuggingFace coordinates, which are already
/// already unique, so nothing has to invent an id. /// unique, so nothing has to invent an id.
pub key: String, pub key: String,
pub repo: String, pub repo: String,
pub file: String, pub file: String,
pub bytes: u64, pub bytes: u64,
} }
/// What a run is doing, or did. /// What a run is doing, or did. Flat rather than a tagged enum carrying its
/// /// message, because the phone switches on this and a string it can compare is
/// Flat rather than a tagged enum carrying its message, because the phone /// easier to render than a variant it has to destructure.
/// switches on this and a string it can compare is easier to render than a
/// variant it has to destructure.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum DownloadState { pub enum DownloadState {
Running, Running,
/// Reading the finished file back to check it against the hash /// Reading the finished file back to check it against the hash HuggingFace
/// HuggingFace publishes. Its own state because it takes real time on /// publishes. Its own state because it takes real time on a multi-gigabyte
/// a multi-gigabyte file and "still working" is the honest thing to /// file and "still working" is the honest thing to show, rather than a bar
/// show, rather than a bar sitting at 100% for half a minute. /// sitting at 100% for half a minute.
Verifying, Verifying,
Finished, Finished,
Failed, Failed,
@@ -82,20 +74,17 @@ pub enum DownloadState {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DownloadStatus { pub struct DownloadStatus {
pub key: String, pub key: String,
/// Distinguishes this run from any earlier one for the same model. /// Distinguishes this run from any earlier one for the same model, so a
/// A device that was watching run 3 can tell that what it is looking /// device that was watching run 3 can tell it is now looking at run 4.
/// at now is run 4 rather than assuming its own run ended.
pub run: u64, pub run: u64,
pub repo: String, pub repo: String,
pub file: String, pub file: String,
pub state: DownloadState, pub state: DownloadState,
/// Bytes on disk, including any carried over from a resumed attempt.
pub done: u64, pub done: u64,
/// What `Content-Length` said, or absent when the server did not say. /// What `Content-Length` said, or absent when the server did not say.
/// Absent means "unknown", never "zero" -- see this module's doc. /// Absent means "unknown", never "zero".
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub total: Option<u64>, pub total: Option<u64>,
/// Present only when [`DownloadState::Failed`], and it is the reason.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>, pub error: Option<String>,
pub started: f64, pub started: f64,
@@ -154,9 +143,8 @@ impl Run {
/// Every model this machine has, and every download in flight or finished. /// Every model this machine has, and every download in flight or finished.
pub struct ModelStore { pub struct ModelStore {
dir: PathBuf, dir: PathBuf,
/// Keyed by model key: one run per model at a time, and the last run /// Keyed by model key: one run per model at a time, and the last run for a
/// for a model stays here after it ends so its outcome can still be /// model stays here after it ends so its outcome can still be read.
/// read. Bounded by how many distinct models have been asked for.
runs: Mutex<HashMap<String, Arc<Run>>>, runs: Mutex<HashMap<String, Arc<Run>>>,
next_run: AtomicU64, next_run: AtomicU64,
} }
@@ -173,11 +161,10 @@ impl ModelStore {
/// Where a model's file lives, refusing anything that would escape the /// Where a model's file lives, refusing anything that would escape the
/// models directory. /// models directory.
/// ///
/// The repo and file come from a phone, and this server runs as the /// The repo and file come from a phone, so they are treated as hostile:
/// user who started it, so they are treated as hostile: every /// every component must be an ordinary name. Rejecting rather than
/// component must be an ordinary name. Rejecting is deliberate rather /// sanitising, since a silently rewritten path would download the right
/// than sanitising, since a silently rewritten path would download the /// bytes to the wrong place.
/// right bytes to the wrong place.
fn path_for(&self, repo: &str, file: &str) -> Result<PathBuf> { fn path_for(&self, repo: &str, file: &str) -> Result<PathBuf> {
let mut path = self.dir.clone(); let mut path = self.dir.clone();
for part in repo.split('/').chain(file.split('/')) { for part in repo.split('/').chain(file.split('/')) {
@@ -193,11 +180,9 @@ impl ModelStore {
format!("{repo}/{file}") format!("{repo}/{file}")
} }
/// Every `.gguf` found under the models directory, newest first. /// Every `.gguf` found under the models directory, newest first. Read from
/// /// disk on each call rather than cached: a file deleted by hand should stop
/// Read from disk on each call rather than cached: a file deleted by /// being offered.
/// hand should stop being offered, and the directory is small enough
/// that walking it costs nothing next to loading a model.
pub fn list(&self) -> Vec<LocalModel> { pub fn list(&self) -> Vec<LocalModel> {
let mut found = Vec::new(); let mut found = Vec::new();
collect(&self.dir, &self.dir, &mut found); collect(&self.dir, &self.dir, &mut found);
@@ -213,12 +198,10 @@ impl ModelStore {
all all
} }
/// Starts fetching `file` from `repo`, or returns the run already /// Starts fetching `file` from `repo`, or returns the run already doing so.
/// doing so. /// Idempotent on purpose: a phone that lost its connection will press the
/// /// button again, and that must join the existing run rather than start a
/// Idempotent on purpose: a phone that lost its connection and came /// second one writing the same file.
/// back will press the button again, and that must join the existing
/// run rather than start a second one writing the same file.
pub fn start(self: &Arc<Self>, repo: &str, file: &str) -> Result<DownloadStatus> { pub fn start(self: &Arc<Self>, repo: &str, file: &str) -> Result<DownloadStatus> {
let key = Self::key_for(repo, file); let key = Self::key_for(repo, file);
let target = self.path_for(repo, file)?; let target = self.path_for(repo, file)?;
@@ -253,8 +236,8 @@ impl ModelStore {
drop(runs); drop(runs);
// A dedicated thread rather than the blocking pool: this holds its // A dedicated thread rather than the blocking pool: this holds its
// thread for as long as the download takes, which is minutes to // thread for as long as the download takes, which is minutes to hours,
// hours, and the pool exists for short work. // and the pool exists for short work.
let store = Arc::clone(self); let store = Arc::clone(self);
std::thread::spawn(move || { std::thread::spawn(move || {
let outcome = store.fetch(&run, &target); let outcome = store.fetch(&run, &target);
@@ -277,8 +260,8 @@ impl ModelStore {
Ok(status) Ok(status)
} }
/// Asks a running download to stop. The partial file stays, so /// Asks a running download to stop. The partial file stays, so starting
/// starting again resumes rather than refetching. /// again resumes rather than refetching.
pub fn cancel(&self, key: &str) -> Result<DownloadStatus> { pub fn cancel(&self, key: &str) -> Result<DownloadStatus> {
let runs = self.runs.lock().unwrap(); let runs = self.runs.lock().unwrap();
let Some(run) = runs.get(key) else { let Some(run) = runs.get(key) else {
@@ -305,7 +288,6 @@ impl ModelStore {
Ok(()) Ok(())
} }
/// The download loop: resume where a partial left off, write, report.
fn fetch(&self, run: &Run, target: &Path) -> Result<()> { fn fetch(&self, run: &Run, target: &Path) -> Result<()> {
let partial = partial_of(target); let partial = partial_of(target);
let identity = identity_of(target); let identity = identity_of(target);
@@ -313,9 +295,8 @@ impl ModelStore {
private::create_dir(parent)?; private::create_dir(parent)?;
} }
// What we have, and what it was part of. A partial with no // What we have, and what it was part of. A partial with no recorded
// recorded identity is not resumable -- it could be a fragment of // identity is not resumable -- it could be a fragment of any revision.
// any revision -- so it is refetched rather than guessed at.
let known = std::fs::read_to_string(&identity) let known = std::fs::read_to_string(&identity)
.ok() .ok()
.map(|s| s.trim().to_string()); .map(|s| s.trim().to_string());
@@ -333,13 +314,11 @@ impl ModelStore {
let mut etag = etag_of(&response); let mut etag = etag_of(&response);
// HuggingFace's CDN ignores `If-Range` -- probed 2026-08-28: a // HuggingFace's CDN ignores `If-Range` -- probed 2026-08-28: a
// deliberately stale validator still answers 206 with the ranged // deliberately stale validator still answers 206 with the ranged bytes.
// bytes rather than 200 with the whole file. So the header cannot // So the header cannot be relied on to restart us, and the check is done
// be relied on to restart us, and the check is done here instead: // here instead: if what arrived is not the revision our partial belongs
// if what arrived is not the revision our partial belongs to, // to, resuming would splice two files into something of exactly the
// resuming would splice two files into something of exactly the // right length and the wrong contents.
// right length and the wrong contents. Throw the partial away and
// ask again from zero.
if resumed && etag.is_some() && etag != known { if resumed && etag.is_some() && etag != known {
tracing::info!( tracing::info!(
"{} changed upstream since the partial was written -- starting again", "{} changed upstream since the partial was written -- starting again",
@@ -351,11 +330,9 @@ impl ModelStore {
etag = etag_of(&response); etag = etag_of(&response);
} }
// On a 206, Content-Length is the length of the *range*, not of // On a 206, Content-Length is the length of the *range*, not of the file
// the file -- it answers a different question than the one a // -- taken at face value it would fill the bar at 72 MB of a 234 MB
// progress bar asks, and taken at face value it would fill the bar // model. The whole size is the last field of Content-Range, which has
// at 72 MB of a 234 MB model. The whole size is the last field of
// Content-Range (`bytes 162000000-234074815/234074816`), which has
// the further merit of not depending on where the range began. // the further merit of not depending on where the range began.
let total: Option<u64> = if resumed { let total: Option<u64> = if resumed {
response response
@@ -380,12 +357,10 @@ impl ModelStore {
p.total = total; p.total = total;
} }
// `truncate(false)` is the whole resume story: the file is opened // `truncate(false)` is the whole resume story: the file is opened to be
// to be seeked into and appended to, and truncating here would // seeked into and appended to, and truncating would throw away exactly
// throw away exactly the bytes the Range request just asked the // the bytes the Range request just asked the server not to send again.
// server not to send again. Stated rather than left to the // Stated rather than left to the default.
// default, because the default is what a reader would have to
// remember.
let mut file = std::fs::OpenOptions::new() let mut file = std::fs::OpenOptions::new()
.create(true) .create(true)
.write(true) .write(true)
@@ -399,9 +374,8 @@ impl ModelStore {
file.set_len(0) file.set_len(0)
.context("truncate a partial we cannot resume onto")?; .context("truncate a partial we cannot resume onto")?;
} }
// Written before the body, so an interrupted download leaves a // Written before the body, so an interrupted download leaves a partial
// partial that can still say which revision it belongs to. That is // that can still say which revision it belongs to.
// what makes it safe to keep one across a restart of this server.
if let Some(etag) = &etag { if let Some(etag) = &etag {
std::fs::write(&identity, etag).ok(); std::fs::write(&identity, etag).ok();
} }
@@ -427,12 +401,10 @@ impl ModelStore {
file.flush().context("flushing the model file")?; file.flush().context("flushing the model file")?;
drop(file); drop(file);
// Checked before the rename, so a file that fails never gets the // Checked before the rename, so a file that fails never gets the real
// real name and `list` never offers it. With the identity check // name and `list` never offers it. With the identity check above this
// above this should not fire; it is here because a download of // should not fire; it is here because a wrong model is the kind of
// this size has too many ways to go subtly wrong to take on // failure that surfaces as bad output rather than as an error.
// trust, and because a wrong model is the kind of failure that
// surfaces as bad output rather than as an error.
if let Some(expected) = published_sha256(&run.repo, &run.file) { if let Some(expected) = published_sha256(&run.repo, &run.file) {
run.progress.lock().unwrap().state = DownloadState::Verifying; run.progress.lock().unwrap().state = DownloadState::Verifying;
let actual = sha256_of(&partial)?; let actual = sha256_of(&partial)?;
@@ -448,8 +420,8 @@ impl ModelStore {
} }
} }
// Renamed only once complete, so a file at its real name is always // Renamed only once complete, so a file at its real name is always a
// a whole model -- `list` needs no other way to tell. // whole model -- `list` needs no other way to tell.
std::fs::rename(&partial, target) std::fs::rename(&partial, target)
.with_context(|| format!("finish {}", target.display()))?; .with_context(|| format!("finish {}", target.display()))?;
std::fs::remove_file(&identity).ok(); std::fs::remove_file(&identity).ok();
@@ -457,9 +429,8 @@ impl ModelStore {
} }
} }
/// The sha256 of a file, read in chunks -- these are gigabytes, and /// The sha256 of a file, read in chunks -- these are gigabytes, and reading one
/// reading one into memory to hash it would be the largest allocation this /// into memory to hash it would be the largest allocation this server makes.
/// server ever makes.
fn sha256_of(path: &Path) -> Result<String> { fn sha256_of(path: &Path) -> Result<String> {
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
let mut file = let mut file =
@@ -473,8 +444,8 @@ fn sha256_of(path: &Path) -> Result<String> {
} }
hasher.update(&buffer[..read]); hasher.update(&buffer[..read]);
} }
// Hex by hand, as wg_app_link::enroll::token_hash_hex also has to, // Hex by hand, as `wg_app_link::enroll::token_hash_hex` also has to, since
// since this sha2 version's output type does not implement LowerHex. // this sha2 version's output type does not implement LowerHex.
Ok(hasher Ok(hasher
.finalize() .finalize()
.iter() .iter()
@@ -489,9 +460,8 @@ fn request(url: &str, from: u64) -> Result<(ureq::http::Response<ureq::Body>, bo
get = get.header("Range", &format!("bytes={from}-")); get = get.header("Range", &format!("bytes={from}-"));
} }
let response = get.call().with_context(|| format!("GET {url}"))?; let response = get.call().with_context(|| format!("GET {url}"))?;
// Trust the status, not the request: a server that ignores Range // Trust the status, not the request: a server that ignores Range answers
// answers 200 with the whole file, and appending to that would // 200 with the whole file, and appending to that would corrupt it.
// corrupt it.
let resumed = response.status() == 206; let resumed = response.status() == 206;
Ok((response, resumed)) Ok((response, resumed))
} }
@@ -508,8 +478,8 @@ fn etag_of(response: &ureq::http::Response<ureq::Body>) -> Option<String> {
) )
} }
/// `x.gguf` -> `x.gguf.part.etag`, holding which revision the partial /// `x.gguf` -> `x.gguf.part.etag`, holding which revision the partial beside it
/// beside it is a piece of. /// is a piece of.
fn identity_of(target: &Path) -> PathBuf { fn identity_of(target: &Path) -> PathBuf {
let mut name = target.as_os_str().to_os_string(); let mut name = target.as_os_str().to_os_string();
name.push(".part.etag"); name.push(".part.etag");
@@ -646,18 +616,17 @@ pub struct RemoteRepo {
pub struct RemoteFile { pub struct RemoteFile {
pub path: String, pub path: String,
pub bytes: u64, pub bytes: u64,
/// Already on this machine, so the phone can say so rather than /// Already on this machine, so the phone can say so rather than offering to
/// offering to fetch it again. /// fetch it again.
pub have: bool, pub have: bool,
} }
/// Searches HuggingFace for GGUF repositories matching `query`. /// Searches HuggingFace for GGUF repositories matching `query`.
/// ///
/// Proxied through this server rather than called from the phone, for two /// Proxied through this server rather than called from the phone, for two
/// reasons that both matter: the app trusts exactly one certificate -- /// reasons that both matter: the app trusts exactly one certificate -- this
/// this server's -- and has no general internet trust to spend on /// server's -- and has no general internet trust to spend on huggingface.co,
/// huggingface.co, and the machine that has to do the downloading is this /// and the machine that has to do the downloading is this one.
/// one, so it is also the one whose view of what exists is relevant.
pub fn search(query: &str) -> Result<Vec<RemoteRepo>> { pub fn search(query: &str) -> Result<Vec<RemoteRepo>> {
let url = format!( let url = format!(
"https://huggingface.co/api/models?search={}&filter=gguf&limit=25&sort=downloads&direction=-1", "https://huggingface.co/api/models?search={}&filter=gguf&limit=25&sort=downloads&direction=-1",
@@ -685,11 +654,9 @@ pub fn search(query: &str) -> Result<Vec<RemoteRepo>> {
.collect()) .collect())
} }
/// The sha256 HuggingFace publishes for one file, if it publishes one. /// The sha256 HuggingFace publishes for one file, if it publishes one. It is
/// /// the LFS object id, which for these repositories is the sha256 of the content
/// It is the LFS object id, which for these repositories is the sha256 of /// -- so it is a free integrity check rather than a second source of truth.
/// the content -- so it is a free integrity check on a download rather
/// than something we would have to compute a second source of truth for.
fn published_sha256(repo: &str, file: &str) -> Option<String> { fn published_sha256(repo: &str, file: &str) -> Option<String> {
let url = format!("https://huggingface.co/api/models/{repo}/tree/main?expand=true"); let url = format!("https://huggingface.co/api/models/{repo}/tree/main?expand=true");
let body = get_json(&url).ok()?; let body = get_json(&url).ok()?;
@@ -738,10 +705,9 @@ fn get_json(url: &str) -> Result<serde_json::Value> {
serde_json::from_str(&text).with_context(|| format!("{url} did not return JSON")) serde_json::from_str(&text).with_context(|| format!("{url} did not return JSON"))
} }
/// Percent-encodes a query string. Deliberately minimal -- this escapes /// Percent-encodes a query string. Deliberately minimal -- this escapes what a
/// what a model search actually contains rather than implementing the /// model search actually contains rather than implementing the whole rule set,
/// whole rule set, and anything unexpected becomes `%XX` rather than /// and anything unexpected becomes `%XX` rather than being passed through.
/// being passed through.
fn urlencode(value: &str) -> String { fn urlencode(value: &str) -> String {
value value
.bytes() .bytes()
+284 -339
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+183 -243
View File
@@ -1,16 +1,13 @@
//! The stream-json dialect: CLI lines in, common [`Event`]s out. //! The stream-json dialect: CLI lines in, common [`Event`]s out.
//! //!
//! Split from the driver beside it because the two change for unrelated //! Split from the driver beside it because the two change for unrelated
//! reasons. This half moves when the CLI's wire format does -- a new //! reasons. This half moves when the CLI's wire format does, which is what the
//! message subtype, a field that changed shape -- and that is what the //! tests at the bottom pin by replaying recorded lines; the driver half moves
//! tests at the bottom pin, replaying recorded lines. The driver half //! when spawning, resuming or shutting down changes.
//! moves when spawning, resuming or shutting down changes, and never
//! reads a line itself.
//! //!
//! The one side effect here is saving images a tool result carries into //! The one side effect here is saving images a tool result carries into the
//! the session directory (they would bloat the transcript as base64); //! session directory; everything else is pure, which is what makes the mapping
//! everything else is pure, which is what makes the mapping testable //! testable without a process.
//! without a process.
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -21,17 +18,14 @@ use super::super::driver::{Event, QuestionOption, SessionStatus, context_tokens}
/// Whether this line is the CLI opening a fresh model call. /// Whether this line is the CLI opening a fresh model call.
/// ///
/// `message_start` begins one assistant message, and the CLI sends the /// `message_start` begins one assistant message, and the CLI sends the previous
/// previous call's tool results back before it opens the next -- so this /// call's tool results back before opening the next -- so this is the first
/// is the first moment at which anything written since the last one can /// moment at which anything written since the last one can have been read.
/// have been read. Nothing earlier will do: the text deltas and the /// Nothing earlier will do: the deltas and `tool_use` of a message *already in
/// `tool_use` block of a message *already in flight* keep arriving after /// flight* keep arriving after a steer is written, and none of them saw it.
/// a steer is written, and none of them saw it.
/// ///
/// Only present because the driver passes `--include-partial-messages`. /// Only present because the driver passes `--include-partial-messages`, which
/// Without it there are no `stream_event` lines at all and this is never /// is why the caller keeps a fallback that does not depend on it.
/// true, which is why the caller keeps a fallback that does not depend on
/// it.
pub(super) fn starts_a_model_call(message: &Value) -> bool { pub(super) fn starts_a_model_call(message: &Value) -> bool {
message.get("type").and_then(Value::as_str) == Some("stream_event") message.get("type").and_then(Value::as_str) == Some("stream_event")
&& message["event"].get("type").and_then(Value::as_str) == Some("message_start") && message["event"].get("type").and_then(Value::as_str) == Some("message_start")
@@ -46,71 +40,56 @@ pub(super) enum AnswerOutcome {
Unknown, Unknown,
} }
/// A setting a control request asked for, held until the CLI says /// A setting a control request asked for, held until the CLI says whether it
/// whether it took. /// took. The CLI answers `set_model` with a bare success -- no value -- so the
///
/// The CLI answers `set_model` with a bare success -- no value -- so the
/// only way to report what was accepted is to remember what was asked. /// only way to report what was accepted is to remember what was asked.
/// `set_permission_mode` does echo its mode back, and so does a /// `set_permission_mode` does echo its mode back.
/// `system/status` line a moment later; both are handled where they
/// arrive, and this covers the one that says nothing.
pub(super) enum Setting { pub(super) enum Setting {
Model(String), Model(String),
PermissionMode(String), PermissionMode(String),
} }
/// A `can_use_tool` request we've surfaced to the phone and not yet /// A `can_use_tool` request we've surfaced to the phone and not yet answered.
/// answered. For plain permissions there is one implicit question /// For plain permissions there is one implicit question (Allow/Deny); for
/// (Allow/Deny); for AskUserQuestion, one per entry in `questions`. /// AskUserQuestion, one per entry in `questions`.
struct PendingRequest { struct PendingRequest {
request_id: String, request_id: String,
input: Value, input: Value,
/// Question text per sub-question, in order -- the keys the answers /// Question text per sub-question, in order -- the keys the answers map
/// map uses. Empty for a plain permission request. /// uses. Empty for a plain permission request.
questions: Vec<String>, questions: Vec<String>,
answers: HashMap<String, String>, answers: HashMap<String, String>,
} }
/// Translation state: stream-json lines in, common events out. The one /// Translation state: stream-json lines in, common events out.
/// side effect is saving images a tool result carries into the session
/// dir (they'd bloat the transcript as base64); everything else is pure,
/// so the dialect mapping is unit-testable from recorded lines.
pub(super) struct Translator { pub(super) struct Translator {
pub(super) session_id: Option<String>, pub(super) session_id: Option<String>,
pending: HashMap<String, PendingRequest>, pending: HashMap<String, PendingRequest>,
/// Settings asked for and not yet answered, by request id. Its path /// Settings asked for and not yet answered, by request id. Its path out is
/// out is the response: every entry is removed when one arrives, /// the response: every entry is removed when one arrives, whether it
/// whether it succeeded or failed. /// succeeded or failed.
asked: HashMap<String, Setting>, asked: HashMap<String, Setting>,
/// Whether this side asked the turn to stop. /// Whether this side asked the turn to stop.
/// ///
/// The CLI reports an interrupted turn the same way it reports one that /// The CLI reports an interrupted turn the same way it reports one that
/// broke -- a `result` with `is_error` set -- so the line itself cannot /// broke -- a `result` with `is_error` set -- so the line cannot tell them
/// tell them apart, and a person who pressed Stop was shown "the turn /// apart, and somebody who pressed Stop was shown "the turn ended with an
/// ended with an error" for doing exactly what the button says. What /// error". What separates them is that *we* asked.
/// separates them is not in the message at all: it is that *we* asked.
/// So the driver says so before the request goes out, the same way it
/// does for a setting, and this remembers it until the result lands.
/// ///
/// Its path out is that result -- set by `expect_interrupt`, cleared by /// Its path out is that result, so a genuine failure in a later turn is
/// the next `result` whichever way it went, so a genuine failure in a /// still reported.
/// later turn is still reported.
interrupting: bool, interrupting: bool,
/// The input side of the newest assistant message, waiting for the /// The input side of the newest assistant message, waiting for the `result`
/// `result` that ends the turn to carry it out. /// that ends the turn to carry it out.
/// ///
/// Read from the assistant message rather than from the result's own /// Read from the assistant message rather than the result's own usage,
/// usage, which is the whole turn added up: measured on 2026-08-30 /// which is the whole turn added up: measured on 2026-08-30 against 2.1.237,
/// against CLI 2.1.237, a two-message turn reported /// a two-message turn reported `cache_read_input_tokens` of 40,211, being
/// `cache_read_input_tokens` of 40,211 in its result, being 14,259 and /// 14,259 and 25,952 -- the same conversation counted twice. The model held
/// 25,952 from the two messages -- the same conversation counted /// 26,131. A turn with ten tool calls would overstate it tenfold.
/// twice. The model never held 40,211; it held 26,131, which is the
/// last message's three input figures. A turn with ten tool calls
/// would overstate it tenfold.
/// ///
/// Its path out is that result, which takes it -- so a turn whose /// Its path out is that result, so a turn whose messages carried no usage
/// messages carried no usage reports none rather than repeating the /// reports none rather than repeating the previous turn's.
/// previous turn's.
context: Option<u64>, context: Option<u64>,
session_dir: PathBuf, session_dir: PathBuf,
} }
@@ -128,17 +107,15 @@ impl Translator {
} }
/// Remembers what a control request was for, so its answer can say so. /// Remembers what a control request was for, so its answer can say so.
/// /// Called before the request goes out: the reader thread is already running
/// Called before the request goes out, not after: the reader thread is /// and a fast CLI can answer before this side gets back to it.
/// already running and a fast CLI can answer before this side gets
/// back to it.
pub(super) fn expect_setting(&mut self, request_id: String, setting: Setting) { pub(super) fn expect_setting(&mut self, request_id: String, setting: Setting) {
self.asked.insert(request_id, setting); self.asked.insert(request_id, setting);
} }
/// Says that the turn about to end was stopped on purpose -- see /// Says that the turn about to end was stopped on purpose. Called before
/// [`Translator::interrupting`]. Called before the request goes out, /// the request goes out, for the reason [`Translator::expect_setting`]
/// for the reason [`Translator::expect_setting`] gives. /// gives.
pub(super) fn expect_interrupt(&mut self) { pub(super) fn expect_interrupt(&mut self) {
self.interrupting = true; self.interrupting = true;
} }
@@ -154,14 +131,11 @@ impl Translator {
} }
match message.get("type").and_then(Value::as_str) { match message.get("type").and_then(Value::as_str) {
Some("system") => self.translate_system(message), Some("system") => self.translate_system(message),
// The CLI's own announcement that `/clear` took effect, sent // The CLI's own announcement that `/clear` took effect, sent just
// just before the fresh `init` that carries the new // before the fresh `init` carrying the new session_id. Measured
// session_id. Measured against 2.1.237 rather than inferred: // against 2.1.237: this used to watch for the id being *replaced*,
// this used to watch for the id being *replaced*, which is the // which is the same event seen through a side effect. The
// same event seen through one of its side effects. Taking the // announcement lands before the new init rather than after it.
// announcement instead means the transcript's divider is the
// CLI saying "I did this", and it lands before the new init
// rather than after it.
Some("conversation_reset") => vec![Event::Cleared], Some("conversation_reset") => vec![Event::Cleared],
Some("stream_event") => self.translate_stream_event(&message["event"]), Some("stream_event") => self.translate_stream_event(&message["event"]),
Some("assistant") => self.translate_assistant(&message["message"]), Some("assistant") => self.translate_assistant(&message["message"]),
@@ -170,8 +144,8 @@ impl Translator {
Some("control_response") => { Some("control_response") => {
let response = &message["response"]; let response = &message["response"];
// Answered either way, so the request stops being pending // Answered either way, so the request stops being pending
// either way -- a rejected setting that stayed here would // either way -- a rejected setting that stayed here would be
// be applied by the next request that reused its id. // applied by the next request that reused its id.
let asked = response let asked = response
.get("request_id") .get("request_id")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -185,9 +159,9 @@ impl Translator {
message: format!("claude rejected a request: {error}"), message: format!("claude rejected a request: {error}"),
}]; }];
} }
// Success, so the setting this request asked for is now // Success, so the setting this request asked for is now the
// the session's, and this is the only place that says so: // session's, and this is the only place that says so: the
// the response carries no value of its own for a model. // response carries no value of its own for a model.
match asked { match asked {
Some(Setting::Model(model)) => vec![Event::Settings { Some(Setting::Model(model)) => vec![Event::Settings {
model: Some(model), model: Some(model),
@@ -195,11 +169,10 @@ impl Translator {
}], }],
Some(Setting::PermissionMode(mode)) => vec![Event::Settings { Some(Setting::PermissionMode(mode)) => vec![Event::Settings {
model: None, model: None,
// The CLI echoes this one, and its answer wins: // The CLI echoes this one, and its answer wins: `auto`
// `auto` and `manual` are names it accepts on the // and `manual` are names it accepts on the way in and
// way in and reports back under another name, so // reports back under another name, so repeating the
// repeating the request here would show a mode the // request would show a mode the session is not in.
// session is not in.
permission_mode: Some( permission_mode: Some(
response["response"]["mode"] response["response"]["mode"]
.as_str() .as_str()
@@ -223,28 +196,22 @@ impl Translator {
let mut events = Vec::new(); let mut events = Vec::new();
// A turn another agent started, which is only knowable here. // A turn another agent started, which is only knowable here.
// //
// Measured against CLI 2.1.237 (2026-08-31) by sending a // Measured against 2.1.237 (2026-08-31) by sending a real
// real cross-session message to a real stream-json session: // cross-session message to a real stream-json session: the CLI
// the CLI emits no `user` record for it, and nothing in the // emits no `user` record for it and nothing in the
// partial-message stream mentions it either. The whole of // partial-message stream mentions it. The whole of it arrives as
// it arrives as an `origin` object on the turn's `result`, // an `origin` object on the turn's `result`, in the same shape
// in the same shape the session file records -- so this is // the session file records -- so this is `import::peer_message`
// `import::peer_message` reading a different record. // reading a different record.
// //
// The cost is the position: the note lands after the reply // The cost is the position: the note lands after the reply it
// it caused rather than above it, because at no earlier // caused, because at no earlier point does the CLI say why the
// point in the turn does the CLI say why the turn started. // turn started. Taken deliberately over a second reader tailing
// Taken deliberately over the alternative, which is a // the CLI's own session file, which is two sources of truth for
// second reader tailing the CLI's own session file for the // one conversation and a poll per live session.
// one record stdout does not carry -- two sources of truth
// for one conversation, and a poll per live session. What
// it buys is the thing that was missing entirely: a session
// that starts working on something nobody on this phone
// asked for is otherwise unexplainable from the phone.
// //
// Only peer-caused turns carry it: measured over a real // Only peer-caused turns carry it: four ordinary results over a
// session's stdout, four ordinary results and no `origin` // real session's stdout had no `origin` between them.
// between them.
if let Some(peer) = crate::session::import::peer_message(message) { if let Some(peer) = crate::session::import::peer_message(message) {
events.push(peer); events.push(peer);
} }
@@ -278,37 +245,35 @@ impl Translator {
} }
} }
/// The CLI's own notices: which session this is, and what it is doing /// The CLI's own notices: which session this is, and what it is doing that
/// that is not a turn. /// is not a turn.
/// ///
/// Compaction is the whole of that second kind, and it is announced /// Compaction is the whole of that second kind, and it is announced rather
/// rather than inferred. Measured against CLI 2.1.237 (2026-08-29) by /// than inferred. Measured against 2.1.237 (2026-08-29) by driving a session
/// driving a session through `/compact`, one produces in order: /// through `/compact`, one produces in order:
/// ///
/// - `{"subtype":"status","status":"compacting"}` -- the start; /// - `{"subtype":"status","status":"compacting"}` -- the start;
/// - `{"subtype":"status","status":null,"compact_result":"success"}`, /// - `{"subtype":"status","status":null,"compact_result":"success"}`, or
/// or `"failed"` with a `compact_error` saying why -- the end; /// `"failed"` with a `compact_error` -- the end;
/// - a fresh `init` carrying the same `session_id`; /// - a fresh `init` carrying the same `session_id`;
/// - `{"subtype":"compact_boundary","compact_metadata":{…}}` with the /// - `{"subtype":"compact_boundary","compact_metadata":{…}}` with the token
/// token counts, and only when it succeeded; /// counts, and only when it succeeded;
/// - the turn's ordinary `result`, which is what returns it to idle. /// - the turn's ordinary `result`, which returns it to idle.
/// ///
/// The keys are snake_case here and camelCase in the CLI's own /// The keys are snake_case here and camelCase in the CLI's own transcript
/// transcript file, which records the same events. Reading the shape /// file, which records the same events -- so reading the shape off that
/// off that file -- the obvious place to find one, since it is on /// file, the obvious place to look, gets every field name wrong and
/// disk -- gets every field name wrong and silently yields a /// silently yields a compaction with no numbers in it.
/// compaction with no numbers in it.
fn translate_system(&mut self, message: &Value) -> Vec<Event> { fn translate_system(&mut self, message: &Value) -> Vec<Event> {
match message.get("subtype").and_then(Value::as_str) { match message.get("subtype").and_then(Value::as_str) {
Some("init") => { Some("init") => {
if let Some(id) = message.get("session_id").and_then(Value::as_str) { if let Some(id) = message.get("session_id").and_then(Value::as_str) {
self.session_id = Some(id.to_string()); self.session_id = Some(id.to_string());
} }
// The CLI's own account of what it is set to, and the only // The CLI's own account of what it is set to, and the only one
// one that resolves an alias: a session launched with // that resolves an alias: a session launched with
// `--model haiku` reports `claude-haiku-4-5-20251001` // `--model haiku` reports `claude-haiku-4-5-20251001` here. It
// here. It arrives again after a compaction, which is // arrives again after a compaction, which is free.
// free -- the manager drops a setting it is already in.
vec![Event::Settings { vec![Event::Settings {
model: message model: message
.get("model") .get("model")
@@ -336,22 +301,19 @@ impl Translator {
} }
} }
/// A `system/status` line: the CLI entering or leaving a state that is /// A `system/status` line: the CLI entering or leaving a state that is not
/// not a turn. /// a turn.
/// ///
/// A null `status` is the leaving edge, and it carries how the thing /// A null `status` is the leaving edge, and it carries how the thing went.
/// went. Whatever it was, the turn it happened inside is still going /// The turn it happened inside is still going when it ends -- the `result`
/// when it ends -- the `result` has not arrived yet -- so leaving says /// has not arrived -- so leaving says `Running`. A state this build does
/// `Running`, which is also the only place in this file that does. A /// not recognise is left alone rather than mapped onto the nearest one.
/// state this build does not recognise is left alone rather than
/// mapped onto the nearest one we do.
fn translate_status(&self, message: &Value) -> Vec<Event> { fn translate_status(&self, message: &Value) -> Vec<Event> {
// A mode change the CLI has made, announced a moment after it // A mode change the CLI has made, announced a moment after it answers
// answers the request that asked for it. Measured on 2.1.237: // the request. Measured on 2.1.237:
// `{"subtype":"status","status":null,"permissionMode":"plan"}`, // `{"subtype":"status","status":null,"permissionMode":"plan"}`, which
// which is a leaving edge carrying no compaction result -- so it // is a leaving edge carrying no compaction result -- so it is checked
// is checked before the compaction reading below, which would // before the compaction reading below.
// otherwise fall through to nothing.
if let Some(mode) = message.get("permissionMode").and_then(Value::as_str) { if let Some(mode) = message.get("permissionMode").and_then(Value::as_str) {
return vec![Event::Settings { return vec![Event::Settings {
model: None, model: None,
@@ -371,8 +333,8 @@ impl Translator {
}; };
let mut events = Vec::new(); let mut events = Vec::new();
if result != "success" { if result != "success" {
// The CLI's own sentence, because it is specific enough to act // The CLI's own sentence, because it is specific enough to act on:
// on: "Not enough messages to compact." is a complete answer. // "Not enough messages to compact." is a complete answer.
events.push(Event::Error { events.push(Event::Error {
message: match message.get("compact_error").and_then(Value::as_str) { message: match message.get("compact_error").and_then(Value::as_str) {
Some(why) => format!("compaction failed: {why}"), Some(why) => format!("compaction failed: {why}"),
@@ -386,9 +348,9 @@ impl Translator {
events events
} }
/// Raw API streaming: only text deltas become events. Consolidated /// Raw API streaming: only text deltas become events. Consolidated blocks
/// blocks arriving later re-carry the same text, so those are skipped /// arriving later re-carry the same text, so those are skipped in
/// in `translate_assistant` -- one source per fact. /// `translate_assistant` -- one source per fact.
fn translate_stream_event(&mut self, event: &Value) -> Vec<Event> { fn translate_stream_event(&mut self, event: &Value) -> Vec<Event> {
if event.get("type").and_then(Value::as_str) == Some("content_block_delta") if event.get("type").and_then(Value::as_str) == Some("content_block_delta")
&& let Some(delta) = event["delta"].get("text") && let Some(delta) = event["delta"].get("text")
@@ -448,9 +410,8 @@ impl Translator {
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("a tool"); .unwrap_or("a tool");
let input = request.get("input").cloned().unwrap_or(Value::Null); let input = request.get("input").cloned().unwrap_or(Value::Null);
// Measured, not matched: the request names the call it is about, so // Measured, not matched: the request names the call it is about, so the
// the phone never has to guess which tool row a permission belongs // phone never has to guess which tool row a permission belongs to.
// to by comparing inputs.
let about = request let about = request
.get("tool_use_id") .get("tool_use_id")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -471,11 +432,10 @@ impl Translator {
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("(question)") .unwrap_or("(question)")
.to_string(); .to_string();
// Everything the reader decides on, carried in the event. // Everything the reader decides on, carried in the event. The
// The alternative -- and what this was -- is the phone // alternative -- and what this was -- is the phone reaching into
// reaching into the tool call's input for the parts the // the tool call's input for the parts the event dropped, which
// event dropped, which puts this dialect's schema in the // puts this dialect's schema where no other dialect can reach it.
// app where no other dialect can reach it.
let options = question let options = question
.get("options") .get("options")
.and_then(Value::as_array) .and_then(Value::as_array)
@@ -499,12 +459,10 @@ impl Translator {
.and_then(Value::as_bool) .and_then(Value::as_bool)
.unwrap_or(false), .unwrap_or(false),
// The call that is asking, so all of this draws as one // The call that is asking, so all of this draws as one
// thing. It used to be `None` on the grounds that a // thing. It used to be `None` on the grounds that a question
// question the model asked is not permission for a // the model asked is not permission for a call -- true, and
// call -- true, and beside the point: the reader was // beside the point: the reader was shown the AskUserQuestion
// shown the AskUserQuestion call *and* its questions // call *and* its questions as two separate cards.
// as two separate cards for one event, and the call
// itself said nothing they could act on.
about: about.clone(), about: about.clone(),
}); });
questions.push(text); questions.push(text);
@@ -515,8 +473,8 @@ impl Translator {
events.push(Event::Question { events.push(Event::Question {
id: request_id.clone(), id: request_id.clone(),
prompt: format!("Allow {tool_name}?\n{summary}"), prompt: format!("Allow {tool_name}?\n{summary}"),
// No header: the question is about the call it names, and // No header: the question is about the call it names, and the
// the phone draws it on that call's own row. // phone draws it on that call's own row.
header: None, header: None,
options: vec![ options: vec![
QuestionOption::plain("Allow"), QuestionOption::plain("Allow"),
@@ -541,13 +499,13 @@ impl Translator {
events events
} }
/// Applies one answer from the phone. Question ids are the control /// Applies one answer from the phone. Question ids are the control request
/// request id, suffixed `#i` for AskUserQuestion sub-questions. /// id, suffixed `#i` for AskUserQuestion sub-questions.
pub(super) fn answer(&mut self, question_id: &str, answers: &[String]) -> AnswerOutcome { pub(super) fn answer(&mut self, question_id: &str, answers: &[String]) -> AnswerOutcome {
// Where this dialect's shape is put on: the CLI's `answers` map is // Where this dialect's shape is put on: the CLI's `answers` map is
// string-valued whatever the question, so several choices become // string-valued whatever the question, so several choices become one
// one line here rather than everything upstream pretending a // line here rather than everything upstream pretending a question can
// question can only ever have one answer. // only ever have one answer.
let answer = answers.join(", "); let answer = answers.join(", ");
let answer = answer.as_str(); let answer = answer.as_str();
let (request_id, sub) = match question_id.split_once('#') { let (request_id, sub) = match question_id.split_once('#') {
@@ -583,17 +541,15 @@ impl Translator {
})) }))
} }
/// `user` messages: tool results become ToolEnd, with any image parts /// `user` messages: tool results become ToolEnd, with any image parts saved
/// saved into the session dir and referenced by an Image event (the /// into the session dir and referenced by an Image event. Replayed and
/// phone fetches them from `/sessions/{id}/files/{ref}`). Replayed and
/// synthetic user text is skipped -- the manager already recorded the /// synthetic user text is skipped -- the manager already recorded the
/// user's side. /// user's side.
fn translate_user(&self, message: &Value) -> Vec<Event> { fn translate_user(&self, message: &Value) -> Vec<Event> {
// Only tool results are here. The CLI never echoes a person's own // Only tool results are here. The CLI never echoes a person's own
// message back on stdout -- measured, because the obvious way to // message back on stdout -- measured, because the obvious way to learn
// learn that a queued message had been taken was to watch for it // that a queued message had been taken was to watch for it coming back
// coming back -- so nothing in this function marks one as read. // -- so the driver reports that itself, at the line it writes.
// The driver reports that itself, at the line it writes.
let Some(content) = message["message"].get("content").and_then(Value::as_array) else { let Some(content) = message["message"].get("content").and_then(Value::as_array) else {
return Vec::new(); return Vec::new();
}; };
@@ -603,9 +559,8 @@ impl Translator {
continue; continue;
} }
let mut texts = Vec::new(); let mut texts = Vec::new();
// Held until the call's id is in hand a few lines below: an // Held until the call's id is in hand a few lines below: an image is
// image is drawn under the call that produced it, so it has to // drawn under the call that produced it, so it has to carry that id.
// carry that id rather than merely arrive next to it.
let mut images = Vec::new(); let mut images = Vec::new();
match block.get("content") { match block.get("content") {
Some(Value::String(text)) => texts.push(text.clone()), Some(Value::String(text)) => texts.push(text.clone()),
@@ -648,11 +603,9 @@ impl Translator {
} }
} }
/// A string field that is there and not empty, or `None`. /// A string field that is there and not empty, or `None`. The CLI omits these
/// /// rather than sending them empty, but a caller that sends `""` means the same
/// The CLI omits these rather than sending them empty, but a caller that /// thing and should not produce a description that draws as a blank line.
/// sends `""` means the same thing and should not produce a description
/// that draws as a blank line.
fn text_field(value: &Value, name: &str) -> Option<String> { fn text_field(value: &Value, name: &str) -> Option<String> {
value value
.get(name) .get(name)
@@ -663,11 +616,9 @@ fn text_field(value: &Value, name: &str) -> Option<String> {
/// Decodes one base64 image block into `files/` and returns its ref. /// Decodes one base64 image block into `files/` and returns its ref.
/// ///
/// A free function rather than a method because the import replay needs /// A free function rather than a method because the import replay needs exactly
/// exactly this too: a session's history carries the same image blocks as /// this too: a session's history carries the same image blocks as its live
/// its live output, and a reader who can see a screenshot while it happens /// output. Two copies would be two naming schemes for one directory.
/// should still see it after a restart. Two copies of this would be two
/// naming schemes for one directory.
pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option<String> { pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option<String> {
let source = part.get("source")?; let source = part.get("source")?;
let data = source.get("data")?.as_str()?; let data = source.get("data")?.as_str()?;
@@ -675,8 +626,8 @@ pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option
let bytes = base64::engine::general_purpose::STANDARD let bytes = base64::engine::general_purpose::STANDARD
.decode(data) .decode(data)
.ok()?; .ok()?;
// Screenshots are the overwhelming case, and they are PNG; an // Screenshots are the overwhelming case and they are PNG; an unrecognized
// unrecognized type is more likely a dialect change than a JPEG. // type is more likely a dialect change than a JPEG.
let extension = source let extension = source
.get("media_type") .get("media_type")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -726,8 +677,7 @@ mod tests {
); );
assert_eq!(translator.session_id.as_deref(), Some("5ecf21da-d53f")); assert_eq!(translator.session_id.as_deref(), Some("5ecf21da-d53f"));
// The resolved model, which is the point: a session launched with // The resolved model, which is the point: a session launched with
// `--model haiku` is reported by its full name here, and that is // `--model haiku` is reported by its full name here.
// the name the phone should be showing.
assert_eq!( assert_eq!(
events, events,
vec![Event::Settings { vec![Event::Settings {
@@ -749,8 +699,8 @@ mod tests {
Setting::PermissionMode("plan".to_string()), Setting::PermissionMode("plan".to_string()),
); );
// Success carries no model of its own -- measured on 2.1.237 -- // Success carries no model of its own -- measured on 2.1.237 -- so what
// so what was asked for is the only answer available. // was asked for is the only answer available.
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -765,9 +715,8 @@ mod tests {
}] }]
); );
// A mode the CLI answers with a value of its own is taken from // A mode the CLI answers with a value of its own is taken from that
// that value: `auto` on the way in is `default` coming back, and // value: `auto` on the way in is `default` coming back.
// the request is not the answer.
translator.expect_setting( translator.expect_setting(
"req-c".to_string(), "req-c".to_string(),
Setting::PermissionMode("auto".to_string()), Setting::PermissionMode("auto".to_string()),
@@ -801,8 +750,8 @@ mod tests {
}] }]
); );
// And neither request is still waiting: a second answer to either // And neither request is still waiting: a second answer to either id
// id reports nothing at all. // reports nothing at all.
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -815,8 +764,8 @@ mod tests {
#[test] #[test]
fn a_mode_the_cli_announces_is_taken_from_the_announcement() { fn a_mode_the_cli_announces_is_taken_from_the_announcement() {
// The line it sends just after answering `set_permission_mode`, // The line it sends just after answering `set_permission_mode`, which is
// which is also how a mode changed from the terminal arrives. // also how a mode changed from the terminal arrives.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf());
let events = translate_lines( let events = translate_lines(
@@ -916,8 +865,8 @@ mod tests {
panic!("expected a question, got {events:?}"); panic!("expected a question, got {events:?}");
}; };
assert_eq!(id, "req-1"); assert_eq!(id, "req-1");
// The call being asked about, so the phone draws the ask on that // The call being asked about, so the phone draws the ask on that tool's
// tool's row instead of as a second card repeating its input. // row instead of as a second card repeating its input.
assert_eq!(about.as_deref(), Some("toolu_03")); assert_eq!(about.as_deref(), Some("toolu_03"));
assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x")); assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x"));
assert_eq!(labels(options), ["Allow", "Deny"]); assert_eq!(labels(options), ["Allow", "Deny"]);
@@ -1013,10 +962,9 @@ mod tests {
#[test] #[test]
fn a_question_carries_what_it_takes_to_answer_it() { fn a_question_carries_what_it_takes_to_answer_it() {
// Descriptions and previews are what the reader decides on, and a // Descriptions and previews are what the reader decides on, and a
// multi-select is how many answers the question takes. All of it // multi-select is how many answers the question takes. All of it travels
// travels in the event: a phone that had to read this dialect's // in the event: a phone that had to read this dialect's tool input to
// tool input to find them would be the only place that knew how, // find them would be the only place that knew how.
// and no other provider could reach it.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf());
let events = translate_lines( let events = translate_lines(
@@ -1049,8 +997,8 @@ mod tests {
.contains("dev-updater") .contains("dev-updater")
); );
// Two choices, one answer: the joining is this dialect's shape, // Two choices, one answer: the joining is this dialect's shape, done
// done where it is spoken. The CLI's answers map holds strings. // where it is spoken. The CLI's answers map holds strings.
let AnswerOutcome::Respond(response) = translator.answer( let AnswerOutcome::Respond(response) = translator.answer(
"req-9#0", "req-9#0",
&["Tool calls".to_string(), "Peer messages".to_string()], &["Tool calls".to_string(), "Peer messages".to_string()],
@@ -1078,8 +1026,8 @@ mod tests {
panic!("expected an image event, got {events:?}"); panic!("expected an image event, got {events:?}");
}; };
assert!(image.ends_with(".png")); assert!(image.ends_with(".png"));
// Named as belonging to the call that produced it, so a phone draws // Named as belonging to the call that produced it, so a phone draws it
// it under that row rather than beside it. // under that row rather than beside it.
assert_eq!(about.as_deref(), Some("toolu_05")); assert_eq!(about.as_deref(), Some("toolu_05"));
let saved = dir.path().join("files").join(image); let saved = dir.path().join("files").join(image);
assert!(saved.is_file(), "image not saved at {}", saved.display()); assert!(saved.is_file(), "image not saved at {}", saved.display());
@@ -1118,19 +1066,15 @@ mod tests {
/// A turn another agent started says so, on the record that carries it. /// A turn another agent started says so, on the record that carries it.
/// ///
/// The line is the real shape, taken from a real cross-session message /// The line is the real shape, taken from a real cross-session message sent
/// sent to a real stream-json session on CLI 2.1.237 (2026-08-31) -- /// to a real stream-json session on 2.1.237 (2026-08-31) -- including the
/// including the `from` socket path, which is deliberately *not* what a /// `from` socket path, which is deliberately *not* what a reader is shown:
/// reader is shown: the sending session's `name` is what they recognise /// the sending session's `name` is what they recognise it by. The `body` is
/// it by. The `body` is the message as it was written; the content the /// the message as written; the content the model is given wraps the same
/// model is given beside it wraps the same text in a preamble and a /// text in a preamble written for the model rather than for a person.
/// `<cross-session-message>` tag, which is written for the model rather
/// than for a person.
/// ///
/// The note comes before the usage and the idle, so it sits as close to /// The note comes before the usage and the idle, so it sits as close to the
/// the turn it explains as the wire allows -- which is after the reply, /// turn it explains as the wire allows.
/// not above it. See the comment at the callsite for why that is the
/// best available position rather than an oversight.
#[test] #[test]
fn a_turn_started_by_another_agent_records_who_and_what() { fn a_turn_started_by_another_agent_records_who_and_what() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
@@ -1147,8 +1091,8 @@ mod tests {
Event::PeerMessage { Event::PeerMessage {
from: "ai-app-2-fb".to_string(), from: "ai-app-2-fb".to_string(),
text: "Reply with just the word ACK.".to_string(), text: "Reply with just the word ACK.".to_string(),
// Stamped by the pump, which is the only place that // Stamped by the pump, which is the only place that knows
// knows what seq the turn started at. // what seq the turn started at.
turn_start: None, turn_start: None,
}, },
Event::UsageDelta { Event::UsageDelta {
@@ -1162,9 +1106,9 @@ mod tests {
); );
} }
/// And an ordinary turn does not, which is the half that decides /// And an ordinary turn does not, which is the half that decides whether
/// whether the check above is a check or a rubber stamp. Measured over /// the check above is a check or a rubber stamp. Measured over a real
/// a real session's stdout: four results, no `origin` between them. /// session's stdout: four results, no `origin` between them.
#[test] #[test]
fn an_ordinary_turn_carries_no_peer_note() { fn an_ordinary_turn_carries_no_peer_note() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
@@ -1186,12 +1130,10 @@ mod tests {
/// The context is the last assistant message's, not the result's. /// The context is the last assistant message's, not the result's.
/// ///
/// Real figures from a two-message haiku turn on 2.1.237, captured /// Real figures from a two-message haiku turn on 2.1.237, captured
/// 2026-08-30. The result adds the turn up -- its /// 2026-08-30. The result adds the turn up -- its `cache_read_input_tokens`
/// `cache_read_input_tokens` of 40,211 is 14,259 and 25,952, the same /// of 40,211 is 14,259 and 25,952, the same conversation counted twice -- so
/// conversation counted twice -- so reading the context off it would /// reading the context off it would report a size the model never held, by
/// report a size the model never held, and by more the more tool calls /// more the more tool calls a turn makes.
/// a turn makes. The last message's three input figures are what it
/// was holding when the turn ended.
#[test] #[test]
fn the_context_is_what_the_last_message_held_not_the_turn_added_up() { fn the_context_is_what_the_last_message_held_not_the_turn_added_up() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
@@ -1231,9 +1173,9 @@ mod tests {
#[test] #[test]
fn a_compaction_reports_its_start_and_what_it_recovered() { fn a_compaction_reports_its_start_and_what_it_recovered() {
// Real lines (trimmed) from a 2.1.237 session driven through // Real lines (trimmed) from a 2.1.237 session driven through `/compact`.
// `/compact`. Note the snake_case keys -- the CLI's transcript // Note the snake_case keys -- the CLI's transcript file writes the same
// file writes the same records in camelCase. // records in camelCase.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf());
let events = translate_lines( let events = translate_lines(
@@ -1333,16 +1275,14 @@ mod tests {
); );
} }
/// Pressing Stop is not a failure, and the CLI cannot tell you which it /// Pressing Stop is not a failure, and the CLI cannot tell you which it was.
/// was.
/// ///
/// An interrupted turn arrives as exactly the same shape a broken one /// An interrupted turn arrives as exactly the same shape a broken one does,
/// does -- `is_error` set, on a `result` -- so somebody who pressed the /// so somebody who pressed the button was shown "the turn ended with an
/// button was shown "the turn ended with an error" for doing what the /// error". What separates the two is that this side asked. The second half
/// button says. What separates the two is not in the line: it is that /// of this test is the one that matters, because the naive fix -- never
/// this side asked. The second half of this test is the one that /// reporting an error result -- passes the first half and silences every
/// matters, because the naive fix -- never reporting an error result -- /// genuine failure afterwards.
/// passes the first half and silences every genuine failure afterwards.
#[test] #[test]
fn a_turn_stopped_on_purpose_is_not_an_error() { fn a_turn_stopped_on_purpose_is_not_an_error() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
+220 -335
View File
@@ -9,26 +9,22 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::mpsc; use tokio::sync::mpsc;
/// The name a session's image is stored and served under -- returned by /// The name a session's image is stored and served under -- minted for an
/// `POST /attachments` for an upload, minted by a driver for one a tool /// upload or for one a tool produced, and fetched back from
/// produced, and fetched back from `/sessions/{id}/files/{ref}`. Both /// `/sessions/{id}/files/{ref}`. One id both directions, so the transcript
/// directions use the one id so the transcript renders them identically. /// renders them identically.
pub type ImageRef = String; pub type ImageRef = String;
/// The name an upload from the phone is stored and served under: an image /// The name an upload is stored and served under: an image is
/// is `<hex>.<extension>` and is an [`ImageRef`] like any other; any other /// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
/// file keeps its own name after the hex, `<hex>-<name>`, because the name /// keeps its own name after the hex, `<hex>-<name>`, because the name is what
/// is what the reader attached and what the session is told. The two are /// the reader attached and what the session is told. Told apart by
/// told apart by `crate::media::media_type_for`, which knows every image /// `crate::media::media_type_for`.
/// extension this server writes.
pub type AttachmentRef = String; pub type AttachmentRef = String;
/// One choice offered in answer to a [`Event::Question`]. /// One choice offered in answer to a [`Event::Question`]. More than a label
/// /// because the reader is deciding rather than confirming: what an option
/// More than a label because the reader is deciding, not confirming: what /// means, and what picking it would produce, are what decide it.
/// an option means, and what picking it would produce, are the things that
/// decide it. Both are optional -- a permission's Allow and Deny mean
/// exactly what they say.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct QuestionOption { pub struct QuestionOption {
@@ -42,7 +38,6 @@ pub struct QuestionOption {
} }
impl QuestionOption { impl QuestionOption {
/// An option that is only its label, which is most of them.
pub fn plain(label: impl Into<String>) -> Self { pub fn plain(label: impl Into<String>) -> Self {
Self { Self {
label: label.into(), label: label.into(),
@@ -52,70 +47,52 @@ impl QuestionOption {
} }
} }
/// Everything a session can tell the outside world. Every event is /// Everything a session can tell the outside world. Every event is appended
/// appended to the session's transcript with a sequence number, then fanned /// to the transcript with a sequence number, then fanned out to SSE
/// out to SSE subscribers; the phone renders purely from this stream, so /// subscribers, so reconnecting is just "events after seq N" -- no separate
/// reconnecting is just "events after seq N" -- no separate history path /// history path to drift from the live one.
/// to drift from the live one.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
// `rename_all` renames the variants; `rename_all_fields` renames what is // `rename_all` renames the variants; `rename_all_fields` renames what is
// inside them. Both are needed and only the first is obvious: every field // inside them. Both are needed and only the first is obvious: every field
// here was a single lowercase word until `pre_tokens` arrived, so a // here was one lowercase word until `pre_tokens` arrived, so a multi-word
// multi-word field went out as snake_case, the app looked for camelCase and // field went out as snake_case, the app looked for camelCase and found
// found nothing, and the event still rendered -- as the "no counts were // nothing, and the event still rendered -- as the "no counts reported" case,
// reported" case, which is a state it is allowed to be in. A wire mismatch // which is a state it is allowed to be in.
// that lands on a plausible state is invisible; anything added below with a
// two-word field would have hit the same thing.
#[serde( #[serde(
tag = "type", tag = "type",
rename_all = "camelCase", rename_all = "camelCase",
rename_all_fields = "camelCase" rename_all_fields = "camelCase"
)] )]
pub enum Event { pub enum Event {
/// What the user sent, written into the transcript by the manager (not /// What the user sent, written into the transcript by the manager (not by
/// by drivers) so every device renders the full conversation from the /// drivers) so every device renders the conversation from one stream.
/// one stream. Recorded when the session reads the message, which is /// Recorded when the session reads it, which is what `MessageTaken` reports.
/// what `MessageTaken` reports.
UserMessage { UserMessage {
/// The [`Event::MessageQueued`] this resolves, when it waited. /// The [`Event::MessageQueued`] this resolves, when it waited. The
/// /// phone has a bubble on screen for the waiting message and needs to
/// A message sent between turns is read at once and never queued, /// know *which* one this is, rather than matching on the text and
/// so this is `None` for most of them. It is the pair to the id on /// clearing the wrong one when the same thing was sent twice.
/// `MessageQueued` and exists for the same reason `CommandSent`
/// carries one: the phone has a bubble on screen for the waiting
/// message and needs to know *which* one this is, rather than
/// matching on the text and clearing the wrong one when the same
/// thing was sent twice.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>, id: Option<String>,
text: String, text: String,
/// What was attached to it, by the ref the files route serves. /// What was attached, by the ref the files route serves. On the
/// /// message rather than beside it: these used to be their own `Image`
/// On the message rather than beside it. These used to be their own /// events just before, which left the phone deciding from adjacency
/// `Image` events emitted just before, which drew a person's /// which message an image belonged to. `images` on disk until
/// screenshot as a row of its own floating above the bubble that /// 2026-09-03, when files joined them; the alias reads the older rows.
/// sent it -- and left the phone to decide, from nothing but
/// adjacency, which message an image belonged to. Belonging is not
/// something to infer when the sender knew.
///
/// `images` on disk until 2026-09-03, when files joined them;
/// the alias reads the rows written before that.
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")] #[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
attachments: Vec<AttachmentRef>, attachments: Vec<AttachmentRef>,
}, },
/// A message accepted from the phone that the session cannot read yet. /// A message accepted from the phone that the session cannot read yet.
/// ///
/// Recorded, unlike the message itself, and that difference is the /// Recorded, unlike the message itself, and that difference is the point:
/// point. The *message* belongs in the transcript where the session /// the message belongs in the transcript where the session read it, but
/// read it -- see `MessageTaken` -- but something has to say it is /// something has to say it is waiting, and it has to be the server. The
/// waiting, and it has to be the server that says it: the phone used /// phone used to remember its own outgoing messages, so leaving the
/// to remember its own outgoing messages, so leaving the session /// screen showed nothing pending when something was.
/// screen or restarting the app showed nothing pending when something
/// was, which reads as "nothing queued" rather than "I have forgotten".
/// ///
/// Carries no row of its own. It is resolved by the `UserMessage` /// Carries no row of its own; resolved by the `UserMessage` bearing the
/// bearing the same id, exactly as `CommandQueued` is resolved by /// same id, as `CommandQueued` is resolved by `CommandSent`.
/// `CommandSent`.
MessageQueued { MessageQueued {
id: String, id: String,
text: String, text: String,
@@ -125,38 +102,31 @@ pub enum Event {
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")] #[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
attachments: Vec<AttachmentRef>, attachments: Vec<AttachmentRef>,
}, },
/// A message taken out of the queue before the session read it, by /// A message taken out of the queue before the session read it.
/// somebody tapping the bubble that was waiting for it.
/// ///
/// Recorded for the same reason `MessageQueued` is: the queue is the /// Recorded for the same reason `MessageQueued` is: the queue is the
/// server's, so what is waiting has to be answerable from the /// server's, so what is waiting has to be answerable from the transcript
/// transcript alone. Without it a phone that reconnects replays the /// alone. Without it a phone that reconnects replays the `MessageQueued`
/// `MessageQueued` and puts back a bubble for a message that will /// and puts back a bubble nothing will ever resolve -- the `UserMessage`
/// never arrive -- and nothing later would ever resolve it, since the /// that normally does is exactly what is not coming.
/// `UserMessage` that normally does is exactly what is not coming.
/// ///
/// Only ever sent for a message that had not been handed over. One /// Only ever sent for a message that had not been handed over; see
/// that has is not droppable and says so instead; see
/// [`Unqueued::AlreadySent`]. /// [`Unqueued::AlreadySent`].
MessageDropped { MessageDropped {
id: String, id: String,
}, },
/// A driver has taken one of the user's messages and started reading /// A driver has taken one of the user's messages and started reading it.
/// it. The manager turns this into the `UserMessage` above, so it /// The manager turns this into the `UserMessage` above, so it never
/// never reaches a phone itself. /// reaches a phone itself.
/// ///
/// It exists because sending and being read are not the same moment. A /// It exists because sending and being read are not the same moment. A
/// message sent into a running turn waits for that turn to finish, and /// message sent into a running turn waits, and recording it among things
/// until then the session has not seen it -- so recording it among /// already read puts it in the transcript above output that predates it.
/// things already read puts it in the transcript above output that
/// predates it, and leaves a phone drawing it as still waiting with
/// nothing coming to say otherwise.
MessageTaken { MessageTaken {
/// The `MessageQueued` this answers, or `None` when it never /// The `MessageQueued` this answers, or `None` when it never waited.
/// waited. Carried through onto the `UserMessage`. /// Carried through onto the `UserMessage`.
id: Option<String>, id: Option<String>,
text: String, text: String,
/// Carried through onto the `UserMessage` with everything else.
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")] #[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
attachments: Vec<AttachmentRef>, attachments: Vec<AttachmentRef>,
}, },
@@ -183,13 +153,10 @@ pub enum Event {
Image { Image {
#[serde(rename = "ref")] #[serde(rename = "ref")]
image: ImageRef, image: ImageRef,
/// The tool call whose result carried it, when one did. /// The tool call whose result carried it, when one did. A screenshot
/// /// belongs under the call that took it, not floating beside it -- the
/// A screenshot belongs under the call that took it, not floating /// reader has to pair them by position otherwise, and position is
/// beside it -- the reader has to pair them by position otherwise, /// exactly what a page boundary breaks.
/// and position is exactly what a page boundary breaks. `None` for
/// an image a person attached to their own message, which belongs
/// to no call.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
about: Option<String>, about: Option<String>,
}, },
@@ -199,71 +166,54 @@ pub enum Event {
id: String, id: String,
prompt: String, prompt: String,
/// A few words naming what the question is about, when the asker /// A few words naming what the question is about, when the asker
/// offered one -- a tag beside the question rather than part of /// offered one. `None` for a permission, which is about the call
/// it. `None` for a permission, which is about the call above it. /// above it.
#[serde(default, skip_serializing_if = "Option::is_none")]
header: Option<String>, header: Option<String>,
options: Vec<QuestionOption>, options: Vec<QuestionOption>,
/// Whether several options may be chosen at once. /// Whether several options may be chosen at once. Here rather than
/// /// left for a phone to work out from the dialect underneath: how many
/// Here rather than left for a phone to work out from the dialect /// answers a question takes is a fact about the question, and the
/// underneath: how many answers a question takes is a fact about /// alternative was Claude Code's tool-input schema written out a
/// the question, and the alternative was the app parsing Claude /// second time in Kotlin, where no other dialect could reach it.
/// Code's tool input to find out -- one dialect's schema, written
/// out a second time in Kotlin, where no other dialect could
/// reach it.
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
multi_select: bool, multi_select: bool,
/// The tool call this is permission for, when it is one. /// The tool call this is permission for, when it is one, so a phone
/// /// can draw the ask on the tool's own row rather than as a second
/// The CLI's `can_use_tool` request carries the `tool_use_id` of /// card repeating its input. `None` for anything not about a tool.
/// the call it is asking about, so a phone can draw the ask on the
/// tool's own row rather than as a second card repeating its
/// input. `None` for anything that is not about a tool --
/// AskUserQuestion, and an echo session's question.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
about: Option<String>, about: Option<String>,
}, },
/// A message another agent sent this session. /// A message another agent sent this session.
/// ///
/// Its own kind rather than a `UserMessage`, because it is not /// Its own kind rather than a `UserMessage`, because it is not something
/// something the reader said and a transcript that renders it in their /// the reader said and a transcript that renders it in their voice is
/// voice is claiming they did. It also explains what would otherwise /// claiming they did. It also explains what would otherwise be
/// be inexplicable: a session that starts working on something nobody /// inexplicable: a session working on something nobody here asked for.
/// on this phone asked for.
PeerMessage { PeerMessage {
/// The sending session's own name, which is what the reader /// The sending session's own name, which is what the reader
/// recognises it by -- the socket path it came from is not. /// recognises it by -- the socket path it came from is not.
from: String, from: String,
text: String, text: String,
/// The seq of the `Status::Running` that opened the turn this /// The seq of the `Status::Running` that opened the turn this message
/// message started, so a reader can draw it above that turn. /// started, so a reader can draw it above that turn.
/// ///
/// It exists because the live Claude Code path cannot record the /// The CLI says nothing about a peer message until the turn's
/// message where it belongs. The CLI says nothing about a peer /// `result`, so the event is appended after everything it caused, and
/// message until the turn's `result` -- see /// an append-only transcript cannot go back and insert it. Carrying
/// `claude::translate` -- so the event is appended after /// the position instead keeps one order on the wire and one on screen.
/// everything it caused, and an append-only transcript cannot go
/// back and insert it. Carrying the position instead keeps one
/// order on the wire and one order on screen without a second
/// source for either.
/// ///
/// Filled in by the pump, which is the only place that knows a /// Filled in by the pump, the only place that knows a seq, and only
/// seq, and only where a turn was open: `None` for a message read /// where a turn was open: `None` for a message replayed by `import`,
/// out of a session file by `import`, which already has it in the /// which already has it in the right place.
/// right place, and for one that started no turn.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
turn_start: Option<u64>, turn_start: Option<u64>,
}, },
/// The manager's record of a question being answered, so a rendered /// The manager's record of a question being answered, so a rendered
/// question card resolves on every device, not just the one that /// question card resolves on every device rather than only the one that
/// answered it. /// answered.
/// ///
/// A list because a question can take several answers, and one that /// A list because a question can take several answers, and one that took
/// took one is the list of length one rather than a different shape. /// one is the list of length one rather than a different shape.
/// What a dialect makes of that -- Claude Code's answers map holds a
/// string, so several become one line -- is that dialect's business
/// and is done where it talks to it.
Answered { Answered {
id: String, id: String,
answers: Vec<String>, answers: Vec<String>,
@@ -273,17 +223,13 @@ pub enum Event {
}, },
/// What the session is set to, as the session itself reports it. /// What the session is set to, as the session itself reports it.
/// ///
/// Asking for a change and having one are different things, and only /// Asking for a change and having one are different things, and only this
/// this one is a measurement: a model name the dialect does not know, /// is a measurement: a model name the dialect does not know, a mode it
/// a mode it refuses, or a driver whose model is fixed at startup all /// refuses, or a driver whose model is fixed at startup all leave a
/// leave a request that was sent and nothing that changed. Reporting /// request that was sent and nothing that changed. Reporting from the
/// from the request instead put the answer on the phone before the /// request put the answer on the phone before the question was answered.
/// question had been answered, and left it there when the answer was
/// no.
/// ///
/// Either field alone, because the two are confirmed separately and /// Either field alone, because the two are confirmed separately.
/// by different things -- the CLI echoes a mode change, and names the
/// model it resolved an alias to when a session starts.
Settings { Settings {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>, model: Option<String>,
@@ -295,58 +241,44 @@ pub enum Event {
/// What this turn cost: the tokens it was charged for. /// What this turn cost: the tokens it was charged for.
tokens: u64, tokens: u64,
/// What the model was holding when the turn ended -- see /// What the model was holding when the turn ended -- see
/// [`context_tokens`] for what goes into it. /// [`context_tokens`].
/// ///
/// Carried on the event rather than summed by whoever is reading, /// Carried rather than summed by whoever is reading, because it is
/// because it is not a sum: a conversation's context goes *down* /// not a sum: context goes *down* at a compaction and a clear, so
/// at a compaction and a clear, so adding turns up would report a /// adding turns up would report a figure the session stopped being
/// figure the session stopped being true of long ago. It is also /// true of long ago.
/// the number a reader is asking about -- how much room is left
/// before the next compaction -- rather than what has been spent
/// getting here.
/// ///
/// `None` where the dialect did not say, which every reader has to /// `None` where the dialect did not say, which every reader has to be
/// be able to draw: a turn whose usage the CLI omitted leaves the /// able to draw.
/// context unmeasured rather than unchanged, and entries written
/// before this existed have no answer at all.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
context: Option<u64>, context: Option<u64>,
}, },
/// A compaction that finished, and how much context it recovered. /// A compaction that finished, and how much context it recovered.
/// ///
/// The counts are the point, and a spinner is not: what a reader wants /// The counts are the point, and a spinner is not. They are optional
/// afterwards is that the session went from a million tokens to ten /// because the record has shipped without them, and "the compaction
/// thousand, which is measured rather than estimated. They are /// happened, we don't know by how much" is a state this has to be able to
/// optional because the record has shipped without them, and "the /// say -- a plausible number would be indistinguishable from a counted one.
/// compaction happened, we don't know by how much" is a state this
/// has to be able to say -- filling in a plausible number would make
/// it indistinguishable from one that was counted.
Compacted { Compacted {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pre_tokens: Option<u64>, pre_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
post_tokens: Option<u64>, post_tokens: Option<u64>,
/// What asked for it, in the dialect's own word -- `auto` when the /// What asked for it, in the dialect's own word -- `auto` when the
/// session compacted on its own. Carried rather than reduced to a /// session compacted on its own. Carried rather than reduced to a bool
/// bool so an unrecognised trigger stays unrecognised: an /// so an unrecognised trigger stays unrecognised: an automatic
/// automatic compaction is the one worth naming, because it /// compaction is the one worth naming, because it explains a wait
/// explains a wait nobody asked for, and defaulting the unknown /// nobody asked for.
/// case to "you asked for this" would explain it away.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
trigger: Option<String>, trigger: Option<String>,
}, },
/// A command the session was asked to run on itself, held because it /// A command the session was asked to run on itself, held because it
/// cannot run yet. /// cannot run yet. These are not messages: `/compact` and `/rename` are
/// /// instructions about the session, and a session mid-turn reads a line
/// These are not messages: `/compact` and `/rename` are instructions /// written to it as something the model should see. So they wait, and
/// to the session about itself, and a session in the middle of a turn /// this is what a phone draws while they do.
/// reads a line written to it as something the model should see. So
/// they wait for the turn to end, and this is what a phone draws
/// while they do -- otherwise pressing Compact during a long turn
/// does nothing visible for minutes and looks like it was missed.
CommandQueued { CommandQueued {
id: String, id: String,
/// What to show for it: the command as a person would type it.
text: String, text: String,
}, },
/// The same command, now handed to the session. Its [`CommandQueued`] /// The same command, now handed to the session. Its [`CommandQueued`]
@@ -356,73 +288,55 @@ pub enum Event {
id: String, id: String,
text: String, text: String,
}, },
/// The conversation was cleared: everything above this is still in /// The conversation was cleared: everything above this is still in the
/// the record but is no longer in the session's context. /// record but is no longer in the session's context.
/// ///
/// Nothing is deleted. A transcript is the thing a person scrolls /// Nothing is deleted. A transcript is the thing a person scrolls back
/// back through, and a session that dropped its history from the /// through, so this is a divider, not a truncation.
/// screen as well as from the model would lose the only copy the
/// phone has -- so this is a divider, not a truncation, and the
/// events before it stay exactly where they were.
///
/// It is also what makes clearing mean the same thing for every
/// driver, which is why the marker lives here rather than in one
/// dialect: `llama` folds its conversation out of the transcript and
/// simply folds from the last one of these, and `claude` starts a new
/// CLI conversation behind it.
/// ///
/// **Load-bearing, not decorative.** For any driver that rebuilds its /// **Load-bearing, not decorative.** For any driver that rebuilds its
/// conversation from the transcript, this marker decides what the /// conversation from the transcript, this marker decides what the model
/// model is given -- dropping it, or treating it as something only /// is given -- dropping it, or treating it as something only the phone
/// the phone draws, silently puts a cleared conversation back in /// draws, silently puts a cleared conversation back in front of the model
/// front of the model at full cost. Today `llama::conversation` is /// at full cost. Today `llama::conversation` is the only fold that reads
/// the only fold that reads it, which is the reason to write this /// it, which is why this is written down rather than left to be inferred
/// down rather than leave it to be inferred from a second example /// from a second example that does not exist.
/// that does not exist yet.
Cleared, Cleared,
Error { Error {
message: String, message: String,
}, },
} }
/// How much the model was holding, from the three figures a turn reports. /// How much the model was holding, from the three figures a turn reports:
/// the input side only, prompt plus both cache figures. A cached token is
/// cheaper but it is still one the model was given; output is what the turn
/// produced rather than what continuing has to carry.
/// ///
/// The input side only -- prompt plus both cache figures. A cached token /// One function so the definition cannot drift, because it is extracted two
/// is cheaper but it is still one the model was given, so all three count; /// quite different ways -- the live translators have the usage object parsed,
/// output is left out because it is what the turn produced rather than /// and `import::context_tokens` scans it out of a raw line without parsing.
/// what continuing from here has to carry.
///
/// One function so the definition cannot drift, because it is extracted in
/// two quite different ways: the live translators have the usage object
/// parsed, and `import::context_tokens` scans it out of a raw line without
/// parsing, since those files reach tens of megabytes.
pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 { pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 {
input + cache_creation + cache_read input + cache_creation + cache_read
} }
/// The context after `event`, given what it was before. /// The context after `event`, given what it was before.
/// ///
/// The whole rule in one place, because three readers need the same /// The whole rule in one place, because three readers need the same answer:
/// answer: the pump keeping a live session's figure, the transcript /// the pump keeping a live session's figure, the transcript seeding it at
/// seeding it at startup, and the phone folding the same events into what /// startup, and the phone folding the same events into what it draws.
/// it draws. Written here beside the events it reads so a fourth reader
/// finds it.
/// ///
/// The two that *lower* it are the point. A clear takes the conversation /// The two that *lower* it are the point. A clear takes the conversation away
/// away and a compaction replaces it with a summary, so a figure measured /// and a compaction replaces it with a summary, so a figure measured before
/// before either stopped being true at that moment -- and carrying it /// either stopped being true at that moment -- and carrying it forward is how
/// forward is how a session that had just been cleared went on reporting /// a session that had just been cleared went on reporting the context it no
/// the context it no longer had. /// longer had.
/// ///
/// `None` is "we don't know", which is a state each of them can reach: /// `None` is "we don't know", which each of them can reach.
/// nothing has been measured yet, a compaction finished without saying
/// how much it recovered, or a clear left a conversation nobody has
/// counted since.
pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> { pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> {
match event { match event {
// `or`, so a turn the dialect reported no usage for leaves the last // `or`, so a turn the dialect reported no usage for leaves the last
// measurement standing: it is stale by a turn, which every context // measurement standing: stale by a turn, which every context figure
// figure is, rather than wrong. // is, rather than wrong.
Event::UsageDelta { context, .. } => context.or(current), Event::UsageDelta { context, .. } => context.or(current),
Event::Compacted { post_tokens, .. } => *post_tokens, Event::Compacted { post_tokens, .. } => *post_tokens,
Event::Cleared => None, Event::Cleared => None,
@@ -433,11 +347,10 @@ pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> {
/// Something a session can be asked to do to itself. /// Something a session can be asked to do to itself.
/// ///
/// A closed set rather than a string, because the two that are not /// A closed set rather than a string, because the two that are not
/// dialect-specific have to reach every provider: compaction is a /// dialect-specific have to reach every provider: compaction is a capability
/// capability an llama session may one day have, and a name is this /// an llama session may one day have, and a name is this server's own. `Raw`
/// server's own. `Raw` is the escape for a dialect's own commands -- /// is the escape for a dialect's own commands, which only the thing running
/// `/context`, `/usage` -- which only the thing running the session can /// the session can interpret.
/// interpret.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum SessionCommand { pub enum SessionCommand {
Compact, Compact,
@@ -447,8 +360,8 @@ pub enum SessionCommand {
} }
impl SessionCommand { impl SessionCommand {
/// What a person would have typed to ask for this, which is what a /// What a person would have typed to ask for this, which is what a phone
/// phone shows while it waits. /// shows while it waits.
pub fn label(&self) -> String { pub fn label(&self) -> String {
match self { match self {
Self::Compact => "/compact".to_string(), Self::Compact => "/compact".to_string(),
@@ -477,32 +390,28 @@ pub enum SessionStatus {
AwaitingInput, AwaitingInput,
Compacting, Compacting,
Exited, Exited,
/// There is a process recorded for this session and the machine will /// There is a process recorded for this session and the machine will not
/// not say whether it is still running. /// say whether it is still running.
/// ///
/// Its own state rather than the nearest of the others, because both /// Its own state rather than the nearest of the others, because both
/// neighbours are lies with consequences: `Exited` invites starting a /// neighbours are lies with consequences: `Exited` invites starting a
/// second process against a conversation that may already have one, /// second process against a conversation that may already have one, and
/// and `Idle` claims a session is waiting for you when nobody has /// `Idle` claims a session is waiting for you when nobody has checked.
/// checked. It resolves itself -- the driver keeps asking -- so what
/// it means to a reader is "wait", not "act".
Unknown, Unknown,
} }
/// What became of a request to take a queued message back. /// What became of a request to take a queued message back.
/// ///
/// Three states rather than a bool because the two failures are not the /// Three states rather than a bool because the two failures are not the same
/// same fact. A driver that writes into its session the moment a message /// fact. A driver that writes into its session the moment a message arrives
/// arrives -- which is what `ClaudeDriver` does, so that a steer reaches /// -- which is what `ClaudeDriver` does, so a steer reaches the model at the
/// the model at the next tool boundary rather than at the end of the turn /// next tool boundary -- can never take one back, and a phone told only "no"
/// -- can never take one back, and a phone that was told only "no" would /// would have to guess whether it asked too late or asked about nothing.
/// have to guess whether it had asked too late or asked about nothing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Unqueued { pub enum Unqueued {
/// Out of the queue; the session will never read it. /// Out of the queue; the session will never read it.
Dropped, Dropped,
/// Already handed to the session, so there is nothing left to take /// Already handed to the session, so there is nothing left to take back.
/// back. The message is on its way into the conversation.
AlreadySent, AlreadySent,
/// Nothing is waiting under that id. /// Nothing is waiting under that id.
Unknown, Unknown,
@@ -522,110 +431,93 @@ pub type EventSink = mpsc::UnboundedSender<Event>;
pub trait Driver: Send + Sync { pub trait Driver: Send + Sync {
/// Takes a message, now or once the session is free for it. /// Takes a message, now or once the session is free for it.
/// ///
/// Every driver owes exactly one `MessageTaken` per message, at the /// Every driver owes exactly one `MessageTaken` per message, at the moment
/// moment it actually starts reading it: that event is what puts the /// it actually starts reading it: that event is what puts the message in
/// message in the transcript, so a driver that never sends it drops /// the transcript, so a driver that never sends it drops the message from
/// the message from the conversation entirely. /// the conversation entirely.
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>); fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>);
/// Takes back a message that is still waiting, named by the id its /// Takes back a message that is still waiting, named by the id its
/// [`Event::MessageQueued`] carried. /// [`Event::MessageQueued`] carried.
/// ///
/// Answering is the whole of the contract: a driver that drops the /// Answering is the whole of the contract: a driver that drops the message
/// message owes an [`Event::MessageDropped`], and one that cannot must /// owes an [`Event::MessageDropped`], and one that cannot must say which
/// say which of the two reasons it is, because they are different /// of the two reasons it is -- "the session has already been told" is
/// things to a reader -- "the session has already been told" is worth /// worth knowing, and "there is nothing under that id" means the bubble on
/// knowing, and "there is nothing under that id" means the bubble on /// screen is stale. The default is the honest answer for a driver with no
/// screen is stale. The default is the honest answer for a driver with /// queue at all.
/// no queue at all: nothing of yours is waiting.
fn unqueue(&self, _id: &str) -> Unqueued { fn unqueue(&self, _id: &str) -> Unqueued {
Unqueued::Unknown Unqueued::Unknown
} }
/// Answers one question with everything that was chosen, in the order /// Answers one question with everything that was chosen, in the order it
/// it was offered. One answer is a list of one; a driver whose dialect /// was offered. A driver whose dialect takes a single value joins them
/// takes a single value joins them where it writes it. /// where it writes it.
fn answer_question(&self, id: &str, answers: &[String]); fn answer_question(&self, id: &str, answers: &[String]);
/// Stop mid-run; the session survives. /// Stop mid-run; the session survives.
fn interrupt(&self); fn interrupt(&self);
fn set_model(&self, model: &str); fn set_model(&self, model: &str);
/// How much the session asks about before acting. Live rather than /// How much the session asks about before acting. Live rather than
/// spawn-only: the answer changes with what is being done, and a phone /// spawn-only: the answer changes with what is being done, and a phone is
/// is the worst place to answer "may I run this?" forty times. /// the worst place to answer "may I run this?" forty times.
fn set_permission_mode(&self, mode: &str); fn set_permission_mode(&self, mode: &str);
// Both of the above are requests, and neither reports the outcome by // Both of the above are requests, and neither reports the outcome by
// returning. A driver that actually changes the setting owes an // returning. A driver that changes the setting owes an [`Event::Settings`]
// [`Event::Settings`] once it has -- that event, and not the request, // once it has -- that event, not the request, is what the manager and the
// is what the manager and the phone read. One that cannot change it // phone read. One that cannot owes an [`Event::Error`] saying why.
// owes an [`Event::Error`] saying why; saying nothing leaves a phone
// showing a setting nobody applied.
/// Tells the process what this conversation is called, when it has /// Tells the process what this conversation is called, when it has
/// somewhere to put it. /// somewhere to put it.
/// ///
/// Unlike the two above, this is not a request that can fail: the /// Unlike the two above, this is not a request that can fail: the rename
/// rename has already happened in this server's own config, which is /// has already happened in this server's config, which is what a phone
/// what a phone lists and the only place the name has to be. So a /// lists. So a driver whose process has no notion of a name does nothing
/// driver whose process has no notion of a name does nothing here and /// and says nothing. Claude Code has one: `--name` at creation and
/// says nothing -- there is no failure to report, and an error beside
/// a rename that plainly worked would be a puzzle rather than a
/// warning.
///
/// Claude Code has one: `--name` when a session is created and
/// `/rename` afterwards, which is what puts the same name in its own /// `/rename` afterwards, which is what puts the same name in its own
/// session picker and in what other agents see. /// session picker and in what other agents see.
fn set_title(&self, title: &str); fn set_title(&self, title: &str);
/// Runs a command this session's own dialect understands, verbatim. /// Runs a command this session's own dialect understands, verbatim --
/// `/context`, `/usage`, anything a CLI adds next month. A driver with no
/// such vocabulary says so with an [`Event::Error`] rather than sending it
/// as a message, which would put a line meant for the session in front of
/// the model.
/// ///
/// For the ones this app has no opinion about -- `/context`, `/usage`, /// Called only when the session is between turns; the waiting is done
/// anything a CLI adds next month. A driver whose process has no such /// above, once, for every driver.
/// vocabulary says so with an [`Event::Error`] rather than sending it
/// as a message, which would put a line meant for the session in front
/// of the model instead.
///
/// Like [`Driver::compact`] and [`Driver::set_title`], this is called
/// only when the session is between turns; the waiting is done above,
/// once, for every driver.
fn run_command(&self, text: &str); fn run_command(&self, text: &str);
/// pi: native compaction; claude: `/compact`. /// llama: not built, and refused; claude: `/compact`.
fn compact(&self); fn compact(&self);
/// Drops the conversation so far without ending the session. /// Drops the conversation so far without ending the session.
/// ///
/// The cheap half of managing a long session, and the reason it is a /// The cheap half of managing a long session, and why it is a driver
/// driver operation rather than a manager one: compaction *reads* the /// operation rather than a manager one: compaction *reads* the whole
/// whole conversation in order to summarise it, so on a large context /// conversation in order to summarise it, so on a large context it is
/// it is itself one of the most expensive requests the session will /// itself one of the most expensive requests the session will make --
/// make -- measured at 1.7 million tokens for a single automatic /// measured at 1.7 million tokens for one automatic compaction on
/// compaction on 2026-08-29. Clearing costs nothing, because nothing /// 2026-08-29. Clearing costs nothing, because nothing is sent.
/// is sent.
/// ///
/// Every implementation emits [`Event::Cleared`] so the transcript /// Every implementation emits [`Event::Cleared`] so the transcript carries
/// carries the divider whatever the dialect did behind it. /// the divider whatever the dialect did behind it.
fn clear(&self); fn clear(&self);
/// Stop attending to the process but leave it running, because this /// Stop attending to the process but leave it running, because this
/// server is going away and means to adopt it again when it comes /// server is going away and means to adopt it again.
/// back.
/// ///
/// This is deliberately not a shutdown. A backend restart -- a /// Deliberately not a shutdown: a backend restart must not end a turn that
/// rebuild, a service restart, a crash -- must not end a turn that is /// is in flight, so a session's process outlives the server that started
/// in flight, so a session's process outlives the server that started /// it and is found again through `session::process`.
/// it and is found again through `session::process`. A driver with no
/// process of its own has nothing to do here.
/// ///
/// Its counterpart is [`Driver::stop`]. Every driver owes exactly one /// Its counterpart is [`Driver::stop`]. Every driver owes exactly one of
/// of the two on the way out, and which one is the difference between /// the two on the way out, and which one is the difference between "back
/// "back shortly" and "this conversation is over". /// shortly" and "this conversation is over".
/// Whether a line written *now* would start a turn of its own, rather /// Whether a line written *now* would start a turn of its own, rather than
/// than landing inside one already in flight. /// landing inside one already in flight.
/// ///
/// Asked of the driver because the driver is the only thing that knows: /// Asked of the driver because the driver is the only thing that knows: it
/// it sees every line it wrote and every line that came back, and it /// updates this the instant it writes rather than when output returns. The
/// updates this the instant it writes rather than when output returns. /// manager's `SessionStatus` is built from what has been *recorded*, so
/// The manager's `SessionStatus` cannot answer it -- that is built from /// between writing a line and the CLI's first output it still reads idle,
/// what has been *recorded*, so between writing a line and the CLI's /// and a second line sent in that gap lands inside the turn the first one
/// first output it still reads idle, and a second line sent in that gap /// started. For a command that is the difference between being executed
/// lands inside the turn the first one started. For a command that is /// and being read to the model as text, which is silent both ways.
/// the difference between being executed and being read to the model as
/// text, which is silent both ways.
/// ///
/// Defaults to true for a driver with no turn of its own to be inside. /// Defaults to true for a driver with no turn of its own to be inside.
fn between_turns(&self) -> bool { fn between_turns(&self) -> bool {
@@ -633,16 +525,12 @@ pub trait Driver: Send + Sync {
} }
fn detach(&self); fn detach(&self);
/// End the process for good, because it must not survive this. The /// End the process for good, because it must not survive this. The path
/// path out for everything [`detach`] preserves. /// out for everything [`Driver::detach`] preserves.
/// ///
/// Two callers, and the difference between them is only what is being /// Two callers, differing only in what is being ended: a session being
/// ended: a session being deleted, whose conversation goes with it, and /// deleted, whose conversation goes with it, and a throwaway session at a
/// a throwaway session at a server's exit, whose transcript stays and /// server's exit, whose transcript stays and whose process does not.
/// whose process does not (see [`SessionConfig::throwaway`]).
///
/// [`detach`]: Driver::detach
/// [`SessionConfig::throwaway`]: crate::config::SessionConfig::throwaway
fn stop(&self); fn stop(&self);
} }
@@ -650,11 +538,10 @@ pub trait Driver: Send + Sync {
mod tests { mod tests {
use super::*; use super::*;
/// A tripwire for the wire format, not for serde. /// A tripwire for the wire format, not for serde. The app reads these
/// /// names, and getting one wrong does not fail loudly: a field the app
/// The app reads these names, and getting one wrong does not fail /// cannot find reads as a field the server chose not to send, which
/// loudly: a field the app cannot find reads as a field the server /// several of them are allowed to be.
/// chose not to send, which several of them are allowed to be.
#[test] #[test]
fn multi_word_fields_go_out_in_camel_case() { fn multi_word_fields_go_out_in_camel_case() {
let json = serde_json::to_value(Event::Compacted { let json = serde_json::to_value(Event::Compacted {
@@ -674,11 +561,10 @@ mod tests {
); );
} }
/// The two events that take the context *down* are the point of the /// The two events that take the context *down* are the point of the fold:
/// fold: a figure measured before a compaction or a clear stopped being /// a figure measured before a compaction or a clear stopped being true at
/// true at that moment, and carrying it forward is how a session that /// that moment, and carrying it forward is how a session that had just
/// had just been cleared went on reporting the context it no longer /// been cleared went on reporting the context it no longer had.
/// had.
#[test] #[test]
fn a_compaction_and_a_clear_move_the_context_a_turn_cannot() { fn a_compaction_and_a_clear_move_the_context_a_turn_cannot() {
let after = |current, event| context_after(current, &event); let after = |current, event| context_after(current, &event);
@@ -707,8 +593,7 @@ mod tests {
assert_eq!(after(Some(9_617), Event::Cleared), None); assert_eq!(after(Some(9_617), Event::Cleared), None);
// A compaction that did not say how much it recovered leaves the // A compaction that did not say how much it recovered leaves the
// context unknown rather than stale: it definitely moved, and the // context unknown rather than stale: it definitely moved.
// one thing that is certainly wrong is the figure from before it.
assert_eq!( assert_eq!(
after( after(
Some(128_402), Some(128_402),
+190 -255
View File
@@ -1,63 +1,51 @@
//! The phase-1 fake driver: no child process, just events. It exists to //! The fake driver: no child process, just events. It proves the whole pipe --
//! prove the whole pipe -- spawn, transcript, SSE cursors, questions, //! spawn, transcript, SSE cursors, questions, interrupts, compaction -- and
//! interrupts, compaction -- before any AI is involved, and stays useful afterwards as //! stays useful afterwards as a connectivity check that costs no tokens. It
//! a connectivity check that costs no tokens. //! produces exactly the event vocabulary the real drivers do, so a UI that
//! renders echo sessions correctly renders the real thing.
//! //!
//! Behavior: every message is echoed back as a few streamed text deltas. //! Every message is echoed back as a few streamed text deltas. A leading word
//! A leading word asks for something more specific: //! asks for something more specific:
//! //!
//! - `/tool [input]` -- a full tool run, start through end. //! - `/tool [input]` -- a full tool run, start through end.
//! - `/bash [command]` -- a Bash call carrying that command, for what the //! - `/bash [command]` -- a Bash call carrying that command, for what the
//! phone's shell highlighting does to a particular line. //! phone's shell highlighting does to a particular line.
//! - `/tools [n] [gap]` -- n calls back to back, for what a run of them //! - `/tools [n] [gap]` -- n calls back to back. `gap` is seconds between one
//! looks like when a screen groups them. `gap` is seconds between one //! call and the next, which is what makes a run *grow* while somebody is
//! call and the next, default none: it is what makes a run *grow* while //! looking at it -- the only way to reach the state where a call opened on
//! somebody is looking at it, which is the only way to reach the state //! its own gains a neighbour. The first call carries a screenshot, so that
//! where a call opened on its own gains a neighbour. The first call //! state is also reachable with an image open full screen.
//! carries a screenshot, so that state can also be reached with an image
//! open full screen -- which is where it used to close itself.
//! - `/question [text]` -- a question, exercising the answer path. //! - `/question [text]` -- a question, exercising the answer path.
//! - `/ask` -- an AskUserQuestion call: two questions on one tool call, //! - `/ask` -- an AskUserQuestion call: two questions on one tool call, with
//! with descriptions, a preview and a multi-select, which is the shape //! descriptions, a preview and a multi-select, which is the shape that is
//! that is awkward to get a real model to produce on demand. Wrapped in //! awkward to get a real model to produce on demand. Wrapped in a run of
//! a run of ordinary calls on each side, because being asked something //! ordinary calls on each side, because being asked something happens in the
//! happens in the middle of work and the screen has to keep it out of //! middle of work.
//! the collapsed group around it. //! - `/slow [seconds]` -- a turn that stays running (default 30), so states
//! - `/slow [seconds]` -- a turn that stays running (default 30), so states that only //! that only exist *while* something is happening can be looked at.
//! exist *while* something is happening can be looked at.
//! - `/error [text]` -- a failure, which is otherwise awkward to cause. //! - `/error [text]` -- a failure, which is otherwise awkward to cause.
//! - `/peer [text]` -- a message from another agent, which otherwise takes //! - `/peer [text]`, `/peer-turn` -- a message from another agent, in the
//! two live sessions and one of them deciding to write. //! in-place and the live shapes.
//! - `/usage [what]` -- puts up an invented rate-limit answer, or takes //! - `/usage [what]` -- an invented rate-limit answer, or `/usage off` to take
//! it away again (`/usage off`). An echo session meters nothing, so it //! it away. An echo session meters nothing, so it draws no usage bar until
//! draws no usage bar at all until this is set; what it exists for is //! this is set; what it exists for is the states that bar can be in, which
//! the states the bar can be in, which otherwise cost real quota to //! otherwise cost real quota to reach. `/usage 42`, `/usage 95 20`,
//! reach. `/usage 42`, `/usage 95 20`, `/usage 42 never`, //! `/usage 42 never`, `/usage notloggedin`, `/usage unreachable`,
//! `/usage notloggedin`, `/usage unreachable`, `/usage failed`. The //! `/usage failed`. The vocabulary is `usage::Fixture`'s, where the states
//! vocabulary is `usage::Fixture`'s, which is where the states live. //! live.
//! - `/compact` -- a compaction, start to finish. Typed rather than //! - `/compact` -- a compaction, start to finish.
//! pressed, because the real dialects take it as a typed command too and //! - `/stream N` -- one long answer in N small pieces, 50ms apart: the shape a
//! the phone no longer has a button for it. //! real model's reply arrives in, and the one where the row a reader is
//! anchored to is the row that keeps changing height.
//! - `/mixed N` -- N beats of an interleaved transcript: rows of every shape
//! and height the app draws, in one session, which is what a scrolling
//! problem needs in order to be reproduced twice the same way.
//! - `/table [columns]` -- a markdown table with cells too long for one line.
//! //!
//! This is exactly the event vocabulary the real drivers produce, so a UI //! `/slow` earns its place: a queued message, a Stop button and a spinner are
//! that renders echo sessions correctly renders the real thing. //! states that only exist mid-turn, and the obvious way to get one -- ask a
//! //! real model to sleep -- does not work. It declines and answers instantly, so
//! - `/stream N` -- one long answer in N small pieces, 50ms apart: the //! the state never arrives and the attempt still costs a turn.
//! shape a real model's reply arrives in, and the one where the row a
//! reader is anchored to is the row that keeps changing height.
//! - `/mixed N` -- N beats of an interleaved transcript: paragraphs of
//! different lengths, single tool calls, runs of adjacent ones, attachments
//! and a peer message. Rows of every shape and height the app draws, in
//! one session, which is what a scrolling problem needs in order to be
//! reproduced twice the same way.
//!
//! `/slow` earns its place: a queued message, a Stop button, a spinner
//! where the answer will go are all states that only exist mid-turn, and
//! the obvious way to get one -- ask a real model to sleep -- does not
//! work. It declines, reasonably, and answers instantly instead, so the
//! state never arrives and the attempt still costs a turn on somebody's
//! account. A driver that can be *told* to take its time costs nothing and
//! is the same every run.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -69,24 +57,18 @@ use super::driver::{
}; };
/// Delay between streamed deltas -- long enough that streaming is visibly /// Delay between streamed deltas -- long enough that streaming is visibly
/// streaming in the UI, short enough that tests waiting on a full turn /// streaming, short enough that tests waiting on a full turn stay fast.
/// stay fast.
const DELTA_DELAY: Duration = Duration::from_millis(50); const DELTA_DELAY: Duration = Duration::from_millis(50);
/// How long a fake compaction takes. /// How long a fake compaction takes. A measured one, near enough: driving a
/// /// real session through `/compact` on 2026-08-29 took 13 seconds for a small
/// A measured one, near enough: driving a real session through `/compact` /// conversation. Three seconds -- what this was -- is too short to look at the
/// on 2026-08-29 took 13 seconds for a small conversation, and a large one /// row that only exists while a compaction is running.
/// takes minutes. Three seconds -- what this was -- is too short to look
/// at the row that only exists while a compaction is running, and too
/// short to watch its elapsed count reach two digits.
const COMPACT_TIME: Duration = Duration::from_secs(13); const COMPACT_TIME: Duration = Duration::from_secs(13);
/// A question echo is waiting on, and the tool call it belongs to. /// A question echo is waiting on, and the tool call it belongs to. `call` is
/// /// `None` for `/question`, which asks on its own the way a permission does;
/// `call` is `None` for `/question`, which asks on its own the way a /// `Some` for `/ask`, where several questions share one call.
/// permission does; `Some` for `/ask`, where several questions share one
/// call and the call ends when the last of them is answered.
struct PendingQuestion { struct PendingQuestion {
id: String, id: String,
call: Option<String>, call: Option<String>,
@@ -96,42 +78,35 @@ pub struct EchoDriver {
sink: EventSink, sink: EventSink,
/// Whether a turn is in flight, and what arrived during it. /// Whether a turn is in flight, and what arrived during it.
/// ///
/// A real CLI holds a message sent mid-turn and injects it at the next /// A real CLI holds a message sent mid-turn and injects it at the next tool
/// tool boundary; echo used to answer it on the spot, which made it /// boundary; echo used to answer it on the spot, which made it the wrong
/// the wrong shape for testing anything about queueing -- the status /// shape for testing anything about queueing.
/// dropped to idle immediately, so a phone had nothing to show as
/// pending. Holding it here is what makes echo able to stand in.
busy: Arc<AtomicBool>, busy: Arc<AtomicBool>,
/// Held messages with the id of the `MessageQueued` each one announced, /// Held messages with the id of the `MessageQueued` each one announced, so
/// so the announcement can say which waiting bubble it resolves. /// the announcement can say which waiting bubble it resolves.
queued: Arc<Mutex<Vec<Held>>>, queued: Arc<Mutex<Vec<Held>>>,
/// Where `/mixed` writes the attachments it references, which is the same /// Where `/mixed` writes the attachments it references, which is the same
/// directory the files route serves them from. /// directory the files route serves them from.
session_dir: PathBuf, session_dir: PathBuf,
/// Ids of the questions awaiting an answer, in the order they were /// Ids of the questions awaiting an answer, in the order asked. A list
/// asked. A list because `/ask` puts up to four on one tool call, the /// because `/ask` puts up to four on one tool call, and the turn resumes
/// way AskUserQuestion does, and the turn resumes when the last of /// when the last is answered rather than the first.
/// them is answered rather than the first.
pending_questions: Mutex<Vec<PendingQuestion>>, pending_questions: Mutex<Vec<PendingQuestion>>,
/// The invented rate-limit answer `/usage` sets, shared with the /// The invented rate-limit answer `/usage` sets, shared with the usage
/// usage monitor that serves it. An echo session meters nothing, so /// monitor that serves it. An echo session meters nothing, so this is unset
/// this is unset until a test asks for something -- see /// until a test asks for something -- see [`crate::usage::Fixture`].
/// [`crate::usage::Fixture`].
usage: crate::usage::Fixture, usage: crate::usage::Fixture,
/// A pretend context, so the status row has something that behaves the /// A pretend context, so the status row has something that behaves the way
/// way a real one does: it grows with each turn, drops to what the /// a real one does: it grows with each turn, drops to what the compaction
/// compaction says it recovered, and a clear leaves it unmeasured. The /// says it recovered, and a clear leaves it unmeasured. What is real is
/// numbers are invented like everything else here; what is real is /// which way the numbers move.
/// which way they move.
context: Arc<AtomicU64>, context: Arc<AtomicU64>,
} }
impl EchoDriver { impl EchoDriver {
/// A short run of ordinary calls, to sit either side of something. /// A short run of ordinary calls, to sit either side of something. Three,
/// /// because two is the fewest that groups and three makes it obvious the
/// Three, because two is the fewest that groups and three makes it /// group is a group.
/// obvious the group is a group -- and because the point of the
/// fixture is what a question looks like with work around it.
fn some_calls(&self, label: &str) { fn some_calls(&self, label: &str) {
for index in 0..3 { for index in 0..3 {
let id = format!("echo-{label}-{index}-{}", super::random_hex()); let id = format!("echo-{label}-{index}-{}", super::random_hex());
@@ -149,16 +124,15 @@ impl EchoDriver {
/// An AskUserQuestion call, in the shape the CLI sends one. /// An AskUserQuestion call, in the shape the CLI sends one.
/// ///
/// Two questions on one call, because that is where the display is /// Two questions on one call, because that is where the display is hardest
/// hardest and where it was wrong: one question with four options /// and where it was wrong. Written out in full rather than generated so it
/// reads fine even when the options are laid out badly. Written out /// carries the parts that are easy to leave out of a fixture -- a header, an
/// in full rather than generated so it carries the parts that are /// option with a description, an option with a preview block, and a
/// easy to leave out of a fixture -- a header, an option with a /// multi-select.
/// description, an option with a preview block, and a multi-select.
fn ask_user_question(&self) { fn ask_user_question(&self) {
// Written once, in the shape the events carry, and turned into // Written once, in the shape the events carry, and turned into the tool
// the tool call's own input below -- the CLI sends both, and two // call's own input below -- the CLI sends both, and two hand-written
// hand-written copies of one question would drift. // copies of one question would drift.
let asked = [ let asked = [
( (
"Theme", "Theme",
@@ -248,8 +222,7 @@ impl EchoDriver {
header: Some(header.to_string()), header: Some(header.to_string()),
options, options,
multi_select: multi, multi_select: multi,
// The call that asked, so all of it draws as one thing -- // The call that asked, so all of it draws as one thing.
// which is the whole point of the fixture.
about: Some(call.clone()), about: Some(call.clone()),
}); });
} }
@@ -260,23 +233,21 @@ impl EchoDriver {
/// One typed line, whether it arrived as a message or as a command. /// One typed line, whether it arrived as a message or as a command.
/// ///
/// `announce` is the difference and it is the whole of it: a message /// `announce` is the whole difference: a message is announced with
/// is announced with `MessageTaken`, which is what puts it in the /// `MessageTaken`, which is what puts it in the transcript, and a command is
/// transcript, and a command is not -- the manager has already /// not -- the manager has already recorded that one was sent, and saying so
/// recorded that one was sent, and saying so twice drew the same /// twice drew the same line in both colours.
/// line in both colours.
fn handle(&self, text: String, attachments: Vec<AttachmentRef>, announce: bool) { fn handle(&self, text: String, attachments: Vec<AttachmentRef>, announce: bool) {
let sink = self.sink.clone(); let sink = self.sink.clone();
// Mid-turn messages are held rather than answered, the way a real // Mid-turn messages are held rather than answered, the way a real CLI
// CLI holds them until the next tool boundary. Without this the // holds them until the next tool boundary. Without this the session went
// session went idle the instant one arrived, and every state that // idle the instant one arrived, and every state that only exists while
// only exists while something is queued was untestable. // something is queued was untestable.
if self.busy.load(Ordering::SeqCst) { if self.busy.load(Ordering::SeqCst) {
// The waiting is recorded, exactly as the real driver records // The waiting is recorded, exactly as the real driver records it:
// it: the phone draws its pending bubbles from the server, so // the phone draws its pending bubbles from the server, so an echo
// an echo session has to produce the same events or the states // session has to produce the same events.
// it exists to exercise are not the app's real ones.
let id = super::random_hex(); let id = super::random_hex();
self.queued self.queued
.lock() .lock()
@@ -292,17 +263,11 @@ impl EchoDriver {
return; return;
} }
// Answered on the spot rather than in the turn below, because a
// peer message is not a turn: it is something that arrives, and
// what is being exercised is the row it becomes. The message that
// asked for it is still announced -- every driver owes exactly one
// `MessageTaken` per message, and a command that quietly vanishes
// from the transcript is the one thing echo must not model.
// The live Claude Code shape, which is the one the ordering has to // The live Claude Code shape, which is the one the ordering has to
// survive: the CLI says nothing about a peer message until the // survive: the CLI says nothing about a peer message until the turn's
// turn's `result`, so the event arrives below the whole reply it // `result`, so the event arrives below the whole reply it caused and the
// caused and the phone has to put it back. Checked before `/peer`, // phone has to put it back. Checked before `/peer`, which would
// which would otherwise take the rest of this word as the body. // otherwise take the rest of this word as the body.
if let Some(rest) = text.strip_prefix("/peer-turn") { if let Some(rest) = text.strip_prefix("/peer-turn") {
if announce { if announce {
self.emit(Event::MessageTaken { self.emit(Event::MessageTaken {
@@ -357,11 +322,10 @@ impl EchoDriver {
return; return;
} }
// Answered here rather than in the turn below, because it is not // Answered here rather than in the turn below, because it is not a
// a turn: nothing is generated, and what is being exercised is // turn: nothing is generated, and what is being exercised is the
// the *other* screens -- the bar under the header, the button // *other* screens -- the bar under the header, the button beside it and
// beside it and the dialog it opens, all of which read the usage // the dialog it opens, which read the usage route, not this transcript.
// route rather than this transcript.
if let Some(rest) = text.strip_prefix("/usage") { if let Some(rest) = text.strip_prefix("/usage") {
if announce { if announce {
self.emit(Event::MessageTaken { self.emit(Event::MessageTaken {
@@ -380,9 +344,9 @@ impl EchoDriver {
return; return;
} }
// The same word the real CLI takes, so a phone drives both the same // The same word the real CLI takes, so a phone drives both the same way.
// way. `Driver::compact` is what the manager's own route calls; // `Driver::compact` is what the manager's route calls; this is the typed
// this is the typed path onto it. // path onto it.
if text.trim() == "/compact" { if text.trim() == "/compact" {
if announce { if announce {
self.emit(Event::MessageTaken { self.emit(Event::MessageTaken {
@@ -438,23 +402,20 @@ impl EchoDriver {
return; return;
} }
// Checked before `/tool`, which is a prefix of it: matching the // Checked before `/tool`, which is a prefix of it: matching the shorter
// shorter one first would read "/tools 4" as a single tool whose // one first would read "/tools 4" as a single tool whose input is "s 4".
// input is "s 4".
let many_tools = text.strip_prefix("/tools").map(|rest| { let many_tools = text.strip_prefix("/tools").map(|rest| {
let mut words = rest.split_whitespace(); let mut words = rest.split_whitespace();
// At least two, because one call is not a run of them and this // At least two, because one call is not a run of them.
// exists to produce a run.
let count = words let count = words
.next() .next()
.and_then(|w| w.parse().ok()) .and_then(|w| w.parse().ok())
.unwrap_or(3usize) .unwrap_or(3usize)
.clamp(2, 12); .clamp(2, 12);
// How long to wait between calls, default none. A run that // How long to wait between calls, default none. A run that arrives
// arrives all at once cannot exercise anything about a run // all at once cannot exercise a run *growing*: the case worth
// *growing*: the case worth watching is a call somebody has // watching is a call somebody has opened and is reading when the
// opened and is reading when the next one turns it into a // next one turns it into a group.
// group, and 50ms apart is faster than anybody can open one.
let gap = Duration::from_secs( let gap = Duration::from_secs(
words words
.next() .next()
@@ -473,21 +434,19 @@ impl EchoDriver {
let run_bash = text let run_bash = text
.strip_prefix("/bash") .strip_prefix("/bash")
.map(|rest| rest.trim().to_string()); .map(|rest| rest.trim().to_string());
// Seconds to stay running before answering, default 30. Clamped // Seconds to stay running before answering, default 30. Clamped rather
// rather than trusted: this is a test affordance, and a session // than trusted: a session pinned running for an hour by a typo is a
// pinned running for an hour by a typo is a worse outcome than a // worse outcome than a short wait.
// short wait.
let stream = text let stream = text
.strip_prefix("/stream") .strip_prefix("/stream")
.map(|rest| rest.trim().parse::<usize>().unwrap_or(400).clamp(1, 4000)); .map(|rest| rest.trim().parse::<usize>().unwrap_or(400).clamp(1, 4000));
let mixed = text let mixed = text
.strip_prefix("/mixed") .strip_prefix("/mixed")
.map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400)); .map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400));
// How many columns wide a fixture table should be, default six. // How many columns wide a fixture table should be, default six. The
// The count is the parameter because it is the thing the phone // count is the parameter because it is what the phone has to react to: a
// has to react to: a narrow table lays itself out across the // narrow table lays itself out across the screen and a wide one has to
// screen and a wide one has to start scrolling sideways, and the // scroll sideways, and the boundary is where the layout is wrong.
// boundary between the two is where the layout is wrong.
let table = text let table = text
.strip_prefix("/table") .strip_prefix("/table")
.map(|rest| rest.trim().parse::<usize>().unwrap_or(6).clamp(1, 12)); .map(|rest| rest.trim().parse::<usize>().unwrap_or(6).clamp(1, 12));
@@ -507,10 +466,9 @@ impl EchoDriver {
let _ = sink.send(event); let _ = sink.send(event);
}; };
let finish = || finish_turn(&sink, &queued, &busy); let finish = || finish_turn(&sink, &queued, &busy);
// Echo takes a message the instant it gets one, but it says so // Echo takes a message the instant it gets one, but says so anyway:
// anyway: a driver that skips this leaves the phone holding a // a driver that skips this leaves the phone holding a message it
// message it thinks is still queued, and the point of an echo // thinks is still queued.
// provider is that it behaves like the real ones.
if announce { if announce {
send(Event::MessageTaken { send(Event::MessageTaken {
id: None, id: None,
@@ -523,8 +481,7 @@ impl EchoDriver {
}); });
if let Some(linger) = linger { if let Some(linger) = linger {
// A delta a second: visibly alive rather than merely slow, // A delta a second: visibly alive rather than merely slow.
// which is what the states being looked at accompany.
let seconds = linger.as_secs(); let seconds = linger.as_secs();
for remaining in (1..=seconds).rev() { for remaining in (1..=seconds).rev() {
send(Event::AssistantText { send(Event::AssistantText {
@@ -567,16 +524,14 @@ impl EchoDriver {
"timeout": 5000, "timeout": 5000,
}), }),
}); });
// The first call carries a screenshot, and only the // The first call carries a screenshot, and only the first.
// first. That is what makes this rig cover the case a // That is what makes this rig cover the case a growing run
// growing run is actually about: an image opened full // is about: an image opened full screen from a call that is
// screen from a call that is alone, and then a second // alone, and then a second call turning that row into a
// call arriving and turning that row into a group. The // group. The dialog used to be inside the row, so the reader
// dialog used to be inside the row, so the reader was // was thrown back to the transcript by the session making
// thrown back to the transcript by the session making // another tool call. The first call is the one that is on
// another tool call. Any of the calls would do; the // its own for a whole `gap`.
// first is the one that is on its own for a whole
// `gap`, which is the window somebody can open it in.
if i == 1 { if i == 1 {
let part = serde_json::json!({ let part = serde_json::json!({
"source": {"media_type": "image/png", "data": SAMPLE_PNG} "source": {"media_type": "image/png", "data": SAMPLE_PNG}
@@ -600,9 +555,9 @@ impl EchoDriver {
// One long answer arriving in small pieces, which is what a real // One long answer arriving in small pieces, which is what a real
// model does and what `/slow` does not: `/slow` emits a line a // model does and what `/slow` does not: `/slow` emits a line a
// second, so its message grows in steps a reader can watch one // second, so its message grows in steps a reader can watch one at a
// at a time. A jump caused by the *anchor row itself* changing // time. A jump caused by the *anchor row itself* changing height
// height needs growth that is continuous. // needs growth that is continuous.
if let Some(pieces) = stream { if let Some(pieces) = stream {
for i in 0..pieces { for i in 0..pieces {
let len = 3 + (i * 7) % 14; let len = 3 + (i * 7) % 14;
@@ -667,7 +622,6 @@ impl EchoDriver {
}); });
} }
// Word-at-a-time so streaming is visibly streaming.
for word in format!("You said: {text}").split_inclusive(' ') { for word in format!("You said: {text}").split_inclusive(' ') {
send(Event::AssistantText { send(Event::AssistantText {
delta: word.to_string(), delta: word.to_string(),
@@ -675,8 +629,7 @@ impl EchoDriver {
tokio::time::sleep(DELTA_DELAY).await; tokio::time::sleep(DELTA_DELAY).await;
} }
// A conversation gets bigger, so the pretend context does too: // A conversation gets bigger, so the pretend context does too:
// roughly a hundred tokens a turn plus the words themselves, // roughly a hundred tokens a turn plus the words themselves.
// which is enough to watch it climb between compactions.
let spent = text.split_whitespace().count() as u64; let spent = text.split_whitespace().count() as u64;
send(Event::UsageDelta { send(Event::UsageDelta {
tokens: spent, tokens: spent,
@@ -703,36 +656,32 @@ impl EchoDriver {
} }
/// Sends are infallible from the driver's point of view: a closed sink /// Sends are infallible from the driver's point of view: a closed sink
/// means the session is being torn down, and there is nobody left to /// means the session is being torn down.
/// report to.
fn emit(&self, event: Event) { fn emit(&self, event: Event) {
let _ = self.sink.send(event); let _ = self.sink.send(event);
} }
} }
/// A 16x10 checkerboard, the smallest thing that is recognisably an image /// A 16x10 checkerboard, the smallest thing recognisably an image rather than a
/// rather than a blank rectangle. /// blank rectangle. Embedded rather than generated because the alternative is a
/// /// PNG encoder in a test rig, and what a scroll test needs from an image is
/// Embedded rather than generated because the alternative is a PNG encoder /// that it occupies an image's worth of space.
/// in a test rig, and drawn at the transcript's fixed thumbnail height
/// anyway -- what a scroll test needs from an image is that it occupies an
/// image's worth of space, not that it is pretty.
const SAMPLE_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAKCAIAAAAy3EnLAAAAIklEQVR42mPo3PILiOTk9ICIGDYDyRqIVwphk65h1A9EsAGCYdJRj+JH4wAAAABJRU5ErkJggg=="; const SAMPLE_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAKCAIAAAAy3EnLAAAAIklEQVR42mPo3PILiOTk9ICIGDYDyRqIVwphk65h1A9EsAGCYdJRj+JH4wAAAABJRU5ErkJggg==";
/// One beat of `/mixed`: a row shape chosen by position, so the same N /// One beat of `/mixed`: a row shape chosen by position, so the same N always
/// always produces the same transcript. /// produces the same transcript.
/// ///
/// Repeatable on purpose. A scrolling fault is judged by watching the same /// Repeatable on purpose. A scrolling fault is judged by watching the same
/// content behave differently, and a rig that produced a different /// content behave differently, and a rig that produced a different transcript
/// transcript each run would make every comparison an argument about /// each run would make every comparison an argument about whether the content
/// whether the content changed. /// changed.
async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) { async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
let send = |event: Event| { let send = |event: Event| {
let _ = sink.send(event); let _ = sink.send(event);
}; };
match beat % 5 { match beat % 5 {
// A paragraph, of three lengths, because a list of uniform rows // A paragraph, of three lengths, because a list of uniform rows hides
// hides exactly the faults that uneven ones expose. // exactly the faults that uneven ones expose.
1 => { 1 => {
let words = match beat % 3 { let words = match beat % 3 {
0 => 12, 0 => 12,
@@ -741,9 +690,8 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
}; };
// Deliberately ragged: each word's length is a function of its // Deliberately ragged: each word's length is a function of its
// position, so no two lines wrap the same way. A paragraph of // position, so no two lines wrap the same way. A paragraph of
// uniform tokens is a wall that looks identical at every // uniform tokens looks identical at every offset, which makes it
// offset, which makes it impossible to tell a scroll of one // impossible to tell a scroll of one line from a scroll of ten.
// line from a scroll of ten -- by eye or by comparing frames.
let body: String = (0..words) let body: String = (0..words)
.map(|w| { .map(|w| {
let len = 3 + (w * 7 + beat * 3) % 14; let len = 3 + (w * 7 + beat * 3) % 14;
@@ -767,8 +715,8 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
output: format!("beat {beat}: forty-two lines of nothing in particular"), output: format!("beat {beat}: forty-two lines of nothing in particular"),
}); });
} }
// A run of three, which the app folds into one collapsed group -- // A run of three, which the app folds into one collapsed group -- the
// the row whose identity depends on what is next to it. // row whose identity depends on what is next to it.
3 => { 3 => {
for i in 1..=3 { for i in 1..=3 {
let id = format!("t-{}", super::random_hex()); let id = format!("t-{}", super::random_hex());
@@ -815,30 +763,27 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
}); });
} }
} }
// Slow enough that the phone renders each beat as it arrives rather // Slow enough that the phone renders each beat as it arrives rather than
// than composing the whole run in one frame -- which is the condition // composing the whole run in one frame -- which is the condition a scrolling
// a scrolling fault actually happens under. // fault actually happens under.
tokio::time::sleep(Duration::from_millis(120)).await; tokio::time::sleep(Duration::from_millis(120)).await;
} }
/// A message written during a turn and waiting for it to end: the id of the /// A message written during a turn and waiting for it to end: the id of the
/// `MessageQueued` that announced it, what it said, and what was attached to /// `MessageQueued` that announced it, what it said, and what was attached. All
/// it. All three, because all three are what the `MessageTaken` at the other /// three, because all three are what the `MessageTaken` at the other end owes.
/// end owes -- named rather than written out at each of the four places that
/// mention it.
type Held = (String, String, Vec<AttachmentRef>); type Held = (String, String, Vec<AttachmentRef>);
/// A markdown table [columns] wide, with cells too long for one line. /// A markdown table [columns] wide, with cells too long for one line.
/// ///
/// Both halves of that matter. Long cells are what the renderer used to cut /// Both halves matter. Long cells are what the renderer used to cut off with an
/// off with an ellipsis, and a cut cell looks exactly like a short one, so /// ellipsis, and a cut cell looks exactly like a short one, so a fixture of
/// a fixture of tidy one-word values would have rendered perfectly while /// tidy one-word values would have rendered perfectly while the defect was
/// the defect was still there. The column count is what decides whether /// still there. The column count decides whether the table fits the screen.
/// the table fits the screen or has to scroll sideways.
/// ///
/// Written out as markdown rather than assembled from a grid type because /// Written out as markdown rather than assembled from a grid type because what
/// what is being tested is the renderer's parse of the syntax a model /// is being tested is the renderer's parse of the syntax a model actually
/// actually writes, pipes and alignment row included. /// writes, pipes and alignment row included.
fn markdown_table(columns: usize) -> String { fn markdown_table(columns: usize) -> String {
let headings = [ let headings = [
"What it is", "What it is",
@@ -890,17 +835,16 @@ fn markdown_table(columns: usize) -> String {
out out
} }
/// Ending a turn is also when anything held during it is taken up -- the /// Ending a turn is also when anything held during it is taken up -- the moment
/// moment a real CLI would have injected it. One place, because a turn has /// a real CLI would have injected it. One place, because a turn has several
/// several ways to end (a reply, an interrupt, a compaction) and every one /// ways to end (a reply, an interrupt, a compaction) and every one of them owes
/// of them owes the same answer. /// the same answer.
fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) { fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
let held = std::mem::take(&mut *queued.lock().unwrap()); let held = std::mem::take(&mut *queued.lock().unwrap());
for (id, text, attachments) in held { for (id, text, attachments) in held {
// Announced before it is answered, in that order: a phone showing // Announced before it is answered, in that order: a phone showing the
// the message as pending needs the signal that it has been read, // message as pending needs the signal that it has been read, and the
// and the answer is meaningless above a message still drawn as // answer is meaningless above a message still drawn as waiting.
// waiting.
let _ = sink.send(Event::MessageTaken { let _ = sink.send(Event::MessageTaken {
id: Some(id), id: Some(id),
text: text.clone(), text: text.clone(),
@@ -921,12 +865,10 @@ impl Driver for EchoDriver {
!self.busy.load(Ordering::SeqCst) !self.busy.load(Ordering::SeqCst)
} }
/// Really droppable, which is what makes this the rig for the phone's /// Really droppable, which is what makes this the rig for the phone's side
/// side of it: the held message is this driver's own and nothing has /// of it: the held message is this driver's own and nothing has been written
/// been written anywhere, so a tap here exercises the whole path /// anywhere, so a tap here exercises the whole path through to the bubble
/// through to the bubble disappearing on every device. The Claude /// disappearing on every device. The Claude driver can only ever refuse.
/// driver can only ever refuse -- see its own `unqueue` -- so it
/// cannot exercise the case where the drop succeeds.
fn unqueue(&self, id: &str) -> Unqueued { fn unqueue(&self, id: &str) -> Unqueued {
let mut queued = self.queued.lock().unwrap(); let mut queued = self.queued.lock().unwrap();
let Some(at) = queued.iter().position(|(waiting, ..)| waiting == id) else { let Some(at) = queued.iter().position(|(waiting, ..)| waiting == id) else {
@@ -939,18 +881,15 @@ impl Driver for EchoDriver {
} }
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) { fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
// Announced, because this is a message: every driver owes exactly // Announced, because this is a message: every driver owes exactly one
// one `MessageTaken` per message, and one that quietly vanishes // `MessageTaken` per message, and one that quietly vanishes from the
// from the transcript is the thing echo must not model. A command // transcript is the thing echo must not model. A command owes none.
// owes none -- the manager has already recorded that it was sent,
// and announcing it again drew the same line twice, once in each
// colour.
self.handle(text, attachments, true); self.handle(text, attachments, true);
} }
/// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` -- /// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` -- so
/// so this is the same path with the same parsing, and the fixture /// this is the same path with the same parsing, and the fixture behaves like
/// behaves like a real session driven the same way. /// a real session driven the same way.
fn run_command(&self, text: &str) { fn run_command(&self, text: &str) {
self.handle(text.to_string(), Vec::new(), false); self.handle(text.to_string(), Vec::new(), false);
} }
@@ -966,8 +905,8 @@ impl Driver for EchoDriver {
return; return;
}; };
let answered = pending.remove(at); let answered = pending.remove(at);
// Whether anything on the same call is still unanswered: a // Whether anything on the same call is still unanswered: a tool that
// tool that asked four questions ends once, not four times. // asked four questions ends once, not four times.
let waiting = answered let waiting = answered
.call .call
.as_ref() .as_ref()
@@ -982,9 +921,9 @@ impl Driver for EchoDriver {
id: call, id: call,
output: format!("answered: {answer}"), output: format!("answered: {answer}"),
}); });
// The work carries on where it left off, which is what makes // The work carries on where it left off, which is what makes the
// the asked-here row a boundary with a group on each side // asked-here row a boundary with a group on each side rather than
// rather than the last thing in the turn. // the last thing in the turn.
self.some_calls("after"); self.some_calls("after");
} else { } else {
self.emit(Event::AssistantText { self.emit(Event::AssistantText {
@@ -997,17 +936,16 @@ impl Driver for EchoDriver {
} }
fn interrupt(&self) { fn interrupt(&self) {
// Nothing real to stop; a pending question is abandoned so the // Nothing real to stop; a pending question is abandoned so the session
// session isn't stuck awaiting input forever. // isn't stuck awaiting input forever.
self.pending_questions.lock().unwrap().clear(); self.pending_questions.lock().unwrap().clear();
self.emit(Event::Status { self.emit(Event::Status {
state: SessionStatus::Idle, state: SessionStatus::Idle,
}); });
} }
// Nothing to forward: this process has no notion of what the // Nothing to forward: this process has no notion of what the conversation
// conversation is called, and the rename it belongs to has already // is called, and the rename has already happened where the name lives.
// happened where the name lives. See `Driver::set_title`.
fn set_title(&self, _title: &str) {} fn set_title(&self, _title: &str) {}
fn set_permission_mode(&self, mode: &str) { fn set_permission_mode(&self, mode: &str) {
@@ -1022,14 +960,11 @@ impl Driver for EchoDriver {
}); });
} }
/// A compaction with nothing to compact. /// A compaction with nothing to compact. The counts are invented, like
/// /// everything else this driver says -- what is real is the shape and the
/// The counts are invented, like everything else this driver says -- /// order: busy, a pause long enough to see, then the result. The only other
/// what is real is the shape and the order: busy, a pause long enough /// way to reach those states is to fill a real session's context and spend
/// to see, then the result. `Compacting` and `Compacted` are states a /// two minutes of somebody's account getting it back.
/// screen has to draw, and the only other way to reach them is to fill
/// a real session's context and spend two minutes of somebody's
/// account getting it back.
fn compact(&self) { fn compact(&self) {
let sink = self.sink.clone(); let sink = self.sink.clone();
let queued = Arc::clone(&self.queued); let queued = Arc::clone(&self.queued);
@@ -1041,10 +976,10 @@ impl Driver for EchoDriver {
state: SessionStatus::Compacting, state: SessionStatus::Compacting,
}); });
tokio::time::sleep(COMPACT_TIME).await; tokio::time::sleep(COMPACT_TIME).await;
// What it says it recovered is what the pretend context becomes, // What it says it recovered is what the pretend context becomes, so
// so the figure on the status row and the one on the divider // the figure on the status row and the one on the divider agree --
// agree -- two numbers about the same moment disagreeing is the // two numbers about the same moment disagreeing is the thing this
// thing this rig exists to catch. // rig exists to catch.
context.store(9_617, Ordering::SeqCst); context.store(9_617, Ordering::SeqCst);
let _ = sink.send(Event::Compacted { let _ = sink.send(Event::Compacted {
pre_tokens: Some(128_402), pre_tokens: Some(128_402),
+222 -321
View File
@@ -28,20 +28,15 @@ use super::transport::{Launch, Transport};
/// How much of a transcript's tail is replayed into the phone's view. /// How much of a transcript's tail is replayed into the phone's view.
/// ///
/// The imported conversation is for reading; *continuing* it is the CLI's /// The imported conversation is for reading; *continuing* it is the CLI's job
/// job through `--resume`, and it reads the whole file itself regardless /// through `--resume`, and it reads the whole file itself. So this is a
/// of what is shown here. So this is a display budget, not a fidelity one /// display budget, and it needs to be one: these files reach tens of megabytes
/// -- and it needs to be a budget, because these files reach tens of /// and every line would otherwise cross a WireGuard link to a phone.
/// megabytes (the session this feature was written in was 39 MB) and every
/// line of it would otherwise cross a WireGuard link to a phone.
const REPLAY_LINES: usize = 2000; const REPLAY_LINES: usize = 2000;
/// Whether a session is open in a CLI somewhere. /// Whether a session is open in a CLI somewhere. Three answers, because
/// /// "nobody could check" is not "nobody is using it" -- collapsing them puts
/// Three answers, because "nobody could check" is not "nobody is using /// the dangerous case behind the safe word.
/// it". Collapsing them would put the dangerous case behind the safe
/// word, which is how the expensive version of this happens: an import
/// that looks permitted, of a session that is being written to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum InUse { pub enum InUse {
@@ -49,8 +44,8 @@ pub enum InUse {
No, No,
/// Checked, and a live CLI has it open. /// Checked, and a live CLI has it open.
Yes, Yes,
/// The machine does not keep the record this is read from, so there is /// The machine does not keep the record this is read from, so there is no
/// no answer to be had -- not an answer of "no". /// answer to be had -- not an answer of "no".
Unknown, Unknown,
} }
@@ -61,113 +56,87 @@ pub struct Importable {
/// The CLI's own session id, which is both the file name and the /// The CLI's own session id, which is both the file name and the
/// `--resume` token. /// `--resume` token.
pub id: String, pub id: String,
/// Where that session was working, offered as the imported session's /// Where that session was working, offered as the imported session's cwd
/// cwd so it resumes pointing at the same tree. /// so it resumes pointing at the same tree.
pub cwd: String, pub cwd: String,
/// The first thing a person said in it, for recognising it in a list. /// The first thing a person said in it, for recognising it in a list.
pub title: String, pub title: String,
/// Epoch seconds, for ordering by "what I was last doing". /// Epoch seconds, for ordering by "what I was last doing".
pub modified: f64, pub modified: f64,
pub lines: usize, pub lines: usize,
/// How many tokens the model was holding at the last turn. /// How many tokens the model was holding at the last turn: the input side
/// /// of the most recent assistant message's usage, which is the closest thing
/// The input side of the most recent assistant message's usage -- /// to "what continuing this costs" and is a number the CLI recorded rather
/// prompt plus both cache figures -- which is the closest thing to /// than one inferred from the file.
/// "what continuing this costs", and unlike the size it is a number
/// the CLI itself recorded rather than one inferred from the file.
/// ///
/// Size and this disagree in the direction that matters. Most of a big /// Size and this disagree in the direction that matters. Most of a big
/// transcript is usually history from before a compaction, which the /// transcript is usually history from before a compaction, which the model
/// model is no longer given: of the 133 MB session behind the /// is no longer given: of the 133 MB session behind the 2026-08-29
/// 2026-08-29 incident, 99% of the bytes sat before its last /// incident, 99% of the bytes sat before its last compaction summary.
/// compaction summary. A 77 MB file whose context is 10k tokens is
/// cheap to continue; a smaller one that has never compacted may not
/// be.
/// ///
/// `None` when no assistant turn has recorded usage yet -- which is /// `None` when no assistant turn has recorded usage yet -- which is not
/// not zero, and is why this is an option rather than a default. /// zero, and is why this is an option.
pub context_tokens: Option<u64>, pub context_tokens: Option<u64>,
/// Size of the file, in bytes. /// Size of the file, in bytes. Reported because it predicts what
/// continuing the session will cost and lines do not: these transcripts
/// embed screenshots as base64, so one line can be a megabyte. The session
/// behind the 2026-08-29 incident was 65 MB across 13,000 lines.
/// ///
/// Reported because it is the only thing on a row that predicts what /// Shown rather than warned about: importing a large session is a choice
/// continuing the session will cost, and lines do not: these /// somebody is entitled to make.
/// transcripts embed screenshots as base64, so one line can be a
/// megabyte. The session behind the 2026-08-29 incident was 65 MB
/// across 13,000 lines, which is a line count that looks unremarkable.
///
/// Shown rather than warned about. Importing a large session is a
/// choice somebody is entitled to make, and marking it would be the
/// interface nagging about a decision already taken -- but they should
/// be able to see what they are taking on.
pub bytes: u64, pub bytes: u64,
/// Whether [`title`](Self::title) is a name somebody chose rather than /// Whether [`title`](Self::title) is a name somebody chose rather than
/// something read out of the conversation. Sorted on, and worth the /// something read out of the conversation. Worth the reader knowing: a name
/// reader knowing: a name is a claim about what a session *is*, and a /// is a claim about what a session *is*, and a last message is only the
/// last message is only the last thing that happened in it. /// last thing that happened in it.
pub named: bool, pub named: bool,
/// Whether a CLI is running this session right now. /// Whether a CLI is running this session right now.
/// ///
/// The load-bearing field on this struct. Importing a session that is /// The load-bearing field on this struct. Importing a session already open
/// already open puts a second `--resume` on one file: the whole /// puts a second `--resume` on one file: the conversation gets duplicated
/// conversation gets duplicated into it, both copies then read each /// into it, both copies read each other's writes as work done elsewhere,
/// other's writes as work done elsewhere, and the adopted one is /// and the adopted one is billed for re-reading everything -- measured on
/// billed for re-reading everything -- measured on 2026-08-29 at 65 MB /// 2026-08-29 at 65 MB and 154 screenshots.
/// and 154 screenshots, from importing the session the importing agent
/// was itself running in.
pub in_use: InUse, pub in_use: InUse,
/// Where it lives. Not serialized: the phone chooses by id and the /// Where it lives. Not serialized: the phone chooses by id and the server
/// server resolves the path, so a path never crosses the wire in /// resolves the path, so a path never crosses the wire either direction.
/// either direction.
#[serde(skip)] #[serde(skip)]
pub path: String, pub path: String,
} }
/// Asks `transport`'s machine which Claude Code sessions it has. /// Asks `transport`'s machine which Claude Code sessions it has.
/// ///
/// One command rather than one per file, for the reason `setups::discover` /// One command rather than one per file: over ssh each would be its own
/// gives: over ssh each would be its own connection and handshake. /// connection and handshake. `stat -c` is GNU-specific, which is the thing to
/// /// change first if this ever meets a BSD.
/// `stat -c` is GNU-specific, which is fine for the machines here and is
/// the thing to change first if this ever meets a BSD.
pub async fn list(transport: &Transport) -> Result<Vec<Importable>> { pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
// Which sessions are open right now, before the files themselves. // Which sessions are open right now, before the files themselves.
// //
// Claude Code writes a descriptor per live session at // Claude Code writes a descriptor per live session at
// `~/.claude/sessions/<pid>.json`, and the pid is the file name. It // `~/.claude/sessions/<pid>.json`, and records `procStart` -- the kernel's
// also records `procStart` -- the kernel's start time for that pid -- // start time for that pid -- for the same reason `session::process` does: a
// for the same reason `session::process` does: a pid on its own is // pid on its own is reused, so a descriptor left by a crashed CLI would
// reused, so a descriptor left behind by a CLI that crashed would // otherwise mark a session as open for as long as something else held its
// otherwise mark a session as open for as long as something else held // number. Checking both is what makes this a measurement.
// its number. Checking both is what makes this a measurement.
// //
// The `LIVEKNOWN` line says the directory was there to be read at // The `LIVEKNOWN` line says the directory was there to be read at all.
// all. Without it an old CLI that keeps no descriptors would look // Without it an old CLI that keeps no descriptors would look exactly like a
// exactly like a machine with nothing running, which is the one // machine with nothing running.
// mistake this check exists to prevent.
// //
// Then two questions per file, both answered from the end of it. // Then two questions per file, both answered from the end of it. A rename
// if there was one, grepped over the whole file rather than its tail
// because a session can be named early and talked in for hours after. Then
// the last several things a person said -- the *last*, because the question
// this answers is "which one was I just in", and several because the final
// ones are often the CLI's own.
// //
// A rename, if there was one: `/rename` appends a `custom-title` // Tool results are excluded rather than typed messages included, and the
// record, and a name somebody chose beats anything inferred from the // difference matters: a tool result is *also* a user record, so grepping
// conversation. Grepped over the whole file rather than its tail, // the type alone gave a session that ended mid-tool a tail of empty records.
// because a session can be named early and talked in for hours after. // But matching only a string `content` was worse -- a message carrying an
// // attachment stores its text in a list, so that reading lost twenty rows
// Then the last several things a person said. The *last*, not the // rather than two. Excluding `tool_use_id` keeps both shapes of a real
// first: the question a list like this answers is "which one was I // message and drops the one that is not.
// just in", and every session's opening line is the least distinctive
// thing about it. Several, because the final ones are often the CLI's
// own -- a slash command, the caveat wrapped around its output -- and
// one of those identifies nothing.
//
// Tool results are excluded rather than typed messages included, and
// the difference matters: a tool result is *also* a user record --
// it is how the API models one -- so grepping the type alone gave a
// session that ended mid-tool a tail of empty records and a row
// saying nothing was said, when plenty was. But matching only a
// string `content` was worse: a message carrying an attachment stores
// its text in a list, so that reading lost twenty rows rather than
// two. Excluding `tool_use_id` keeps both shapes of a real message
// and drops the one that is not.
let script = listing_script(r#""$HOME"/.claude/projects/*/*.jsonl"#); let script = listing_script(r#""$HOME"/.claude/projects/*/*.jsonl"#);
let launch = Launch::new("sh", vec!["-c".to_string(), script], None); let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
parse_listing(&transport.capture(&launch).await?) parse_listing(&transport.capture(&launch).await?)
@@ -175,13 +144,10 @@ pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
/// The same listing, for one session named by id. /// The same listing, for one session named by id.
/// ///
/// Importing needs everything a row holds -- the path to follow, how many /// Importing needs everything a row holds, and used to get it by listing
/// lines have already been written, what it is called, where it was working /// *every* session and searching the result -- a full read of every transcript
/// and whether something else has it open -- and used to get them by /// on the machine, seconds of it, to answer a question about one file, paid
/// listing *every* session and searching the result. That is a full read of /// once per import in a batch. Same script, same parsing, one glob narrower.
/// every transcript on the machine, seconds of it, to answer a question
/// about one file; a batch of imports paid it once each. Same script, same
/// parsing, one glob narrower.
pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>> { pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>> {
if !is_session_id(id) { if !is_session_id(id) {
return Ok(None); return Ok(None);
@@ -199,18 +165,14 @@ pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>>
/// What the machine is asked, over whichever set of files `glob` names. /// What the machine is asked, over whichever set of files `glob` names.
/// ///
/// One script with the glob substituted rather than two that drift: the /// One script with the glob substituted rather than two that drift: a row has
/// per-file half decides what a row *is*, and a row has to mean the same /// to mean the same thing whether it came from a listing or a lookup. The glob
/// thing whether it arrived from a listing or from a lookup. The glob is /// is this module's own text; the only thing that crosses from outside is the
/// this module's own text; the only thing that ever crosses from outside is /// id, which stays an argument and is checked by [`is_session_id`] first.
/// the id, which stays an argument (`$1`) and is checked by
/// [`is_session_id`] first.
fn listing_script(glob: &str) -> String { fn listing_script(glob: &str) -> String {
// `replace` rather than `format!`: this is shell, so it is full of // `replace` rather than `format!`: this is shell, so it is full of braces,
// braces -- `${s##*/}`, an awk program, the `{[^}]*` that finds a usage // and every one would have to be doubled to survive a format string --
// record -- and every one of them would have to be doubled to survive a // exactly the kind of edit that looks right and changes what the shell runs.
// format string. Doubling braces inside a script is exactly the kind of
// edit that looks right and changes what the shell runs.
SCRIPT.replace("{glob}", glob) SCRIPT.replace("{glob}", glob)
} }
@@ -260,21 +222,18 @@ fn parse_listing(found: &str) -> Result<Vec<Importable>> {
}; };
} }
// One row per session id, because the id is what everything downstream // One row per session id, because the id is what everything downstream
// addresses: `--resume` takes it, deleting globs for it, and the // addresses: `--resume` takes it, deleting globs for it, the in-flight
// in-flight registry is keyed on it. So two rows sharing an id are two // registry is keyed on it, and the phone keys its list on it -- which
// rows that no operation can tell apart -- and the phone keys its list // turned two rows sharing an id into a crash rather than a confusion.
// on it too, which turned this into a crash rather than a confusion.
// //
// It is a real state of the machine, not corruption: resuming a session // It is a real state of the machine, not corruption: resuming from a
// from a different working directory makes the CLI write a second file // different working directory makes the CLI write a second file under that
// under that directory's project folder with the same id. One of the two // directory's project folder with the same id. One is then usually a stub
// is then usually a stub of a few hundred bytes and the other is the // of a few hundred bytes.
// conversation somebody means.
// //
// So the copy with the most in it wins, and the row's `cwd` comes from // So the copy with the most in it wins, and the row's `cwd` comes from that
// that same copy -- which is the directory `--resume` will find it under. // same copy. Ties go to the more recent, and the *stub* is often the more
// Ties go to the more recent, and the *stub* is often the more recent, so // recent, so size has to be the first key rather than the tie-break.
// the size has to be the first key rather than the tie-break.
sessions.sort_by(|a, b| { sessions.sort_by(|a, b| {
b.lines b.lines
.cmp(&a.lines) .cmp(&a.lines)
@@ -283,12 +242,10 @@ fn parse_listing(found: &str) -> Result<Vec<Importable>> {
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
sessions.retain(|session| seen.insert(session.id.clone())); sessions.retain(|session| seen.insert(session.id.clone()));
// Most recent first, and only that. Naming was tried as the first key // Most recent first, and only that. Naming was tried as the first key and
// and is a worse list: it buries what somebody was just doing under // is a worse list: it buries what somebody was just doing under everything
// everything they ever named, and the reason to open this screen is // they ever named. A name still shows, as the row's title and as a word
// almost always to pick up where they left off. A name still shows, // beside it.
// as the row's title and as a word beside it -- being easier to
// recognise is what a name is for, and it does not need the order too.
sessions.sort_by(|a, b| b.modified.total_cmp(&a.modified)); sessions.sort_by(|a, b| b.modified.total_cmp(&a.modified));
Ok(sessions) Ok(sessions)
} }
@@ -322,23 +279,21 @@ fn parse_row(line: &str) -> Option<Importable> {
if !is_hidden(&record) if !is_hidden(&record)
&& let Some(text) = first_line_of(&record) && let Some(text) = first_line_of(&record)
{ {
// Kept rather than broken out of: these arrive oldest first, // Kept rather than broken out of: these arrive oldest first, so the
// so the last one to survive the filter is the most recent // last to survive the filter is the most recent thing said.
// thing that was actually said.
said = Some(text); said = Some(text);
} }
} }
Some(Importable { Some(Importable {
id, id,
// Filled in by `list`, which is the only thing that knows: it // Filled in by `list`, which is the only thing that knows: it takes one
// takes one command to ask a machine, and asking per row would be // command to ask a machine, and asking per row would be one ssh
// one ssh connection each. // connection each.
in_use: InUse::Unknown, in_use: InUse::Unknown,
cwd: cwd.unwrap_or_default(), cwd: cwd.unwrap_or_default(),
// A name somebody typed outranks anything read out of the // A name somebody typed outranks anything read out of the conversation,
// conversation, because they chose it to answer this exact // because they chose it to answer this exact question.
// question.
named: named.is_some(), named: named.is_some(),
title: named title: named
.or(said) .or(said)
@@ -351,24 +306,21 @@ fn parse_row(line: &str) -> Option<Importable> {
}) })
} }
/// The input tokens named in one `usage` object, added up. /// The input tokens named in one `usage` object, added up: prompt plus cache
/// /// creation plus cache read, all three being context the model was given. The
/// Prompt plus cache creation plus cache read: all three are context the /// definition is [`driver::context_tokens`]; this is the same three figures dug
/// model was given -- the definition is [`driver::context_tokens`]; this /// out of a raw line rather than a parsed one, because these files reach tens
/// is the same three figures dug out of a raw line rather than a parsed /// of megabytes.
/// one, because these files reach tens of megabytes.
/// ///
/// `None` for an empty blob, meaning no assistant turn has recorded usage. /// `None` for an empty blob, meaning no assistant turn has recorded usage.
/// Missing individual fields count as zero, which is what an absent /// Missing fields count as zero, which is what an absent category means.
/// category means; an unparseable one does the same rather than
/// discarding the figures that did read.
fn context_tokens(usage: &str) -> Option<u64> { fn context_tokens(usage: &str) -> Option<u64> {
if usage.trim().is_empty() { if usage.trim().is_empty() {
return None; return None;
} }
// The leading quote matters: without it `"input_tokens"` also matches // The leading quote matters: without it `"input_tokens"` also matches
// inside `"cache_read_input_tokens"`, and the same number gets counted // inside `"cache_read_input_tokens"`, and the same number is counted three
// three times. // times.
let field = |name: &str| -> u64 { let field = |name: &str| -> u64 {
usage usage
.split_once(&format!("\"{name}\":")) .split_once(&format!("\"{name}\":"))
@@ -388,12 +340,10 @@ fn context_tokens(usage: &str) -> Option<u64> {
/// The first line of what a person typed, short enough for a list row. /// The first line of what a person typed, short enough for a list row.
/// ///
/// None for the CLI's own plumbing. A slash command, the caveat wrapped /// None for the CLI's own plumbing. A slash command, the caveat wrapped around
/// around a local command's output, and an injected reminder are all /// a local command's output, and an injected reminder are all stored as
/// stored as ordinary user records without the `isMeta` flag -- so titling /// ordinary user records without `isMeta` -- so titling by "first user record"
/// by "first user record" gave a list where most rows read /// gave a list where most rows read `<command-name>/clear</command-name>`.
/// `<command-name>/clear</command-name>`, which identifies nothing. The
/// caller offers several candidates for exactly this reason.
fn first_line_of(record: &Value) -> Option<String> { fn first_line_of(record: &Value) -> Option<String> {
let text = text_of(record.get("message")?.get("content")?); let text = text_of(record.get("message")?.get("content")?);
let first = text.lines().find(|line| !line.trim().is_empty())?.trim(); let first = text.lines().find(|line| !line.trim().is_empty())?.trim();
@@ -404,19 +354,15 @@ fn first_line_of(record: &Value) -> Option<String> {
(!trimmed.is_empty()).then_some(trimmed) (!trimmed.is_empty()).then_some(trimmed)
} }
/// Records the transcript should not show: a subagent's private /// Records the transcript should not show: a subagent's private conversation,
/// conversation, and the CLI's own injected notes. /// and the CLI's own injected notes. The same rule the live translator applies
/// /// -- a sidechain is another agent talking to itself, and duplicating it would
/// The same rule the live translator applies -- a sidechain is another
/// agent talking to itself, and duplicating it into this transcript would
/// show the reader two conversations interleaved as one. /// show the reader two conversations interleaved as one.
fn is_hidden(record: &Value) -> bool { fn is_hidden(record: &Value) -> bool {
record.get("isSidechain").and_then(Value::as_bool) == Some(true) record.get("isSidechain").and_then(Value::as_bool) == Some(true)
|| record.get("isMeta").and_then(Value::as_bool) == Some(true) || record.get("isMeta").and_then(Value::as_bool) == Some(true)
} }
/// Concatenated text of a message's content, which is either a bare string
/// or the API's list of blocks.
fn text_of(content: &Value) -> String { fn text_of(content: &Value) -> String {
match content { match content {
Value::String(text) => text.clone(), Value::String(text) => text.clone(),
@@ -432,36 +378,31 @@ fn text_of(content: &Value) -> String {
/// Whether a directory the machine recorded is still there. /// Whether a directory the machine recorded is still there.
/// ///
/// Asked because a session's recorded cwd can outlive the directory: these /// A session's recorded cwd can outlive the directory: these files go back
/// files go back months, and a checkout that moved leaves every session /// months, and a checkout that moved leaves every session from before it
/// from before the move pointing at a path that is gone. Resuming into one /// pointing at a path that is gone. Resuming into one fails at `cd` before the
/// fails at `cd` before the CLI starts, which is a confusing way to meet a /// CLI starts.
/// feature whose whole promise is "carry on where you left off".
pub async fn directory_exists(transport: &Transport, path: &str) -> bool { pub async fn directory_exists(transport: &Transport, path: &str) -> bool {
if path.is_empty() { if path.is_empty() {
return false; return false;
} }
// Asked by *entering* it rather than by `test -d <path>`, because the // Asked by *entering* it rather than by `test -d <path>`, because the
// question this is standing in for is "can a session start here" and // question this stands in for is "can a session start here" and because a
// because a path is only expanded where it is a working directory -- // path is only expanded where it is a working directory -- `~/repos/ai-app`
// `~/repos/ai-app` as an argument stays four literal characters on // as an argument stays literal on both transports, so the old form answered
// both transports (`ssh::quote_path`, `ssh::expand_home`), so the old // "no such directory" about every home-relative path somebody typed.
// form answered "no such directory" about every home-relative path
// somebody typed.
let launch = Launch::new("true", Vec::new(), Some(std::path::Path::new(path))); let launch = Launch::new("true", Vec::new(), Some(std::path::Path::new(path)));
transport.capture(&launch).await.is_ok() transport.capture(&launch).await.is_ok()
} }
/// Reads the tail of one session's file, as the raw JSONL. /// Reads the tail of one session's file, as the raw JSONL.
/// ///
/// `tail` rather than the whole file, and as [`Launch`] arguments rather /// `tail` rather than the whole file, and as [`Launch`] arguments rather than a
/// than a shell string, so the path is an argument and never syntax. /// shell string, so the path is an argument and never syntax.
/// ///
/// Returns text rather than events because turning records into events has /// Returns text rather than events because turning records into events has a
/// a side effect -- writing out the images they carry -- and it needs the /// side effect -- writing out the images they carry -- and that needs the
/// session directory to write them into. That directory does not exist /// session directory, which does not exist until after this runs.
/// until the session is created, which is after this runs, so the
/// conversion happens there instead. See [`events_from`].
pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> { pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> {
let launch = Launch::new( let launch = Launch::new(
"tail", "tail",
@@ -477,32 +418,28 @@ pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> {
/// Claude Code's stored JSONL as this project's events. /// Claude Code's stored JSONL as this project's events.
/// ///
/// A partial first line is expected and ignored: `tail -n` cuts at a line /// A partial first line is expected and ignored: `tail -n` cuts at a line
/// boundary, but the *file* may have been appended to since, and a line /// boundary, but the *file* may have been appended to since.
/// that does not parse is one this reader has no opinion about.
/// ///
/// `session_dir` is where images found along the way are written, the same /// `session_dir` is where images found along the way are written, the same
/// place and by the same function the live translator uses -- so a /// place and by the same function the live translator uses -- so a screenshot
/// screenshot looks identical whether it was watched as it happened or /// looks identical whether it was watched happening or replayed afterwards.
/// replayed afterwards. It is only the *reference* that reaches the phone; /// Only the *reference* reaches the phone.
/// the bytes are fetched from `/sessions/{id}/files/{ref}` when something
/// actually draws them, and none of this is ever sent back to the CLI,
/// which reads its own session file.
pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> { pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
let mut events = Vec::new(); let mut events = Vec::new();
// What the newest record that had an opinion says the session is // What the newest record that had an opinion says the session is doing.
// doing. Kept to the end rather than pushed as it is found, because // Kept to the end rather than pushed as it is found, because the answer is
// the answer is the last one and everything before it is history. // the last one and everything before it is history.
let mut state = None; let mut state = None;
for line in text.lines() { for line in text.lines() {
let Ok(record) = serde_json::from_str::<Value>(line) else { let Ok(record) = serde_json::from_str::<Value>(line) else {
continue; continue;
}; };
if let Some(peer) = peer_message(&record) { if let Some(peer) = peer_message(&record) {
// Before `is_hidden`, which these records are: the CLI marks // Before `is_hidden`, which these records are: the CLI marks them
// them meta because they are not the user's own words, and // meta because they are not the user's own words, and that is the
// that is the reason to draw them differently rather than the // reason to draw them differently rather than to drop them. A
// reason to drop them. A session working on something a phone // session working on something a phone never asked for is otherwise
// never asked for is otherwise unexplainable from the phone. // unexplainable from the phone.
state = turn_state(&record).or(state); state = turn_state(&record).or(state);
events.push(peer); events.push(peer);
continue; continue;
@@ -531,20 +468,18 @@ pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
/// A message from another agent, as the CLI reports one. /// A message from another agent, as the CLI reports one.
/// ///
/// Measured from a real session file (2026-08-29): the record is a `user` /// Measured from a real session file (2026-08-29): the record is a `user` one
/// one marked `isMeta`, and its `origin` carries `kind: "peer"`, the /// marked `isMeta`, and its `origin` carries `kind: "peer"`, the sending
/// sending session's `name`, and the message itself as `body`. The /// session's `name`, and the message as `body`. The message content beside it
/// message content beside it is the same text wrapped in an explanatory /// is the same text wrapped in a preamble written for the model rather than for
/// preamble and a `<cross-session-message>` tag, which is written for the /// a person, so the body is what a reader is shown.
/// model that has to read it rather than for a person -- so the body is
/// what a reader is shown, and the name is who they are told sent it.
/// ///
/// Shared with the live driver (`claude::translate`), which finds the same /// Shared with the live driver, which finds the same `origin` object on a
/// `origin` object on a different record -- so this reads the object and /// different record -- so this reads the object and not the record around it.
/// not the record around it. One function because it is one wire format: /// One function because it is one wire format: two copies would drift the first
/// two copies would drift the first time the CLI renames a field, and the /// time the CLI renames a field, and the half that drifted would produce
/// half that drifted would go on producing nothing at all, which is /// nothing at all, which is indistinguishable from nobody having sent
/// indistinguishable from nobody having sent anything. /// anything.
pub(in crate::session) fn peer_message(record: &Value) -> Option<Event> { pub(in crate::session) fn peer_message(record: &Value) -> Option<Event> {
let origin = record.get("origin")?; let origin = record.get("origin")?;
if origin.get("kind").and_then(Value::as_str) != Some("peer") { if origin.get("kind").and_then(Value::as_str) != Some("peer") {
@@ -562,27 +497,22 @@ pub(in crate::session) fn peer_message(record: &Value) -> Option<Event> {
}) })
} }
/// Whether this record means the session is working, as far as it can be /// Whether this record means the session is working, as far as the file can
/// told from the file. /// say.
/// ///
/// The one thing a session file does not contain is the CLI saying "this /// The one thing a session file does not contain is the CLI saying "this turn
/// turn is over": there is no `result` record, only the messages. What /// is over": there is no `result` record. What there is instead is why the last
/// there is instead is why the last assistant message stopped, and that /// assistant message stopped -- `tool_use` means a call is being made and more
/// answers it -- `tool_use` means a call is being made and more is coming, /// is coming, anything else means the model has finished talking. Anything on
/// anything else means the model has finished talking. Anything on the /// the user's side means the session has something to answer.
/// user's side of the conversation -- a person, a tool's result, another
/// agent -- means the session has something to answer and is answering it.
/// ///
/// `None` is the third answer and it matters: a record that says nothing /// `None` is the third answer and it matters: a record that says nothing about
/// about the turn leaves the status alone rather than voting for idle. The /// the turn leaves the status alone rather than voting for idle.
/// same goes for a record whose reason for stopping is missing, which is
/// what a future CLI adding a shape we do not know looks like.
/// ///
/// What this cannot see is a session that stopped existing mid-turn -- its /// What this cannot see is a session that stopped existing mid-turn -- its
/// file's last record still says `tool_use`, so it reads as working /// file's last record still says `tool_use`, so it reads as working forever.
/// forever. Nothing in the file distinguishes that from a model thinking, /// Nothing in the file distinguishes that from a model thinking, and inventing
/// and inventing a timeout here would replace a stale reading with a /// a timeout would replace a stale reading with a confident wrong one.
/// confident wrong one.
fn turn_state(record: &Value) -> Option<super::driver::SessionStatus> { fn turn_state(record: &Value) -> Option<super::driver::SessionStatus> {
use super::driver::SessionStatus; use super::driver::SessionStatus;
match record.get("type").and_then(Value::as_str)? { match record.get("type").and_then(Value::as_str)? {
@@ -600,20 +530,19 @@ fn turn_state(record: &Value) -> Option<super::driver::SessionStatus> {
fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::Path) { fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::Path) {
// A tool result arrives as a user record, because that is how the API // A tool result arrives as a user record, because that is how the API
// models it -- but it is the other half of a tool call, not something // models it -- but it is the other half of a tool call, and showing it as a
// a person said, and showing it as a message would put the reader's // message would put the reader's own words and a command's output in the
// own words and a command's output in the same voice. // same voice.
if let Value::Array(blocks) = content { if let Value::Array(blocks) = content {
for block in blocks { for block in blocks {
// A picture the person attached to their own message, rather // A picture the person attached to their own message rather than
// than one a tool produced. Same block shape, one level up. // one a tool produced. Same block shape, one level up.
push_images(events, std::slice::from_ref(block), session_dir, None); push_images(events, std::slice::from_ref(block), session_dir, None);
if block.get("type").and_then(Value::as_str) == Some("tool_result") if block.get("type").and_then(Value::as_str) == Some("tool_result")
&& let Some(id) = block.get("tool_use_id").and_then(Value::as_str) && let Some(id) = block.get("tool_use_id").and_then(Value::as_str)
{ {
// Before the tool's own row, matching the live translator: // Before the tool's own row, matching the live translator: a
// a screenshot belongs to the call that took it, and after // screenshot belongs to the call that took it.
// the result it reads as belonging to whatever came next.
if let Some(Value::Array(parts)) = block.get("content") { if let Some(Value::Array(parts)) = block.get("content") {
push_images(events, parts, session_dir, Some(id)); push_images(events, parts, session_dir, Some(id));
} }
@@ -626,12 +555,10 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
} }
let text = text_of(content); let text = text_of(content);
if !text.trim().is_empty() { if !text.trim().is_empty() {
// Replayed from the CLI's own file: it was read long ago, so // Replayed from the CLI's own file: it was read long ago, so there is
// there is no waiting bubble for it to resolve. // no waiting bubble for it to resolve. Its images are saved and
// The images in this record are saved and referenced separately just // referenced separately just above, because they came out of somebody
// above, because a replayed message's pictures came out of somebody // else's file rather than this app's composer.
// else's file rather than out of this app's composer -- there is no
// upload here whose refs could ride on the message.
events.push(Event::UserMessage { events.push(Event::UserMessage {
id: None, id: None,
text, text,
@@ -640,11 +567,10 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
} }
} }
/// Saves every image block in `parts` and references each one. /// Saves every image block in `parts` and references each one. `about` is the
/// /// call the images came out of, or `None` for one a person attached to their
/// `about` is the call the images came out of, or `None` for one a person attached /// own message -- the same distinction the live translator makes, so replayed
/// to their own message -- the same distinction the live translator makes, so replayed /// history draws a screenshot under the call that took it.
/// history draws a screenshot under the call that took it exactly as a live one does.
fn push_images( fn push_images(
events: &mut Vec<Event>, events: &mut Vec<Event>,
parts: &[Value], parts: &[Value],
@@ -698,21 +624,17 @@ fn push_assistant(events: &mut Vec<Event>, content: &Value) {
/// Removes each id it is given and prints one `<id>\t<state>` line per id. /// Removes each id it is given and prints one `<id>\t<state>` line per id.
/// ///
/// The three states are every way removing one id can end: `deleted` if at /// The three states are every way removing one id can end: `deleted` if at
/// least one file went, `missing` if the glob matched nothing, `failed` if /// least one file went, `missing` if the glob matched nothing, `failed` if an
/// an `rm` refused. "Not there" is deliberately kept apart from "it broke" /// `rm` refused. "Not there" is deliberately kept apart from "it broke" --
/// rather than folded together by the shell -- only one of them is worth /// only one of them is worth retrying.
/// retrying, and the caller is what knows how to word either.
/// ///
/// Every copy of each id, not the first. The same id can name a file under /// Every copy of each id, not the first. The same id can name a file under two
/// two project directories -- see the de-duplication in `parse_listing` -- /// project directories, and stopping at the first left the other behind, so the
/// and stopping at the first left the other behind, so the row came back on /// row came back on the next listing after a delete that reported success.
/// the next listing after a delete that had reported success. `failed` /// `failed` therefore sticks once set.
/// therefore sticks once set: one copy removed and another refused is not a
/// success.
/// ///
/// Ids arrive as arguments rather than in the script text, so nothing here /// Ids arrive as arguments rather than in the script text; `is_session_id` is
/// is shell syntax; `is_session_id` is what keeps one from globbing its way /// what keeps one from globbing its way out of the projects directory.
/// out of the projects directory.
const DELETE_SCRIPT: &str = r#" const DELETE_SCRIPT: &str = r#"
for id do for id do
state=missing state=missing
@@ -730,37 +652,30 @@ done
/// Deletes sessions [`list`] reported, and says what happened to each. /// Deletes sessions [`list`] reported, and says what happened to each.
/// ///
/// By id, resolved on the machine against what it actually has, so the /// By id, resolved on the machine against what it actually has, so the caller
/// caller never names a file -- the same rule importing follows, and it /// never names a file -- the same rule importing follows, and it matters more
/// matters more here: this one removes something. /// here: this one removes something.
/// ///
/// Irreversible, and the caller is expected to have said so. Claude Code /// Irreversible, and the caller is expected to have said so. Claude Code keeps
/// keeps no copy: the JSONL *is* the session, so deleting it ends any /// no copy: the JSONL *is* the session.
/// chance of resuming that conversation, including from an ai-app session
/// that was already importing it.
/// ///
/// The whole batch in one invocation, which over ssh is the difference /// The whole batch in one invocation, which over ssh is the difference between
/// between one connection and one per session. Six deletes started in the /// one connection and one per session. Six deletes started in the same tick
/// same tick were six `ssh` processes racing to authenticate, and a batch /// were six `ssh` processes racing to authenticate, and a batch past the remote
/// big enough to pass the remote sshd's `MaxStartups` (10 unauthenticated /// sshd's `MaxStartups` had rows come back as `Connection closed by …` -- a row
/// connections, by default, before it begins refusing) had rows come back /// reporting a delete that never ran, for a reason nothing to do with the
/// as `Connection closed by … port 2222` -- a row reporting a delete that /// session.
/// never ran, for a reason that has nothing to do with the session. One
/// connection cannot exceed that however many ids are selected.
/// ///
/// Still one outcome per id, because a batch is not a transaction: six /// Still one outcome per id, because a batch is not a transaction. Every
/// removals that must all succeed or all roll back is not something a /// requested id gets an entry, so an id the machine said nothing about is
/// filesystem offers, and the caller settles each row from its own line. /// reported as such rather than defaulting to either answer.
/// Every requested id gets an entry, so an id the machine said nothing
/// about is reported as such rather than defaulting to either answer.
pub async fn delete( pub async fn delete(
transport: &Transport, transport: &Transport,
ids: &[String], ids: &[String],
) -> Result<HashMap<String, Result<(), String>>> { ) -> Result<HashMap<String, Result<(), String>>> {
// Refused here rather than on the machine: `is_session_id` is what // Refused here rather than on the machine: `is_session_id` is what keeps an
// keeps an id from walking out of the projects directory, and a bad // id from walking out of the projects directory. It fails only itself --
// one must never reach the glob. It fails only itself -- one malformed // one malformed id is not a reason to leave the other five in place.
// id is not a reason to leave the other five in place.
let (safe, mut outcomes): (Vec<&String>, HashMap<String, Result<(), String>>) = let (safe, mut outcomes): (Vec<&String>, HashMap<String, Result<(), String>>) =
ids.iter().fold( ids.iter().fold(
(Vec::new(), HashMap::new()), (Vec::new(), HashMap::new()),
@@ -780,23 +695,16 @@ pub async fn delete(
return Ok(outcomes); return Ok(outcomes);
} }
// The file name *is* the id, so the machine can find it by name. This // The file name *is* the id, so the machine can find it by name. This used
// used to call `list` and search its output, which is correct and costs // to call `list` and search its output, which is correct and costs a full
// a full read of every transcript on the machine -- around four seconds // read of every transcript on the machine -- around four seconds against a
// against a gigabyte of them, per delete, so a batch of ten took the // gigabyte of them, per delete.
// best part of a minute doing nothing but re-reading the same files.
// `context_of` below already resolved an id the cheap way; this is the
// same lookup, and the two now agree.
// //
// Every copy of each id, not the first. The same id can name a file // Every copy of each id, not the first: the same id can name a file under
// under two project directories -- see the de-duplication in // two project directories, and stopping at the first left the other behind.
// `parse_listing` -- and stopping at the first left the other behind,
// so the row came back on the next listing after a delete that had
// reported success.
// //
// Each id prints its own verdict rather than the loop exiting on the // Each id prints its own verdict rather than the loop exiting on the first
// first failure: with a batch, exiting would leave every id after it // failure, which would leave every id after it unexplained.
// unexplained. See [`DELETE_SCRIPT`] for what the words mean.
let mut args = vec![ let mut args = vec![
"-c".to_string(), "-c".to_string(),
DELETE_SCRIPT.to_string(), DELETE_SCRIPT.to_string(),
@@ -806,8 +714,7 @@ pub async fn delete(
let launch = Launch::new("sh", args, None); let launch = Launch::new("sh", args, None);
// A failure to run the script at all is the machine being unreachable, // A failure to run the script at all is the machine being unreachable,
// which is true of every id in the batch rather than of any one of // which is true of every id in the batch rather than of any one of them.
// them -- so it is returned as the error, not written into each row.
let reported = transport let reported = transport
.capture(&launch) .capture(&launch)
.await .await
@@ -826,10 +733,10 @@ pub async fn delete(
}, },
); );
} }
// Anything the machine did not mention. The connection can drop // Anything the machine did not mention. The connection can drop part-way
// part-way through the loop, and an id whose line never arrived is one // through the loop, and an id whose line never arrived is one nobody knows
// nobody knows the fate of -- which is its own answer, and must not be // the fate of -- which is its own answer, and must not read as either a
// read as either a success or a clean "not there". // success or a clean "not there".
for id in safe { for id in safe {
outcomes.entry(id.clone()).or_insert_with(|| { outcomes.entry(id.clone()).or_insert_with(|| {
Err(format!( Err(format!(
@@ -843,40 +750,34 @@ pub async fn delete(
/// Whether an id is one of ours to put in a shell glob. /// Whether an id is one of ours to put in a shell glob.
/// ///
/// Both places that resolve an id to a file interpolate it into /// Both places that resolve an id interpolate it into
/// `$HOME/.claude/projects/*/"$1".jsonl`. That is an argument rather than /// `$HOME/.claude/projects/*/"$1".jsonl`. That is an argument rather than
/// script text, so a shell cannot be talked into running something -- but a /// script text, so a shell cannot be talked into running something -- but a `/`
/// `/` or a `..` inside it still walks the glob out of the directory the id /// or a `..` inside it still walks the glob out of the directory the id is
/// is supposed to name. [`delete`] is where that would be fatal, because it /// supposed to name, and [`delete`] removes whatever it lands on.
/// removes whatever it lands on, and it is exactly the reason `delete` used
/// to resolve ids by searching a listing instead.
/// ///
/// Claude Code names each transcript with a uuid, so hex and dashes is the /// Claude Code names each transcript with a uuid, so hex and dashes is the
/// whole alphabet. Refused rather than escaped: an id that is not one of /// whole alphabet. Refused rather than escaped.
/// these did not come from the list this app showed.
fn is_session_id(id: &str) -> bool { fn is_session_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-')
} }
/// How often an imported session checks whether its source file grew. /// How often an imported session checks whether its source file grew.
/// ///
/// A poll rather than a watch, because the file may be on another machine /// A poll rather than a watch, because the file may be on another machine and
/// and there is no portable way to be told. Ten seconds is chosen against /// there is no portable way to be told. Ten seconds is chosen against the cost
/// the cost of an ssh round trip rather than against how fast a person /// of an ssh round trip rather than against how fast a person types.
/// types: nothing here is waiting on it, and the events arrive on the same
/// stream as everything else once they do.
pub const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); pub const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
/// Where an imported session came from, and how much of it has been shown. /// Where an imported session came from, and how much of it has been shown.
/// /// Kept beside the session rather than in its config, because it is a position
/// Kept beside the session rather than in its config, because it is a /// in someone else's file rather than anything the person chose, and it changes
/// position in someone else's file rather than anything the person chose, /// constantly.
/// and it changes constantly.
#[derive(Debug, Clone, Serialize, serde::Deserialize)] #[derive(Debug, Clone, Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Cursor { pub struct Cursor {
/// Server-side only, and resolved once at import. Nothing accepts a /// Server-side only, resolved once at import. Nothing accepts a path from
/// path from the phone; this is the path *we* found. /// the phone; this is the path *we* found.
pub path: String, pub path: String,
/// Lines of that file already accounted for -- whether replayed into /// Lines of that file already accounted for -- whether replayed into
/// the transcript or skipped because this session wrote them itself. /// the transcript or skipped because this session wrote them itself.
+135 -166
View File
@@ -1,43 +1,32 @@
//! The llama.cpp driver: a `llama-server` process per session, spoken to //! The llama.cpp driver: a `llama-server` process per session, spoken to over
//! over its OpenAI-compatible HTTP API and translated into the common //! its OpenAI-compatible HTTP API and translated into the common event model.
//! event model.
//! //!
//! Two things make this shaped differently from the Claude driver, and //! Two things make this shaped differently from the Claude driver.
//! both are worth knowing before changing anything here.
//! //!
//! **It is spawned but not spoken to over stdio.** The process is started //! **It is spawned but not spoken to over stdio.** The process is started
//! through the same [`Transport`] as any other, and then reached over //! through the same [`Transport`] as any other and then reached over HTTP on a
//! HTTP on a loopback port. That is the second half of what a transport //! loopback port. That is the second half of what a transport is -- "run this"
//! is -- "run this" plus "reach this port" -- and it is what lets a //! plus "reach this port" -- and it is what lets a session run on another
//! session run on another machine: [`Transport::reserve_port`] hands back //! machine: [`Transport::reserve_port`] hands back a port the server binds
//! a port the server binds *there* and a port that reaches it *here*, and //! *there* and one that reaches it *here*, and the ssh connection carrying the
//! the ssh connection carrying the command carries the tunnel between //! command carries the tunnel between them. The far `llama-server` binds
//! them. The far `llama-server` binds loopback only, so a model is never //! loopback only, so a model is never served to that machine's network.
//! served to that machine's network.
//! //!
//! **The model file is the far machine's, not this one's.** A session //! **The model file is the far machine's, not this one's.** A remote setup
//! serves a GGUF from the machine that runs `llama-server`, so a remote //! names its own models directory (`SshConfig::models_dir`, defaulting to where
//! setup names its own models directory (`SshConfig::models_dir`, //! this backend keeps its downloads), and the file is looked for *there* -- so
//! defaulting to the same place this backend keeps its own downloads). //! a session naming a model that machine does not have says so, instead of
//! What this backend has downloaded is on that machine only when they are //! starting a server that will never load one. Downloading to another machine
//! the same machine -- so the file is looked for *there*, and a session //! is not built; the model gets there however anything else does.
//! that names a model the machine does not have says so instead of
//! starting a server that will never load one. Downloading to another
//! machine is not built; the model gets there however anything else
//! gets there.
//! //!
//! **The server is stateless between requests**, so the whole //! **The server is stateless between requests**, so the whole conversation goes
//! conversation goes with every one. It is rebuilt from the session's //! with every one. It is rebuilt from the session's transcript rather than kept
//! transcript rather than kept in this struct, which is not tidiness: a //! in this struct, which is not tidiness: a copy in driver memory is invisible
//! copy in driver memory is invisible to a second device and gone when //! to a second device and gone when this process restarts.
//! this process restarts, and the app is meant to work across devices.
//! The transcript is already the source of truth for everything else, and
//! this makes it the source of truth for the prompt too.
//! //!
//! That leaves the Claude driver as the odd one out rather than this one: //! That leaves the Claude driver as the odd one out rather than this one -- the
//! the CLI's own memory of a conversation is a cache in front of the same //! CLI's own memory of a conversation is a cache in front of the same
//! transcript, not a second truth. Anyone tempted to "fix" the //! transcript. Resolve any inconsistency in this direction.
//! inconsistency should resolve it in this direction.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -52,10 +41,9 @@ use super::process;
use super::transport::{Launch, Streams, Transport}; use super::transport::{Launch, Streams, Transport};
use crate::config::{ProviderConfig, SessionConfig}; use crate::config::{ProviderConfig, SessionConfig};
/// How long to wait for a model to load before giving up on it. Loading /// How long to wait for a model to load before giving up. Loading is mostly
/// is mostly disk, and a large quantised model on a cold cache is /// disk, and a large quantised model on a cold cache is genuinely slow, so this
/// genuinely slow, so this is generous -- the failure it exists for is a /// is generous -- the failure it exists for is a server that will never answer.
/// server that will never answer, not one that is taking its time.
const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
/// One turn in the conversation this driver keeps on the server's behalf. /// One turn in the conversation this driver keeps on the server's behalf.
@@ -76,20 +64,19 @@ pub struct LlamaDriver {
/// Set by [`Driver::interrupt`]; the streaming loop checks it between /// Set by [`Driver::interrupt`]; the streaming loop checks it between
/// chunks and stops, leaving what was generated in the transcript. /// chunks and stops, leaving what was generated in the transcript.
cancel: Arc<AtomicBool>, cancel: Arc<AtomicBool>,
/// Where this session's process record lives, so [`Driver::stop`] can /// Where this session's process record lives, so [`Driver::stop`] can find
/// find the server it has to end. /// the server it has to end.
session_dir: PathBuf, session_dir: PathBuf,
} }
impl LlamaDriver { impl LlamaDriver {
/// Takes charge of this session's `llama-server`: the one already /// Takes charge of this session's `llama-server`: the one already loaded if
/// loaded if there is one, otherwise a new one. /// there is one, otherwise a new one.
/// ///
/// One entry point, for the reason `ClaudeDriver::launch` gives -- the /// One entry point, for the reason `ClaudeDriver::launch` gives, expensive
/// choice is not the caller's and a second process is the expensive /// in a different currency: two servers holding the same model is twice the
/// mistake. Here it is expensive in a different currency: two servers /// memory, and the second would bind a different port while the phone kept
/// holding the same model is twice the memory, and the second would /// talking to the first.
/// bind a different port while the phone kept talking to the first.
pub fn launch( pub fn launch(
meta: &SessionConfig, meta: &SessionConfig,
provider: &ProviderConfig, provider: &ProviderConfig,
@@ -104,10 +91,10 @@ impl LlamaDriver {
)?; )?;
let path = model_on(transport, models_dir, model)?; let path = model_on(transport, models_dir, model)?;
// Already loaded and still running: keep talking to it. The // Already loaded and still running: keep talking to it. The health poll
// health poll below is what confirms it is really answering, so // below confirms it is really answering, so adopting a pid whose server
// adopting a pid whose server has wedged still reports as a // has wedged still reports as a failure rather than as a session that
// failure rather than as a session that silently never replies. // silently never replies.
if let Some(process::Record { if let Some(process::Record {
detail: process::Detail::Http { port }, detail: process::Detail::Http { port },
pid, pid,
@@ -144,9 +131,9 @@ impl LlamaDriver {
"--port".into(), "--port".into(),
forward.there.to_string(), forward.there.to_string(),
]; ];
// Settings that belong to the server because they decide how the // Settings that belong to the server because they decide how the model
// model is loaded; the sampling ones ride on each request instead, // is loaded; the sampling ones ride on each request instead, so changing
// so changing them later needn't reload anything. // them later needn't reload anything.
for (key, flag) in [ for (key, flag) in [
("contextSize", "-c"), ("contextSize", "-c"),
("gpuLayers", "-ngl"), ("gpuLayers", "-ngl"),
@@ -162,8 +149,8 @@ impl LlamaDriver {
let launch = Launch::new(program, args, meta.cwd.as_deref()).reaching(forward); let launch = Launch::new(program, args, meta.cwd.as_deref()).reaching(forward);
// Its output goes to files, not pipes. Not only so the process can // Its output goes to files, not pipes. Not only so the process can
// outlive this server: nothing ever read those pipes, so a chatty // outlive this server: nothing ever read those pipes, so a chatty
// llama-server filled the 64 KB buffer and blocked mid-load with // llama-server filled the 64 KB buffer and blocked mid-load with no sign
// no sign of why. // of why.
let child = transport.spawn( let child = transport.spawn(
&launch, &launch,
Streams::Detached { Streams::Detached {
@@ -183,10 +170,9 @@ impl LlamaDriver {
forward.there, forward.there,
forward.here, forward.here,
); );
// Reaped so it does not become a zombie while this server is still // Reaped so it does not become a zombie while this server is still its
// its parent; the health poll and the record are what actually say // parent; the health poll and the record are what say whether the
// whether the session is alive, because after a restart there is no // session is alive, because after a restart there is no `Child` to ask.
// `Child` here to ask.
tokio::spawn(async move { tokio::spawn(async move {
let mut child = child; let mut child = child;
let _ = child.wait().await; let _ = child.wait().await;
@@ -214,10 +200,10 @@ impl LlamaDriver {
/// The driver for a `llama-server` at `endpoint`, however it got there. /// The driver for a `llama-server` at `endpoint`, however it got there.
/// ///
/// Shared by starting one and adopting one, because everything after /// Shared by starting one and adopting one, because everything after "there
/// "there is a server at this address" is identical -- including /// is a server at this address" is identical -- including waiting for it to
/// waiting for it to answer, which an adopted one still owes: a /// answer, which an adopted one still owes: a recorded pid says a process
/// recorded pid says a process exists, not that its model is loaded. /// exists, not that its model is loaded.
fn attached( fn attached(
endpoint: String, endpoint: String,
meta: &SessionConfig, meta: &SessionConfig,
@@ -226,9 +212,9 @@ impl LlamaDriver {
session_dir: &Path, session_dir: &Path,
sink: EventSink, sink: EventSink,
) -> Self { ) -> Self {
// Loading is slow enough to be worth saying so: the session shows // Loading is slow enough to be worth saying so: the session shows as
// as running until the model is in memory, then goes idle, rather // running until the model is in memory, rather than looking ready and
// than looking ready and refusing the first message. // refusing the first message.
let _ = sink.send(Event::Status { let _ = sink.send(Event::Status {
state: SessionStatus::Running, state: SessionStatus::Running,
}); });
@@ -287,11 +273,9 @@ impl LlamaDriver {
/// terminal anyway. /// terminal anyway.
const SERVER_LOG: &str = "llama-server.log"; const SERVER_LOG: &str = "llama-server.log";
/// How often a loaded server is checked for still being there. /// How often a loaded server is checked for still being there. Slower than the
/// /// Claude driver's stdout poll because nothing is waiting on it: this only has
/// Slower than the Claude driver's stdout poll because nothing is waiting /// to notice a server that has gone.
/// on it: this only has to notice a server that has gone, and a few
/// seconds late costs nothing.
const WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); const WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
/// An owner-only log opened for appending, so the two streams pointed at /// An owner-only log opened for appending, so the two streams pointed at
@@ -306,22 +290,21 @@ fn log_file(path: &Path) -> Result<std::fs::File> {
.with_context(|| format!("opening {}", path.display())) .with_context(|| format!("opening {}", path.display()))
} }
/// Reports the server going away, for as long as the session is there to /// Reports the server going away, for as long as the session is there to report
/// report it to. /// it to.
/// ///
/// Polled rather than waited on, for the reason the Claude driver gives: /// Polled rather than waited on, for the reason the Claude driver gives: after a
/// after a restart this server is not the process's parent and has nothing /// restart this server is not the process's parent, so liveness has to be a
/// to wait on, so liveness has to be a question asked of the record -- and /// question asked of the record -- and asking it two different ways is how the
/// asking it two different ways is how the two answers come to disagree. /// two answers come to disagree.
fn watch(session_dir: PathBuf, sink: EventSink) { fn watch(session_dir: PathBuf, sink: EventSink) {
std::thread::spawn(move || { std::thread::spawn(move || {
loop { loop {
std::thread::sleep(WATCH_INTERVAL); std::thread::sleep(WATCH_INTERVAL);
match process::recorded(&session_dir) { match process::recorded(&session_dir) {
Some((_, process::Liveness::Alive)) => {} Some((_, process::Liveness::Alive)) => {}
// Nothing recorded means the session was stopped or // Nothing recorded means the session was stopped or deleted
// deleted deliberately, and whoever did that has already // deliberately, and whoever did that has already said so.
// said so.
None => return, None => return,
Some((_, process::Liveness::Dead)) => { Some((_, process::Liveness::Dead)) => {
let _ = sink.send(Event::Error { let _ = sink.send(Event::Error {
@@ -360,34 +343,33 @@ impl Driver for LlamaDriver {
let cancel = Arc::clone(&self.cancel); let cancel = Arc::clone(&self.cancel);
cancel.store(false, Ordering::Relaxed); cancel.store(false, Ordering::Relaxed);
// Its own thread: the request blocks for as long as the model // Its own thread: the request blocks for as long as the model takes to
// takes to generate, which is the whole point of streaming it. // generate, which is the whole point of streaming it.
std::thread::spawn(move || { std::thread::spawn(move || {
// Nothing is ever held back here -- there is no queue to wait // Nothing is ever held back here -- there is no queue to wait in --
// in -- so the message is taken the moment it arrives. Said // so the message is taken the moment it arrives. Said anyway,
// anyway, because this is what records it: see `MessageTaken`. // because this is what records it: see `MessageTaken`.
let _ = sink.send(Event::MessageTaken { let _ = sink.send(Event::MessageTaken {
id: None, id: None,
text: text.clone(), text: text.clone(),
// Never any: this driver refuses attachments above, and // Never any: this driver refuses attachments above.
// saying so is what the refusal above is for.
attachments: Vec::new(), attachments: Vec::new(),
}); });
let _ = sink.send(Event::Status { let _ = sink.send(Event::Status {
state: SessionStatus::Running, state: SessionStatus::Running,
}); });
// Everything before this message, plus this message. Read // Everything before this message, plus this message. Read rather
// rather than remembered, and `text` is appended here rather // than remembered, and `text` is appended here rather than waited
// than waited for, because the message's own transcript entry // for, because the message's own transcript entry is still on its
// is still on its way when this runs. // way when this runs.
let mut messages = conversation(&transcript); let mut messages = conversation(&transcript);
messages.push(Message { messages.push(Message {
role: "user".into(), role: "user".into(),
content: text, content: text,
}); });
// The reply is not stored: the deltas below are the durable // The reply is not stored: the deltas below are the durable record,
// record, so the next turn reads back exactly what the phone // so the next turn reads back exactly what the phone was shown --
// was shown -- including a partial one that was interrupted. // including a partial one that was interrupted.
if let Err(err) = generate(&endpoint, &messages, &sampling, &cancel, &sink) { if let Err(err) = generate(&endpoint, &messages, &sampling, &cancel, &sink) {
let _ = sink.send(Event::Error { let _ = sink.send(Event::Error {
message: format!("{err:#}"), message: format!("{err:#}"),
@@ -400,17 +382,15 @@ impl Driver for LlamaDriver {
} }
fn answer_question(&self, _id: &str, _answers: &[String]) { fn answer_question(&self, _id: &str, _answers: &[String]) {
// Nothing here asks questions: this driver has no tools, so no // Nothing here asks questions: this driver has no tools.
// permission prompts and no AskUserQuestion.
} }
fn interrupt(&self) { fn interrupt(&self) {
self.cancel.store(true, Ordering::Relaxed); self.cancel.store(true, Ordering::Relaxed);
} }
// Nothing to forward: this process has no notion of what the // Nothing to forward: this process has no notion of what the conversation
// conversation is called, and the rename it belongs to has already // is called, and the rename has already happened where the name lives.
// happened where the name lives. See `Driver::set_title`.
fn set_title(&self, _title: &str) {} fn set_title(&self, _title: &str) {}
fn set_permission_mode(&self, _mode: &str) { fn set_permission_mode(&self, _mode: &str) {
@@ -445,23 +425,21 @@ impl Driver for LlamaDriver {
} }
fn clear(&self) { fn clear(&self) {
// All of it. `conversation` folds from the last of these, so // All of it. `conversation` folds from the last of these, so recording
// recording the marker *is* the reset -- there is no driver state // the marker *is* the reset -- there is no driver state to keep in step
// to keep in step with it, which is the same property that makes // with it, which is the same property that makes a second device see the
// a second device see the same conversation this one does. // same conversation this one does.
let _ = self.sink.send(Event::Cleared); let _ = self.sink.send(Event::Cleared);
} }
/// Stops generating and leaves the server loaded. /// Stops generating and leaves the server loaded.
/// ///
/// Worth being deliberate about, because the cost is asymmetric and /// Worth being deliberate about, because the cost points the other way from
/// points the other way from the Claude driver's: a `llama-server` /// the Claude driver's: a `llama-server` holds its whole model in memory, so
/// holds its whole model in memory, so a leaked one is gigabytes /// a leaked one is gigabytes nobody is using. It is left anyway, because the
/// nobody is using. It is left anyway, because the alternative is /// alternative is unloading and reloading that model on every backend
/// unloading and reloading that model on every backend restart -- /// restart -- minutes of disk, for a session somebody is in the middle of.
/// minutes of disk, for a session somebody is in the middle of. The /// The record is what keeps it from being *nobody's*.
/// record is what keeps it from being *nobody's*: the next run of this
/// server adopts it rather than starting a second one.
fn detach(&self) { fn detach(&self) {
self.cancel.store(true, Ordering::Relaxed); self.cancel.store(true, Ordering::Relaxed);
} }
@@ -477,16 +455,15 @@ impl Driver for LlamaDriver {
/// The conversation so far, folded out of the transcript. /// The conversation so far, folded out of the transcript.
/// ///
/// Consecutive `AssistantText` deltas are one assistant turn, closed by /// Consecutive `AssistantText` deltas are one assistant turn, closed by the next
/// the next user message -- which is also what makes an interrupted reply /// user message -- which is also what makes an interrupted reply come back as
/// come back as the partial text the phone actually saw, rather than /// the partial text the phone actually saw.
/// vanishing or being invented.
/// ///
/// This must stay a pure function of the transcript and must never /// This must stay a pure function of the transcript and must never re-render
/// re-render earlier turns. llama.cpp caches the prompt prefix, so a /// earlier turns. llama.cpp caches the prompt prefix, so a growing conversation
/// growing conversation reprocesses almost nothing -- but only while /// reprocesses almost nothing -- but only while every turn is byte-identical to
/// every turn is byte-identical to last time. Changing how an old turn is /// last time. Changing how an old turn is rendered silently reprocesses the
/// rendered silently reprocesses the whole history on every message. /// whole history on every message.
fn conversation(path: &Path) -> Vec<Message> { fn conversation(path: &Path) -> Vec<Message> {
let Ok(events) = crate::session::transcript::read_after(path, 0) else { let Ok(events) = crate::session::transcript::read_after(path, 0) else {
return Vec::new(); return Vec::new();
@@ -494,8 +471,8 @@ fn conversation(path: &Path) -> Vec<Message> {
let mut messages: Vec<Message> = Vec::new(); let mut messages: Vec<Message> = Vec::new();
let mut pending = String::new(); let mut pending = String::new();
// Everything before the last clear is still in the transcript and is // Everything before the last clear is still in the transcript and is
// deliberately not in the conversation. Folding from zero here would // deliberately not in the conversation. Folding from zero would put it back,
// put it back, which is the whole of what clearing had to undo. // which is the whole of what clearing had to undo.
let events = match events.iter().rposition(|e| e.event == Event::Cleared) { let events = match events.iter().rposition(|e| e.event == Event::Cleared) {
Some(at) => &events[at + 1..], Some(at) => &events[at + 1..],
None => &events[..], None => &events[..],
@@ -543,46 +520,40 @@ fn model_path(models_dir: &Path, key: &str) -> Result<PathBuf> {
Ok(path) Ok(path)
} }
/// The model file's path **on the machine that will serve it**, confirmed /// The model file's path **on the machine that will serve it**, confirmed to be
/// to be there. /// there.
/// ///
/// Local and remote answer the same question and it has to be asked of /// One function rather than a local check and hope for the other case: the same
/// two different filesystems, which is why this is one function rather /// question has to be asked of two filesystems. The remote answer is measured
/// than a check beside the local path and hope for the other case. The /// for the reason the local one is -- a missing file otherwise becomes a
/// remote answer is measured for the same reason the local one is: a /// `llama-server` that starts, fails to load, and reports as a session that
/// missing file otherwise becomes a `llama-server` that starts, fails to /// never became ready, which reads as the machine being slow.
/// load, and reports as a session that never became ready -- which reads
/// as the machine being slow.
/// ///
/// One blocking round trip on a remote spawn, which is the same cost the /// One blocking round trip on a remote spawn, which is what the spawn is
/// spawn is already paying to start ssh. The alternative is a path built /// already paying to start ssh. The alternative is a path built here from a `~`
/// here from a `~` this machine cannot expand. /// this machine cannot expand.
fn model_on(transport: &Transport, models_dir: &Path, key: &str) -> Result<String> { fn model_on(transport: &Transport, models_dir: &Path, key: &str) -> Result<String> {
let Transport::Ssh { name, .. } = transport else { let Transport::Ssh { name, .. } = transport else {
return Ok(model_path(models_dir, key)?.to_string_lossy().into_owned()); return Ok(model_path(models_dir, key)?.to_string_lossy().into_owned());
}; };
// The same directory the spawn screen listed for this machine, and // The same directory the spawn screen listed for this machine, and one
// for the same reason it is one function: a list from one place and a // function for the same reason: a list from one place and a load from
// load from another is a model that appears and then fails. // another is a model that appears and then fails.
let dir = crate::models::dir_on(transport, models_dir); let dir = crate::models::dir_on(transport, models_dir);
// Checked here rather than in the script: `..` in a key would walk // Checked here rather than in the script: `..` in a key would walk out of
// out of the models directory on a machine this server can start // the models directory on a machine this server can start processes on,
// processes on, and the phone is where the key comes from. // and the phone is where the key comes from.
for part in key.split('/') { for part in key.split('/') {
if part.is_empty() || part == "." || part == ".." { if part.is_empty() || part == "." || part == ".." {
bail!("\"{key}\" is not a model key this can resolve"); bail!("\"{key}\" is not a model key this can resolve");
} }
} }
let path = format!("{}/{key}", dir.trim_end_matches('/')); let path = format!("{}/{key}", dir.trim_end_matches('/'));
// `$HOME` on the far side, which is the only machine that knows what // `$HOME` on the far side, which is the only machine that knows what it is,
// it is -- and the resolved path is printed back so the launch below // and the resolved path printed back so the launch hands `llama-server`
// hands `llama-server` something absolute. // something absolute. "Not there" is answered rather than failed, because a
// // machine that could not be asked at all has to say so in its own words --
// "the file is not there" is answered rather than failed, because the // it would otherwise arrive as this same sentence about a missing model.
// two are different things to a reader and only one of them is a
// fault: a machine that could not be asked at all has to say so in
// its own words, and it would otherwise arrive as this same sentence
// about a missing model.
let script = "p=$1; case $p in \"~\") p=$HOME;; \"~/\"*) p=$HOME/${p#\"~/\"};; esac; \ let script = "p=$1; case $p in \"~\") p=$HOME;; \"~/\"*) p=$HOME/${p#\"~/\"};; esac; \
[ -f \"$p\" ] && printf 'at\\t%s\\n' \"$p\" || printf 'missing\\n'" [ -f \"$p\" ] && printf 'at\\t%s\\n' \"$p\" || printf 'missing\\n'"
.to_string(); .to_string();
@@ -664,8 +635,8 @@ fn log_tail(session_dir: &Path) -> String {
const LOG_TAIL_LINES: usize = 6; const LOG_TAIL_LINES: usize = 6;
/// One streamed completion: posts the conversation, emits each delta as it /// One streamed completion: posts the conversation, emits each delta as it
/// arrives. Emits rather than returns: the transcript those events land /// arrives. Emits rather than returns, because the transcript those events land
/// in is what the next turn reads back, so there is nothing to hand up. /// in is what the next turn reads back.
fn generate( fn generate(
endpoint: &str, endpoint: &str,
messages: &[Message], messages: &[Message],
@@ -691,16 +662,15 @@ fn generate(
let reader = std::io::BufReader::new(response.body_mut().as_reader()); let reader = std::io::BufReader::new(response.body_mut().as_reader());
let mut tokens = 0u64; let mut tokens = 0u64;
// The prompt side only, which is what the model is holding -- the same // The prompt side only, which is what the model is holding -- the same
// definition the other dialects report, so one word on the phone means // definition the other dialects report, so one word on the phone means one
// one thing whichever kind of session it is. // thing whichever kind of session it is.
let mut context = None; let mut context = None;
for line in std::io::BufRead::lines(reader) { for line in std::io::BufRead::lines(reader) {
if cancel.load(Ordering::Relaxed) { if cancel.load(Ordering::Relaxed) {
break; break;
} }
let line = line.context("reading the generation stream")?; let line = line.context("reading the generation stream")?;
// Server-sent events: the payload lines are the ones that matter, // Server-sent events: the payload lines are the ones that matter.
// and blank lines separate events.
let Some(payload) = line.strip_prefix("data: ") else { let Some(payload) = line.strip_prefix("data: ") else {
continue; continue;
}; };
@@ -748,8 +718,8 @@ mod tests {
use super::*; use super::*;
use crate::session::transcript::Transcript; use crate::session::transcript::Transcript;
/// Writes a transcript the way the pump does, so the fold is tested /// Writes a transcript the way the pump does, so the fold is tested against
/// against the real file format rather than a hand-built vector. /// the real file format rather than a hand-built vector.
fn transcript_with(events: &[Event]) -> (tempfile::TempDir, PathBuf) { fn transcript_with(events: &[Event]) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl"); let path = dir.path().join("transcript.jsonl");
@@ -802,11 +772,10 @@ mod tests {
} }
#[test] #[test]
/// The interrupted case, which decides what a resumed conversation is /// The interrupted case, which decides what a resumed conversation is built
/// built from: whatever the phone was shown. The deltas that arrived /// from: whatever the phone was shown. The deltas that arrived before the
/// before the stop are in the transcript, so they are in the prompt -- /// stop are in the transcript, so they are in the prompt -- the model is
/// the model is never told it said something the user did not see, and /// never told it said something the user did not see.
/// never has a turn silently dropped from under it.
fn an_interrupted_reply_stays_in_the_conversation() { fn an_interrupted_reply_stays_in_the_conversation() {
let (_dir, path) = transcript_with(&[ let (_dir, path) = transcript_with(&[
Event::UserMessage { Event::UserMessage {
@@ -858,9 +827,9 @@ mod tests {
} }
#[test] #[test]
/// Clearing decides what the *model* is given, not just what the /// Clearing decides what the *model* is given, not just what the phone
/// phone draws. Everything above the marker stays in the transcript /// draws. Everything above the marker stays in the transcript and none of it
/// -- a person can still scroll back to it -- and none of it is sent. /// is sent.
fn the_conversation_starts_after_the_last_clear() { fn the_conversation_starts_after_the_last_clear() {
let (_dir, path) = transcript_with(&[ let (_dir, path) = transcript_with(&[
Event::UserMessage { Event::UserMessage {
+522 -857
View File
File diff suppressed because it is too large. Load diff
+37 -47
View File
@@ -1,20 +1,18 @@
//! What is being done to a machine's Claude Code sessions right now. //! What is being done to a machine's Claude Code sessions right now.
//! //!
//! Importing and deleting used to be whatever the phone was in the middle //! Importing and deleting used to be whatever the phone was in the middle of:
//! of: the request was the work, so leaving the screen cancelled it and //! the request was the work, so leaving the screen cancelled it and coming back
//! coming back showed no sign it had ever started. Sessions half-imported //! showed no sign it had ever started. Sessions half-imported that way are the
//! that way are the expensive kind of missing -- the row is back in the //! expensive kind of missing -- the row is back in the list looking untouched,
//! list looking untouched, and taking it again is the second `--resume` the //! and taking it again is the second `--resume` the import path exists to
//! whole import path exists to prevent. //! prevent.
//! //!
//! So the work runs here, on the server, and this is the record of it. The //! So the work runs here, on the server, and this is the record of it. The
//! phone reads that record two ways, and needs both: every row of `GET //! phone reads that record two ways and needs both: every row of the importable
//! /setups/{id}/importable` carries what is happening to it, which is what //! listing carries what is happening to it, which is what a phone that was
//! a phone that was asleep, out of range, or freshly opened has to go on; //! asleep has to go on; and [`Registry::subscribe`] is the live stream, which is
//! and [`Registry::subscribe`] is the live stream, which is what makes a //! what makes a screen change by itself. A broadcast has no memory, and a
//! screen somebody is looking at change by itself. Neither is sufficient //! listing is only true when it was fetched.
//! alone -- a broadcast has no memory, and a listing is only true when it
//! was fetched.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -31,8 +29,8 @@ pub enum Operation {
} }
impl Operation { impl Operation {
/// The word a row shows while this runs. Fixed here rather than in the /// The word a row shows while this runs. Fixed here rather than in the app
/// app so the two ends cannot disagree about what a state is called. /// so the two ends cannot disagree about what a state is called.
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
match self { match self {
Self::Importing => "importing", Self::Importing => "importing",
@@ -43,11 +41,9 @@ impl Operation {
/// One change to what is in flight, as it goes out on the stream. /// One change to what is in flight, as it goes out on the stream.
/// ///
/// The three states are every way an operation ends, including the two that /// The three states are every way an operation ends, including the two easy to
/// are easy to leave out: it can still be running, it can have finished, /// leave out: still running, finished, and failed. There is deliberately no
/// and it can have failed. There is deliberately no "unknown" -- this is /// "unknown" -- this is the server's own work, so not knowing would be a bug.
/// the server's own work, so not knowing would be a bug rather than a
/// state.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "state")] #[serde(rename_all = "camelCase", tag = "state")]
pub enum Change { pub enum Change {
@@ -68,9 +64,8 @@ pub enum Change {
} }
impl Change { impl Change {
/// Which machine this is about, so a stream scoped to one can drop the /// Which machine this is about, so a stream scoped to one can drop the rest.
/// rest. Every variant carries it; matching here rather than at the /// Every variant carries it; matching here keeps that fact in one place.
/// filter keeps that fact in one place.
pub fn setup(&self) -> &str { pub fn setup(&self) -> &str {
match self { match self {
Self::Started { setup, .. } Self::Started { setup, .. }
@@ -84,11 +79,10 @@ impl Change {
#[derive(Debug)] #[derive(Debug)]
pub struct Registry { pub struct Registry {
running: Mutex<HashMap<(String, String), Operation>>, running: Mutex<HashMap<(String, String), Operation>>,
/// Kept after the operation ends, because a phone that was not looking /// Kept after the operation ends, because a phone that was not looking when
/// when it failed has no other way to find out. Replaced when the next /// it failed has no other way to find out. Replaced when the next operation
/// operation on that session starts, and dropped by [`Registry::prune`] /// on that session starts, and dropped by [`Registry::prune`] when the
/// when the session is no longer on the machine -- an error about a /// session is no longer on the machine.
/// transcript that is gone has nothing left to be about.
failures: Mutex<HashMap<(String, String), String>>, failures: Mutex<HashMap<(String, String), String>>,
changes: broadcast::Sender<Change>, changes: broadcast::Sender<Change>,
} }
@@ -98,8 +92,8 @@ impl Default for Registry {
Self { Self {
running: Mutex::new(HashMap::new()), running: Mutex::new(HashMap::new()),
failures: Mutex::new(HashMap::new()), failures: Mutex::new(HashMap::new()),
// Enough that a phone watching one screen cannot lag behind a // Enough that a phone watching one screen cannot lag behind a batch
// batch of any size somebody would start by hand. // of any size somebody would start by hand.
changes: broadcast::channel(256).0, changes: broadcast::channel(256).0,
} }
} }
@@ -109,10 +103,9 @@ impl Registry {
/// Marks an operation as running and announces it. /// Marks an operation as running and announces it.
/// ///
/// The returned guard is how it stops being marked: settle it with /// The returned guard is how it stops being marked: settle it with
/// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it /// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it reports
/// reports a failure. Dropping without settling means the task was /// a failure. Dropping without settling means the task was cancelled or
/// cancelled or panicked, and a row stuck on "importing" for ever is a /// panicked, and a row stuck on "importing" for ever is a worse answer.
/// worse answer than one that says it did not finish.
pub fn begin(self: &Arc<Self>, setup: &str, session: &str, operation: Operation) -> InFlight { pub fn begin(self: &Arc<Self>, setup: &str, session: &str, operation: Operation) -> InFlight {
let key = (setup.to_string(), session.to_string()); let key = (setup.to_string(), session.to_string());
self.running.lock().unwrap().insert(key.clone(), operation); self.running.lock().unwrap().insert(key.clone(), operation);
@@ -141,11 +134,8 @@ impl Registry {
self.failures.lock().unwrap().get(&key).cloned() self.failures.lock().unwrap().get(&key).cloned()
} }
/// Forgets failures against sessions the machine no longer has. /// Forgets failures against sessions the machine no longer has. Called from
/// /// the listing, which is the only place that knows what is still there.
/// Called from the listing, which is the only place that knows what is
/// still there. A deleted session's failure would otherwise outlive
/// everything it referred to.
pub fn prune(&self, setup: &str, present: &[String]) { pub fn prune(&self, setup: &str, present: &[String]) {
self.failures self.failures
.lock() .lock()
@@ -155,8 +145,8 @@ impl Registry {
}); });
} }
/// Every change as it happens. See the module note on why this is not /// Every change as it happens. See the module note on why this is not the
/// the only way the phone finds out. /// only way the phone finds out.
pub fn subscribe(&self) -> broadcast::Receiver<Change> { pub fn subscribe(&self) -> broadcast::Receiver<Change> {
self.changes.subscribe() self.changes.subscribe()
} }
@@ -229,8 +219,8 @@ mod tests {
assert!(matches!(changes.try_recv(), Ok(Change::Finished { .. }))); assert!(matches!(changes.try_recv(), Ok(Change::Finished { .. })));
} }
/// A failure outlives the operation, because the phone that needs it may /// A failure outlives the operation, because the phone that needs it may not
/// not have been listening when it happened. /// have been listening when it happened.
#[test] #[test]
fn a_failure_is_kept_until_something_replaces_or_prunes_it() { fn a_failure_is_kept_until_something_replaces_or_prunes_it() {
let registry = Arc::new(Registry::default()); let registry = Arc::new(Registry::default());
@@ -256,8 +246,8 @@ mod tests {
assert!(registry.failure("local", "abc").is_none()); assert!(registry.failure("local", "abc").is_none());
} }
/// Trying again clears the last failure, so a row cannot show an error /// Trying again clears the last failure, so a row cannot show an error from
/// from before the attempt somebody is currently watching. /// before the attempt somebody is currently watching.
#[test] #[test]
fn starting_again_clears_the_previous_failure() { fn starting_again_clears_the_previous_failure() {
let registry = Arc::new(Registry::default()); let registry = Arc::new(Registry::default());
@@ -270,8 +260,8 @@ mod tests {
second.succeeded(); second.succeeded();
} }
/// A task that is cancelled or panics must not leave a row saying /// A task that is cancelled or panics must not leave a row saying something
/// something is still happening to it. /// is still happening to it.
#[test] #[test]
fn dropping_an_unsettled_operation_reports_a_failure() { fn dropping_an_unsettled_operation_reports_a_failure() {
let registry = Arc::new(Registry::default()); let registry = Arc::new(Registry::default());
+113 -148
View File
@@ -2,31 +2,26 @@
//! written down so a *later* run of this server can find the same process //! written down so a *later* run of this server can find the same process
//! rather than start a second one. //! rather than start a second one.
//! //!
//! The server deliberately outlives its own restarts badly and its //! Stopping the backend must not kill a turn that is in flight, so session
//! children well: stopping the backend must not kill a turn that is in //! processes are left running and adopted again on the way back up. That only
//! flight, so session processes are left running and adopted again on the //! works if "is this still mine?" has an answer, which is what this module is.
//! way back up. That only works if "is this still mine?" has an answer,
//! which is what this module is.
//! //!
//! **A pid is not an identity.** Pids are reused, so adopting one by //! **A pid is not an identity.** Pids are reused, so adopting one by number
//! number alone eventually means treating a stranger's process as a //! alone eventually means treating a stranger's process as a session -- never
//! session -- never resuming the real conversation, and signalling //! resuming the real conversation, and signalling something unrelated when the
//! something unrelated when the session is deleted. The kernel's start //! session is deleted. The kernel's start time for that pid is recorded beside
//! time for that pid is recorded beside it; the pair is unique for as long //! it; the pair is unique for as long as the machine has been up.
//! as the machine has been up, which is longer than any of this lives.
//! //!
//! **How to reach it again belongs here too**, in the same record and the //! **How to reach it again belongs here too**, in the same record and the same
//! same write, because it answers the other half of the same question: not //! write, because it answers the other half of the same question. Splitting
//! just "is my process still there" but "where do I pick it up". Splitting //! them would be two files that can disagree about one process. What it takes
//! them would be two files that can disagree about one process. What that //! differs by driver, so it is a typed [`Detail`] rather than a union of every
//! takes differs by driver -- a reading position into a log for one spoken //! driver's fields.
//! to over stdio, a port for one spoken to over HTTP -- so it is a typed
//! [`Detail`] rather than a union of every driver's fields.
//! //!
//! The record is rewritten in place as reading advances. A crash during //! The record is rewritten in place as reading advances. A crash during that
//! that write leaves a record that does not parse, which is read as "no //! write leaves a record that does not parse, which is read as "no live
//! live process" -- so the failure is the old behaviour (start one with //! process" -- so the failure is the old behaviour rather than a wrong
//! `--resume`) rather than a wrong adoption. //! adoption.
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -40,8 +35,8 @@ const RECORD_FILE: &str = "process.json";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Record { pub struct Record {
pub pid: u32, pub pid: u32,
/// The kernel's start time for `pid`, in clock ticks since boot. See /// The kernel's start time for `pid`, in clock ticks since boot. See the
/// the module comment: this is what makes the pid an identity. /// module comment: this is what makes the pid an identity.
pub started: u64, pub started: u64,
/// What the driver needs in order to pick this process back up. /// What the driver needs in order to pick this process back up.
#[serde(flatten)] #[serde(flatten)]
@@ -52,24 +47,21 @@ pub struct Record {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum Detail { pub enum Detail {
/// Spoken to over stdio, which outlives the server as files in the /// Spoken to over stdio, which outlives the server as files in the session
/// session directory. `stdout_read` is how many bytes of the stdout /// directory. `stdout_read` is how many bytes of the stdout log have
/// log have already become events: everything before it is in the /// already become events: everything after it is what a reattaching server
/// transcript, everything after it is what a reattaching server owes /// owes the conversation.
/// the conversation.
Stdio { stdout_read: u64 }, Stdio { stdout_read: u64 },
/// Spoken to over HTTP on a loopback port, which is all it takes to /// Spoken to over HTTP on a loopback port, which is all it takes to find
/// find it again -- there is no stream to be partway through. /// it again -- there is no stream to be partway through.
Http { port: u16 }, Http { port: u16 },
} }
/// Whether a recorded process is still there. /// Whether a recorded process is still there.
/// ///
/// Three answers rather than a boolean, because "I could not find out" is /// Three answers rather than a boolean, because "I could not find out" is a
/// a real one and is not the same as "no". Treating it as "no" is what /// real one and is not the same as "no". Treating it as "no" is what would
/// would start a second process against a conversation that already has /// start a second process against a conversation that already has one.
/// one -- the expensive mistake this whole module exists to prevent -- so
/// it has to be sayable.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Liveness { pub enum Liveness {
Alive, Alive,
@@ -78,9 +70,9 @@ pub enum Liveness {
} }
impl Record { impl Record {
/// The record for a process this server just started, or `None` when /// The record for a process this server just started, or `None` when the
/// the kernel will not say when it started -- which is the same /// kernel will not say when it started -- which is the same answer as "do
/// answer as "do not adopt this later", and the safe one. /// not adopt this later", and the safe one.
pub fn of(pid: u32, detail: Detail) -> Option<Self> { pub fn of(pid: u32, detail: Detail) -> Option<Self> {
Some(Self { Some(Self {
pid, pid,
@@ -89,12 +81,9 @@ impl Record {
}) })
} }
/// Whether the process this describes is still the one running under
/// that pid.
pub fn liveness(&self) -> Liveness { pub fn liveness(&self) -> Liveness {
match stat_of(self.pid) { match stat_of(self.pid) {
// A different start time is a reused pid, which is a different // A different start time is a reused pid, so definitely not ours.
// process and so definitely not ours.
Ok(Some(stat)) if stat.started == self.started => { Ok(Some(stat)) if stat.started == self.started => {
if stat.exited { if stat.exited {
Liveness::Dead Liveness::Dead
@@ -112,12 +101,10 @@ fn path(session_dir: &Path) -> PathBuf {
session_dir.join(RECORD_FILE) session_dir.join(RECORD_FILE)
} }
/// The recorded process and whether it is still there, or `None` when /// The recorded process and whether it is still there, or `None` when nothing
/// nothing usable is recorded. /// usable is recorded. A record that does not parse reads as no record: the
/// /// only way to get one is a crash partway through writing it, and the safe
/// A record that does not parse reads as no record: the only way to get /// reading is that this server has no claim on anything.
/// one is a crash partway through writing it, and the safe reading of that
/// is that this server has no claim on anything.
pub fn recorded(session_dir: &Path) -> Option<(Record, Liveness)> { pub fn recorded(session_dir: &Path) -> Option<(Record, Liveness)> {
let text = std::fs::read_to_string(path(session_dir)).ok()?; let text = std::fs::read_to_string(path(session_dir)).ok()?;
let record: Record = serde_json::from_str(text.trim_end()).ok()?; let record: Record = serde_json::from_str(text.trim_end()).ok()?;
@@ -125,10 +112,8 @@ pub fn recorded(session_dir: &Path) -> Option<(Record, Liveness)> {
Some((record, liveness)) Some((record, liveness))
} }
/// The recorded process if it is definitely still running. /// The recorded process if it is definitely still running. One function rather
/// /// than a read plus a liveness check at each caller: the caller that forgets
/// One function rather than a read plus a liveness check at each caller:
/// every caller wants the same question answered, and the one that forgets
/// the second half is the one that starts a duplicate. /// the second half is the one that starts a duplicate.
pub fn live(session_dir: &Path) -> Option<Record> { pub fn live(session_dir: &Path) -> Option<Record> {
match recorded(session_dir) { match recorded(session_dir) {
@@ -137,25 +122,20 @@ pub fn live(session_dir: &Path) -> Option<Record> {
} }
} }
/// Writes `record` where [`live`] will find it, atomically. /// Writes `record` where [`live`] will find it, atomically -- to a neighbouring
/// file, renamed over the real name, so a reader sees either the whole old
/// record or the whole new one.
/// ///
/// Written to a neighbouring file and renamed over the real name. The /// Writing in place would not be, and the consequence is severe rather than
/// rename is what makes this safe: a reader sees either the whole old /// untidy. `fs::write` truncates before it fills, so a crash inside that window
/// record or the whole new one, never a partial. /// leaves no readable record -- and a missing record reads as "nothing is
/// running", which is the single answer that makes the next launch start a
/// *second* process against a conversation that already has one. The window is
/// not rare: this runs on every read that makes progress, so many times a
/// second while a turn is producing output.
/// ///
/// Writing in place would not be, and the consequence is severe rather /// Errors are logged rather than returned: this runs on the reading path, and a
/// than untidy. `fs::write` truncates before it fills, so a crash inside /// session that cannot save its position is still worth having.
/// that window leaves no readable record -- and a missing record reads as
/// "nothing is running", which is the single answer that makes the next
/// launch start a *second* process against a conversation that already has
/// one. That is the fault this whole module exists to prevent, and writing
/// the record carelessly would reintroduce it at its own save point. The
/// window is not rare either: this runs on every read that makes progress,
/// so many times a second while a turn is producing output.
///
/// Errors are logged rather than returned: this runs on the reading path,
/// and a session that cannot save its position is still worth having -- it
/// just cannot be reattached to, which is what the log says.
pub fn write(session_dir: &Path, record: &Record) { pub fn write(session_dir: &Path, record: &Record) {
let path = path(session_dir); let path = path(session_dir);
let text = match serde_json::to_string(record) { let text = match serde_json::to_string(record) {
@@ -165,8 +145,8 @@ pub fn write(session_dir: &Path, record: &Record) {
return; return;
} }
}; };
// Beside the real file so the rename stays within one filesystem, // Beside the real file so the rename stays within one filesystem, which is
// which is what makes it atomic. // what makes it atomic.
let temp = path.with_extension("json.new"); let temp = path.with_extension("json.new");
let written = std::fs::OpenOptions::new() let written = std::fs::OpenOptions::new()
.create(true) .create(true)
@@ -190,11 +170,8 @@ pub fn write(session_dir: &Path, record: &Record) {
} }
} }
/// How many bytes `path` holds, or 0 if it is not there. /// How many bytes `path` holds, or 0 if it is not there. Exists so a caller
/// /// wanting only the length does not have to read the file to find it.
/// Exists so a caller wanting only the length does not have to read the
/// file to find it -- [`read_from`] with a large offset answers the
/// question, but allocates the whole file on the way.
pub fn size_of(path: &Path) -> u64 { pub fn size_of(path: &Path) -> u64 {
std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0) std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0)
} }
@@ -211,19 +188,16 @@ pub fn clear(session_dir: &Path) {
} }
/// Grace period between asking a session's process to stop and killing it. /// Grace period between asking a session's process to stop and killing it.
/// /// Here rather than beside each caller: two drivers plus the manager had
/// Here rather than beside each caller: it is a property of stopping one of /// written the same five seconds down separately.
/// these, and two drivers plus the manager had written the same five seconds
/// down separately, which is three places for it to drift.
pub const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5); pub const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
/// Asks it to stop, then makes sure. Used where a leaked process must /// Asks it to stop, then makes sure. Used where a leaked process must actually
/// actually end: a deleted session, or one being replaced. /// end: a deleted session, or one being replaced.
/// ///
/// SIGTERM first because the CLI writes its own session file on the way /// SIGTERM first because the CLI writes its own session file on the way out and
/// out and a SIGKILL would cost whatever it had not flushed; SIGKILL after /// a SIGKILL would cost whatever it had not flushed; SIGKILL after the grace
/// the grace period because a session the phone has deleted must not still /// period because a session the phone has deleted must not still be running.
/// be running when it looks again.
pub fn stop(record: &Record, grace: std::time::Duration) { pub fn stop(record: &Record, grace: std::time::Duration) {
if record.liveness() != Liveness::Alive { if record.liveness() != Liveness::Alive {
return; return;
@@ -236,23 +210,20 @@ pub fn stop(record: &Record, grace: std::time::Duration) {
}); });
} }
/// Waits for processes already asked to stop, and kills whichever have /// Waits for processes already asked to stop, and kills whichever have not, for
/// not, for a caller that is about to exit. /// a caller that is about to exit.
/// ///
/// The waiting cannot be [`stop`]'s here, and that is the whole reason /// The waiting cannot be [`stop`]'s here, and that is the whole reason this
/// this exists: the kill it leaves behind is a timer inside the tokio /// exists: the kill it leaves behind is a timer inside the tokio runtime, and a
/// runtime, and a runtime that is shutting down never runs it. That is /// runtime that is shutting down never runs it. That is how the original
/// how the backend's original `shutdown_all` leaked the processes it had /// `shutdown_all` leaked the processes it had just asked to stop -- it reported
/// just asked to stop -- it reported them stopped, too, which is worse /// them stopped, too, which is worse than not asking.
/// than not asking.
/// ///
/// One deadline for all of them rather than one each: they were signalled /// One deadline for all of them rather than one each: they were signalled
/// together, so waiting is bounded by the grace period however many there /// together, so waiting is bounded by the grace period however many there are.
/// are, and a server does not sit for a minute on the way out.
pub fn wait_gone(records: &[Record], grace: std::time::Duration) { pub fn wait_gone(records: &[Record], grace: std::time::Duration) {
/// How often to look. Short enough that the ordinary case -- a /// How often to look. Short enough that a process that goes at once costs
/// process that goes at once -- costs nothing noticeable, and long /// nothing noticeable, and long enough not to spin.
/// enough not to spin.
const LOOK: std::time::Duration = std::time::Duration::from_millis(20); const LOOK: std::time::Duration = std::time::Duration::from_millis(20);
let deadline = std::time::Instant::now() + grace; let deadline = std::time::Instant::now() + grace;
@@ -264,11 +235,10 @@ pub fn wait_gone(records: &[Record], grace: std::time::Duration) {
} }
} }
/// The end of both paths above: a process that was asked to stop and did /// The end of both paths above: a process that was asked to stop and did not is
/// not is killed. Written once because the two callers differ only in how /// killed. Written once because the two callers differ only in how they wait,
/// they wait, and a grace period that means one thing in one of them and /// and a grace period meaning one thing in one and something else in the other
/// something else in the other is exactly the drift `STOP_GRACE` was /// is exactly the drift `STOP_GRACE` was gathered here to prevent.
/// gathered here to prevent.
fn kill_if_still_there(record: &Record, grace: std::time::Duration) { fn kill_if_still_there(record: &Record, grace: std::time::Duration) {
if record.liveness() == Liveness::Alive { if record.liveness() == Liveness::Alive {
tracing::warn!( tracing::warn!(
@@ -281,61 +251,56 @@ fn kill_if_still_there(record: &Record, grace: std::time::Duration) {
} }
fn signal(pid: u32, signal: libc::c_int) { fn signal(pid: u32, signal: libc::c_int) {
// SAFETY: `kill` with a positive pid touches only that process, and // SAFETY: `kill` with a positive pid touches only that process, and the pid
// the pid came from a record whose start time was just confirmed to // came from a record whose start time was just confirmed to match -- so it
// match -- so it is still the process this server started, not a // is still the process this server started, not a reused number. A failure
// reused number. A failure (already gone) is nothing to act on. // (already gone) is nothing to act on.
unsafe { unsafe {
libc::kill(pid as libc::pid_t, signal); libc::kill(pid as libc::pid_t, signal);
} }
} }
/// The kernel's start time for `pid`, in clock ticks since boot.
///
/// Field 22 of `/proc/<pid>/stat`, counted from the closing parenthesis of
/// field 2 rather than from the start of the line: a process's name is
/// field 2, it is wrapped in parentheses, and it may itself contain spaces
/// and parentheses. Splitting the whole line on whitespace therefore reads
/// the wrong field for anything with a space in its name.
///
/// Three outcomes, and they are not the same: `Ok(None)` is "no such
/// process", `Err` is "could not find out". Collapsing the second into the
/// first is what would let a machine without a readable `/proc` look like
/// a machine with nothing running on it. Linux-specific, like `import`'s
/// use of GNU `stat`.
/// What `/proc` says about a pid. /// What `/proc` says about a pid.
struct Stat { struct Stat {
/// The kernel's start time in clock ticks since boot -- see /// The kernel's start time in clock ticks since boot -- see
/// [`Record::started`]. /// [`Record::started`].
started: u64, started: u64,
/// State `Z`: the process has ended, and the kernel is keeping its /// State `Z`: the process has ended, and the kernel is keeping its entry
/// entry only until somebody collects the exit status. /// only until somebody collects the exit status.
/// ///
/// Read rather than ignored, because the entry it leaves behind has /// Read rather than ignored, because that entry has the same pid *and* the
/// the same pid *and* the same start time, so a process that has /// same start time, so a finished process goes on answering "still there"
/// plainly finished goes on answering "still there" for as long as /// for as long as nothing reaps it -- which makes `Exited` unsayable: the
/// nothing reaps it. None of this module's callers want that answer: a /// session shows `unknown`, its Start button never appears, and stopping it
/// session whose CLI has exited is over whether or not the status has /// says there is nothing to stop.
/// been collected, and reporting it alive makes `Exited` unsayable --
/// the session shows `unknown`, its Start button never appears, and
/// stopping it says there is nothing to stop.
exited: bool, exited: bool,
} }
/// The kernel's start time for `pid`, in clock ticks since boot.
///
/// Field 22 of `/proc/<pid>/stat`, counted from the closing parenthesis of
/// field 2 rather than from the start of the line: a process's name is field 2,
/// it is wrapped in parentheses, and it may itself contain spaces and
/// parentheses. Splitting the whole line on whitespace reads the wrong field
/// for anything with a space in its name.
///
/// Three outcomes, and they are not the same: `Ok(None)` is "no such process",
/// `Err` is "could not find out". Collapsing the second into the first is what
/// would let a machine without a readable `/proc` look like a machine with
/// nothing running on it. Linux-specific, like `import`'s use of GNU `stat`.
fn stat_of(pid: u32) -> std::io::Result<Option<Stat>> { fn stat_of(pid: u32) -> std::io::Result<Option<Stat>> {
let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) { let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Ok(stat) => stat, Ok(stat) => stat,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err), Err(err) => return Err(err),
}; };
// A `/proc` entry that exists but does not have the shape this reads // A `/proc` entry that exists but does not have the shape this reads is not
// is not a process that has gone away; it is a reading this code // a process that has gone away; it is a reading this code cannot make.
// cannot make, which is the other thing entirely.
let unreadable = let unreadable =
|| std::io::Error::new(std::io::ErrorKind::InvalidData, "unreadable /proc stat"); || std::io::Error::new(std::io::ErrorKind::InvalidData, "unreadable /proc stat");
let after_name = stat.rsplit_once(')').ok_or_else(unreadable)?.1; let after_name = stat.rsplit_once(')').ok_or_else(unreadable)?.1;
// Field 3 is the first after the name, so the state is the first here // Field 3 is the first after the name, so the state is the first here and
// and field 22 is the 20th. // field 22 is the 20th.
let mut fields = after_name.split_whitespace(); let mut fields = after_name.split_whitespace();
let exited = fields.next().ok_or_else(unreadable)? == "Z"; let exited = fields.next().ok_or_else(unreadable)? == "Z";
let started = fields let started = fields
@@ -346,9 +311,9 @@ fn stat_of(pid: u32) -> std::io::Result<Option<Stat>> {
Ok(Some(Stat { started, exited })) Ok(Some(Stat { started, exited }))
} }
/// Reads `path` from `from`, returning what is there and where reading /// Reads `path` from `from`, returning what is there and where reading reached.
/// reached. A file that has been truncated or replaced under us reads from /// A file truncated or replaced under us reads from the start, since the offset
/// the start, since the offset no longer means anything in it. /// no longer means anything in it.
pub fn read_from(path: &Path, from: u64) -> Result<(Vec<u8>, u64)> { pub fn read_from(path: &Path, from: u64) -> Result<(Vec<u8>, u64)> {
use std::io::{Read, Seek, SeekFrom}; use std::io::{Read, Seek, SeekFrom};
let mut file = match std::fs::File::open(path) { let mut file = match std::fs::File::open(path) {
@@ -380,8 +345,8 @@ mod tests {
.expect("this process has a start time"); .expect("this process has a start time");
assert_eq!(mine.liveness(), Liveness::Alive); assert_eq!(mine.liveness(), Liveness::Alive);
// The same pid with a different start time is a different process // The same pid with a different start time is a different process --
// -- which is the whole reason the start time is recorded. // which is the whole reason the start time is recorded.
let recycled = Record { let recycled = Record {
started: mine.started + 1, started: mine.started + 1,
..mine.clone() ..mine.clone()
@@ -416,8 +381,8 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut record = let mut record =
Record::of(std::process::id(), Detail::Stdio { stdout_read: 0 }).expect("start time"); Record::of(std::process::id(), Detail::Stdio { stdout_read: 0 }).expect("start time");
// Rewritten the way the reader rewrites it: constantly, as the // Rewritten the way the reader rewrites it: constantly, as the position
// position advances. Each one must land whole. // advances. Each one must land whole.
for read in [1u64, 4096, 2, 999_999] { for read in [1u64, 4096, 2, 999_999] {
record.detail = Detail::Stdio { stdout_read: read }; record.detail = Detail::Stdio { stdout_read: read };
write(dir.path(), &record); write(dir.path(), &record);
@@ -427,8 +392,8 @@ mod tests {
"after offset {read}" "after offset {read}"
); );
} }
// The rename is what makes it atomic; a leftover neighbour would // The rename is what makes it atomic; a leftover neighbour would mean it
// mean it had not happened. // had not happened.
let stray: Vec<_> = std::fs::read_dir(dir.path()) let stray: Vec<_> = std::fs::read_dir(dir.path())
.expect("read dir") .expect("read dir")
.filter_map(Result::ok) .filter_map(Result::ok)
@@ -468,8 +433,8 @@ mod tests {
assert_eq!(bytes, b"world"); assert_eq!(bytes, b"world");
assert_eq!(read, 11); assert_eq!(read, 11);
// An offset past the end means the file was replaced, so the // An offset past the end means the file was replaced, so the offset
// offset describes a file that no longer exists. // describes a file that no longer exists.
std::fs::write(&path, b"new").expect("truncate"); std::fs::write(&path, b"new").expect("truncate");
let (bytes, read) = read_from(&path, 11).expect("read"); let (bytes, read) = read_from(&path, 11).expect("read");
assert_eq!(bytes, b"new"); assert_eq!(bytes, b"new");
+228 -129
View File
@@ -41,9 +41,8 @@ impl Transcript {
/// the last line if one exists. /// the last line if one exists.
pub fn open(path: &Path) -> Result<Self> { pub fn open(path: &Path) -> Result<Self> {
// One pass for all three answers. They are wanted at the same moment // One pass for all three answers. They are wanted at the same moment
// by the same caller, and reading the file again for each doubled // by the same caller, and reading the file again for each doubled the
// the cost of starting every session -- which is paid per session, // cost of starting every session.
// at the point a restart is trying to be quick.
let existing = read_after(path, 0)?; let existing = read_after(path, 0)?;
let last_seq = existing.last().map(|entry| entry.seq).unwrap_or(0); let last_seq = existing.last().map(|entry| entry.seq).unwrap_or(0);
let last_status = existing.iter().rev().find_map(|entry| match entry.event { let last_status = existing.iter().rev().find_map(|entry| match entry.event {
@@ -63,9 +62,9 @@ impl Transcript {
next_seq: last_seq + 1, next_seq: last_seq + 1,
last_status, last_status,
last_activity: existing.last().map(|entry| entry.ts), last_activity: existing.last().map(|entry| entry.ts),
// Folded rather than read off the newest usage entry: a clear // Folded rather than read off the newest usage entry: a clear or
// or a compaction after it is what the answer is, and those // a compaction after it is what the answer is, and those events
// events carry no usage of their own. // carry no usage of their own.
context_tokens: existing context_tokens: existing
.iter() .iter()
.fold(None, |current, entry| context_after(current, &entry.event)), .fold(None, |current, entry| context_after(current, &entry.event)),
@@ -74,53 +73,43 @@ impl Transcript {
/// The state the session was last reported to be in, as of opening. /// The state the session was last reported to be in, as of opening.
/// ///
/// Read from the file rather than assumed, because a server that has /// Read from the file rather than assumed, because a server that has just
/// just restarted has been told nothing yet and this is the only thing /// restarted has been told nothing. Assuming idle claimed a session was
/// it knows. Assuming idle claimed a session was waiting for you when /// waiting for you when it had exited hours earlier.
/// it had exited hours earlier, and would now also claim it of one
/// whose process is still mid-turn.
/// ///
/// `None` for a transcript that never carried a status, which is a new /// `None` for a transcript that never carried a status.
/// session and genuinely has no prior state.
pub fn last_status(&self) -> Option<SessionStatus> { pub fn last_status(&self) -> Option<SessionStatus> {
self.last_status self.last_status
} }
/// When this session last did anything, as of opening. /// When this session last did anything, as of opening.
/// ///
/// Read from the file for the same reason [`Transcript::last_status`] /// Read from the file for the reason [`Transcript::last_status`] is, and it
/// is, and it is the same mistake in the other direction: a restarting /// is the same mistake in the other direction: taking the clock instead
/// server has been told nothing, and taking the clock instead said every /// said every session it relaunched had been active this second. On the
/// session it relaunched had been active this second. On the phone that /// phone that is every row reading "just now" and the list -- sorted by
/// is every row reading "just now" and the list -- which is sorted by /// this -- in an order that means nothing.
/// this -- coming back in an order that means nothing, with the
/// conversation somebody was in the middle of buried among sessions
/// untouched for days.
/// ///
/// `None` for a transcript with no lines in it, which is a session that /// `None` for a transcript with no lines, which is a session that genuinely
/// genuinely has not done anything yet. Its caller answers that with /// has not done anything. Its caller answers with when the session was
/// when the session was created -- not with the clock, which would say /// created, not with the clock.
/// a session nobody has ever sent anything to was active a moment ago,
/// every time this server started.
pub fn last_activity(&self) -> Option<f64> { pub fn last_activity(&self) -> Option<f64> {
self.last_activity self.last_activity
} }
/// How much context the session was holding, as of opening. /// How much context the session was holding, as of opening.
/// ///
/// `None` for a transcript nothing has been measured in -- a new /// `None` for a transcript nothing has been measured in. That is not zero:
/// session, one whose dialect never reported usage, or one whose last /// a server that has just restarted has been told nothing, and answering
/// word on the subject was a clear. That is not zero, and it is why /// zero would draw an empty context for a conversation that may be nearly
/// this is an option: a server that has just restarted has been told /// full.
/// nothing, and answering zero would draw an empty context for a
/// conversation that may be nearly full.
pub fn context_tokens(&self) -> Option<u64> { pub fn context_tokens(&self) -> Option<u64> {
self.context_tokens self.context_tokens
} }
/// Appends `event`, assigning it the next sequence number. Flushed per /// Appends `event`, assigning it the next sequence number. Flushed per
/// event: each line is tiny, and the transcript is the source of truth /// event: each line is tiny, and the transcript is the source of truth a
/// a crash must not lose the tail of. /// crash must not lose the tail of.
pub fn append(&mut self, event: Event, ts: f64) -> Result<SeqEvent> { pub fn append(&mut self, event: Event, ts: f64) -> Result<SeqEvent> {
let entry = SeqEvent { let entry = SeqEvent {
seq: self.next_seq, seq: self.next_seq,
@@ -139,18 +128,23 @@ impl Transcript {
/// A window of the transcript ending just before `before`, newest-biased. /// A window of the transcript ending just before `before`, newest-biased.
/// ///
/// The screen opens on the end of a conversation, not the start of it, and /// The screen opens on the end of a conversation, and the end is all it can
/// the end is all it can show at once. Replaying the whole file to get /// show at once. Replaying the whole file to get there costs one network frame
/// there costs one network frame per event -- on an 863-event import that /// per event -- on an 863-event import that was several seconds of messages
/// was several seconds of messages arriving oldest-first, which reads as /// arriving oldest-first, which reads as the app loading top-down.
/// the app loading top-down because that is exactly what it was doing.
/// ///
/// `before` pages backwards for history somebody actually scrolls to. Only /// `before` pages backwards for history somebody actually scrolls to. Only the
/// the window is parsed; see [`Indexed`] for why that is the whole cost of /// window is parsed; see [`Indexed`] for why that is the whole cost.
/// this call. ///
/// `after` is a floor: nothing at or below it is returned, and the page stops
/// there rather than at `limit`. A phone holding a cached run passes the end of
/// what it already has, so the page is exactly the gap and never overlaps its
/// copy -- an overlap it cannot store, since a coalesced event cannot be cut at
/// a seq inside its own delta run.
pub fn read_window( pub fn read_window(
path: &Path, path: &Path,
before: Option<u64>, before: Option<u64>,
after: Option<u64>,
limit: usize, limit: usize,
coalesce: bool, coalesce: bool,
) -> Result<Vec<SeqEvent>> { ) -> Result<Vec<SeqEvent>> {
@@ -161,56 +155,55 @@ pub fn read_window(
Some(before) => indexed.first_at_or_after(before)?, Some(before) => indexed.first_at_or_after(before)?,
None => indexed.lines.len(), None => indexed.lines.len(),
}; };
// Coalescing counts *rows*, not events, and would misread the newest window: a message still let start = match after {
// streaming there would fold to one event whose seq is its first delta, and the phone resumes Some(after) => indexed.first_at_or_after(after.saturating_add(1))?,
// its live stream from the newest seq it applied -- so the deltas the coalesced event hid None => 0,
// would replay and double. Only settled history (`before` set) is safe, and it is the only };
// place the phone asks for it. See `parse_coalesced`. // A floor above the window is an empty page, not a walk backwards past it.
let start = start.min(end);
// Coalescing counts *rows*, not events, and would misread the newest
// window: a message still streaming there would fold to one event whose seq
// is its first delta, and the phone resumes its live stream from the newest
// seq it applied -- so the deltas the coalesced event hid would replay and
// double. Only settled history (`before` set) is safe.
if coalesce && before.is_some() { if coalesce && before.is_some() {
indexed.parse_coalesced(end, limit) indexed.parse_coalesced(start, end, limit)
} else { } else {
indexed.parse(end.saturating_sub(limit)..end) indexed.parse(start.max(end.saturating_sub(limit))..end)
} }
} }
/// How far behind a reconnecting subscriber can be and still be handed the /// How far behind a reconnecting subscriber can be and still be handed the
/// backlog one event at a time. /// backlog one event at a time.
/// ///
/// Past this it is served better by rebuilding its view from the newest /// Past this it is served better by rebuilding from the newest window. The
/// window than by receiving everything it missed. The events are the same /// events are the same either way; what differs is that one arrives as a single
/// either way; what differs is that one arrives as a single window and the /// window and the other as thousands of frames a screen renders one by one. Set
/// other as thousands of frames a screen renders one by one. Set well /// well above a screenful so an ordinary blip still streams continuously.
/// above a screenful (`transcript`'s page is 80) so an ordinary blip -- a
/// phone asleep, a tunnel reconnecting, a backend restart -- still streams
/// continuously, and only a genuine backlog changes mode.
pub const CATCH_UP_LIMIT: usize = 200; pub const CATCH_UP_LIMIT: usize = 200;
/// What a subscriber asking for "everything after my cursor" gets back. /// What a subscriber asking for "everything after my cursor" gets back.
/// ///
/// Two answers rather than one list, because they mean different things to /// Two answers rather than one list, because they mean different things to the
/// the screen holding the cursor: one continues what it already has, the /// screen holding the cursor: one continues what it has, the other replaces it.
/// other replaces it. Collapsing them into a list would leave the client /// Collapsing them would leave the client splicing a window onto rows it has no
/// splicing a window onto rows it has no way to know are no longer /// way to know are no longer adjacent -- a seam that looks like ordinary output.
/// adjacent to it -- a seam that looks exactly like ordinary output.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum CatchUp { pub enum CatchUp {
/// The events after the cursor, continuing what the subscriber holds. /// The events after the cursor, continuing what the subscriber holds.
Continue(Vec<SeqEvent>), Continue(Vec<SeqEvent>),
/// The subscriber was further behind than [`CATCH_UP_LIMIT`]: the /// The subscriber was further behind than [`CATCH_UP_LIMIT`]: the newest
/// newest window, replacing whatever it holds. Earlier history is /// window, replacing whatever it holds. Earlier history is still there to be
/// still there to be paged backwards through, exactly as it is when a /// paged backwards through.
/// session is first opened.
Restart(Vec<SeqEvent>), Restart(Vec<SeqEvent>),
} }
/// Everything after `after`, or the newest `limit` when that is more than /// Everything after `after`, or the newest `limit` when that is more.
/// `limit` events.
/// ///
/// The window is chosen before anything is parsed, which matters most in /// The window is chosen before anything is parsed, which matters most in the
/// the case that looks least interesting: a subscriber with no cursor at /// case that looks least interesting: a subscriber with no cursor asks for the
/// all asks for the whole conversation and is going to be handed the last /// whole conversation and is handed the last [`CATCH_UP_LIMIT`] events of it,
/// [`CATCH_UP_LIMIT`] events of it. Parsing the discarded prefix first is /// so parsing the discarded prefix is the whole file's work for a screenful.
/// the whole file's worth of work to produce a screenful.
pub fn catch_up(path: &Path, after: u64, limit: usize) -> Result<CatchUp> { pub fn catch_up(path: &Path, after: u64, limit: usize) -> Result<CatchUp> {
let Some(indexed) = Indexed::read(path)? else { let Some(indexed) = Indexed::read(path)? else {
return Ok(CatchUp::Continue(Vec::new())); return Ok(CatchUp::Continue(Vec::new()));
@@ -234,26 +227,22 @@ pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
indexed.parse(start..indexed.lines.len()) indexed.parse(start..indexed.lines.len())
} }
/// The transcript's lines located but not read, so that a reader can find /// The transcript's lines located but not read, so a reader can find the range
/// the range it wants and parse only that. /// it wants and parse only that.
/// ///
/// Both readers above want a *range* of the file -- everything after a /// Both readers above want a *range* of the file, and both used to reach it by
/// cursor, or the window before one -- and both used to reach it by parsing /// parsing every line and discarding the ones outside it -- the cost that grows
/// every line and discarding the ones outside it. That is the cost that /// with the conversation rather than with the answer. Measured on a 21 MB,
/// grows with the conversation rather than with the answer: measured on a /// 24,000-event transcript, one page took **500 ms of server time to return
/// 21 MB, 24,000-event transcript, one page took **500 ms of server time to /// 600 KB**, and the same 500 ms whichever page was asked for. A phone paging
/// return 600 KB**, and it took the same 500 ms whichever page was asked /// back pays it per page, and every stream reconnect pays it again to discover
/// for, since the work was the file rather than the window. A phone paging /// there is nothing new.
/// back through history pays it per page, and every stream reconnect pays
/// it again to discover there is nothing new.
/// ///
/// Sequence numbers only ever increase -- the writer assigns them, one per /// Sequence numbers only ever increase, so the boundary of a range is a
/// appended line, continuing from the last on reopen -- so the boundary of /// bisection: this parses one line per halving, and the caller parses only what
/// a range is a bisection. This parses one line per halving, and the caller /// it returns. The file is still read whole, which is a deliberate stop --
/// parses only what it is going to return. The file is still read whole, /// going further means a chunked backwards reader, and locating a line is not
/// which is a deliberate stop: finding the tail without reading forwards /// what the half-second was going to.
/// means a chunked backwards reader, and locating a line is not what the
/// half-second was going to.
struct Indexed<'a> { struct Indexed<'a> {
path: &'a Path, path: &'a Path,
text: String, text: String,
@@ -287,14 +276,13 @@ impl<'a> Indexed<'a> {
Ok(Some(Self { path, text, lines })) Ok(Some(Self { path, text, lines }))
} }
/// The index of the first line numbered `seq` or higher, or the end /// The index of the first line numbered `seq` or higher, or the end when
/// when every line is older than that. /// every line is older than that.
/// ///
/// A bisection, which is only correct because the file is in sequence /// A bisection, which is only correct because the file is in sequence order.
/// order; it is append-only and nothing else writes it. A line that /// A line that cannot be read is reported here rather than silently treated
/// cannot be read is reported here rather than silently treated as /// as out of range, because the answer would be a window off by however much
/// out of range, because the answer would be a window off by however /// of the file the bad line hid.
/// much of the file the bad line hid.
fn first_at_or_after(&self, seq: u64) -> Result<usize> { fn first_at_or_after(&self, seq: u64) -> Result<usize> {
let (mut low, mut high) = (0, self.lines.len()); let (mut low, mut high) = (0, self.lines.len());
while low < high { while low < high {
@@ -335,23 +323,21 @@ impl<'a> Indexed<'a> {
.with_context(|| format!("bad transcript line in {}", self.path.display())) .with_context(|| format!("bad transcript line in {}", self.path.display()))
} }
/// The newest `limit` *rows* ending at line `end`, with each run of consecutive streamed /// The newest `limit` *rows* ending at line `end`, with each run of
/// [`Event::AssistantText`] deltas concatenated into one. /// consecutive [`Event::AssistantText`] deltas concatenated into one.
/// ///
/// A reply is stored a token at a time -- hundreds of `AssistantText` events for one message -- /// A reply is stored a token at a time, so a window counted in events is a
/// so a window counted in events is a fraction of a row for a reply and a whole row for a tool /// fraction of a row for a reply and a whole row for a tool call, and the
/// call, and the phone can neither predict how much a page will show nor fill a screen without /// phone can neither predict how much a page will show nor fill a screen
/// folding a page's worth of near-duplicate events. Counted in rows, a page is a page: this /// without folding a page of near-duplicate events. Counted in rows, a page
/// walks back from `end`, joining each delta run into the single event the phone would fold it /// is a page.
/// into anyway, and stops once `limit` of them are gathered.
/// ///
/// A run takes the seq and time of its *oldest* delta, matching the phone's own rule that a /// A run takes the seq and time of its *oldest* delta, matching the phone's
/// streamed message keeps the seq of its first delta -- so anchors, and the `before` cursor the /// own rule -- so anchors and the `before` cursor land where they always
/// next page pages from, land where they always did. A run cut by the `limit` (its older /// did. A run cut by the `limit` is emitted as the partial it is, and the
/// deltas beyond this page) is emitted as the partial it is; the next page carries the rest and /// phone's `healSplitMessage` welds it to the next page. `start` is the same
/// the phone's `healSplitMessage` welds the two, exactly as it does for a run cut by any page /// kind of cut from the other end.
/// boundary. fn parse_coalesced(&self, start: usize, end: usize, limit: usize) -> Result<Vec<SeqEvent>> {
fn parse_coalesced(&self, end: usize, limit: usize) -> Result<Vec<SeqEvent>> {
// Newest first while walking back, reversed to transcript order at the end. // Newest first while walking back, reversed to transcript order at the end.
let mut out: Vec<SeqEvent> = Vec::new(); let mut out: Vec<SeqEvent> = Vec::new();
// The run currently being gathered: its oldest seq/ts so far, and its deltas newest-first. // The run currently being gathered: its oldest seq/ts so far, and its deltas newest-first.
@@ -369,10 +355,10 @@ impl<'a> Indexed<'a> {
} }
}; };
let mut index = end; let mut index = end;
while index > 0 { while index > start {
// A row is counted when it lands in `out`; an open run is the row being gathered, so // A row is counted when it lands in `out`; an open run is the row
// stopping while one is open would drop the deltas already read. Break only between // being gathered, so stopping while one is open would drop the
// rows, and flush the last run after the loop. // deltas already read. Break only between rows.
if out.len() >= limit && run.is_none() { if out.len() >= limit && run.is_none() {
break; break;
} }
@@ -471,9 +457,9 @@ mod tests {
assert_eq!(events[0].seq, 6); assert_eq!(events[0].seq, 6);
assert_eq!(events[4].seq, 10); assert_eq!(events[4].seq, 10);
// Exactly at the limit is still a continuation: the boundary // Exactly at the limit is still a continuation: the boundary belongs to
// belongs to the cheaper answer, so a client is not reset for // the cheaper answer, so a client is not reset for being one event
// being one event behind the threshold. // behind the threshold.
assert!(matches!( assert!(matches!(
catch_up(&path, 5, 5).expect("catch up"), catch_up(&path, 5, 5).expect("catch up"),
CatchUp::Continue(_) CatchUp::Continue(_)
@@ -485,8 +471,8 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl"); let path = dir.path().join("transcript.jsonl");
// Nothing recorded yet: no prior state to report, which is not the // Nothing recorded yet: no prior state to report, which is not the same
// same as reporting idle. // as reporting idle.
assert_eq!(Transcript::open(&path).expect("open").last_status(), None); assert_eq!(Transcript::open(&path).expect("open").last_status(), None);
let mut transcript = Transcript::open(&path).expect("open"); let mut transcript = Transcript::open(&path).expect("open");
@@ -528,7 +514,7 @@ mod tests {
} }
// No cursor is the newest page, which is what opening a session asks for. // No cursor is the newest page, which is what opening a session asks for.
let newest = read_window(&path, None, 3, false).expect("window"); let newest = read_window(&path, None, None, 3, false).expect("window");
assert_eq!( assert_eq!(
newest.iter().map(|entry| entry.seq).collect::<Vec<_>>(), newest.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
[8, 9, 10] [8, 9, 10]
@@ -536,7 +522,7 @@ mod tests {
// Then backwards from the oldest of those, exclusive: the page a phone // Then backwards from the oldest of those, exclusive: the page a phone
// scrolling up asks for must not repeat the row it is scrolling from. // scrolling up asks for must not repeat the row it is scrolling from.
let older = read_window(&path, Some(8), 3, false).expect("window"); let older = read_window(&path, Some(8), None, 3, false).expect("window");
assert_eq!( assert_eq!(
older.iter().map(|entry| entry.seq).collect::<Vec<_>>(), older.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
[5, 6, 7] [5, 6, 7]
@@ -544,24 +530,108 @@ mod tests {
// Asking for more than there is gives what there is, rather than failing. // Asking for more than there is gives what there is, rather than failing.
assert_eq!( assert_eq!(
read_window(&path, None, 100, false).expect("window").len(), read_window(&path, None, None, 100, false)
.expect("window")
.len(),
10 10
); );
// Nothing before the first event, which is how the phone learns to stop // Nothing before the first event, which is how the phone learns to stop
// paging. An empty answer here is the end of the history, not a fault. // paging. An empty answer here is the end of the history, not a fault.
assert!( assert!(
read_window(&path, Some(1), 3, false) read_window(&path, Some(1), None, 3, false)
.expect("window") .expect("window")
.is_empty() .is_empty()
); );
assert!( assert!(
read_window(&dir.path().join("nope.jsonl"), None, 3, false) read_window(&dir.path().join("nope.jsonl"), None, None, 3, false)
.expect("window") .expect("window")
.is_empty() .is_empty()
); );
} }
#[test]
fn a_floor_stops_a_page_at_what_the_caller_already_holds() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl");
let mut transcript = Transcript::open(&path).expect("open");
for n in 1..=10 {
transcript
.append(text(&n.to_string()), 0.0)
.expect("append");
}
// The floor is exclusive, like the SSE route's `after`, and it -- not the
// limit -- is what the page stops at. This is the gap between a phone's
// cached run and the window on its screen, fetched exactly.
let page = read_window(&path, Some(9), Some(5), 100, false).expect("window");
assert_eq!(
page.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
[6, 7, 8]
);
// A limit smaller than the gap still bites; the floor is a bound, not a
// replacement for one.
let page = read_window(&path, Some(9), Some(2), 3, false).expect("window");
assert_eq!(
page.iter().map(|entry| entry.seq).collect::<Vec<_>>(),
[6, 7, 8]
);
// A floor at or above the window is an empty page, not a walk past it.
assert!(
read_window(&path, Some(4), Some(9), 10, false)
.expect("window")
.is_empty()
);
}
#[test]
fn a_floor_inside_a_delta_run_leaves_the_partial_run_it_cuts() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl");
let mut transcript = Transcript::open(&path).expect("open");
for d in ["a", "b", "c", "d"] {
transcript.append(text(d), 0.0).expect("append"); // seq 1..4
}
transcript
.append(
Event::ToolStart {
id: "t".into(),
tool: "Bash".into(),
input: serde_json::Value::Null,
},
0.0,
)
.expect("append"); // seq 5
// Cut inside the run: what comes back is the deltas above the floor, seq'd
// at the first of them -- the partial the phone's `healSplitMessage` welds
// onto the rest, the same as a run cut by the limit.
let rows = read_window(&path, Some(6), Some(2), 10, true).expect("window");
assert_eq!(rows.len(), 2);
assert!(matches!(
&rows[0],
SeqEvent { seq: 3, event: Event::AssistantText { delta }, .. } if delta == "cd"
));
assert!(matches!(
&rows[1],
SeqEvent {
seq: 5,
event: Event::ToolStart { .. },
..
}
));
// And with no floor the whole run is one row, as before.
let rows = read_window(&path, Some(6), None, 10, true).expect("window");
assert_eq!(rows.len(), 2);
assert!(matches!(
&rows[0],
SeqEvent { seq: 1, event: Event::AssistantText { delta }, .. } if delta == "abcd"
));
}
#[test] #[test]
fn coalescing_counts_rows_and_joins_delta_runs() { fn coalescing_counts_rows_and_joins_delta_runs() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
@@ -588,7 +658,7 @@ mod tests {
// Three rows asked for, three rows returned -- each delta run one event -- where a raw // Three rows asked for, three rows returned -- each delta run one event -- where a raw
// window of three would have shown one and a half tokens of the newer reply. // window of three would have shown one and a half tokens of the newer reply.
let rows = read_window(&path, Some(8), 3, true).expect("window"); let rows = read_window(&path, Some(8), None, 3, true).expect("window");
assert_eq!(rows.len(), 3); assert_eq!(rows.len(), 3);
// A run keeps its oldest delta's seq, so the phone anchors and pages from where it always // A run keeps its oldest delta's seq, so the phone anchors and pages from where it always
// did. // did.
@@ -610,14 +680,43 @@ mod tests {
)); ));
// The next page pages from the oldest row's seq and returns the rest, no repeat, no gap. // The next page pages from the oldest row's seq and returns the rest, no repeat, no gap.
let older = read_window(&path, Some(1), 3, true).expect("window"); let older = read_window(&path, Some(1), None, 3, true).expect("window");
assert!(older.is_empty()); assert!(older.is_empty());
// The newest window never coalesces even when asked: the live cursor depends on real seqs. // The newest window never coalesces even when asked: the live cursor depends on real seqs.
let newest = read_window(&path, None, 2, true).expect("window"); let newest = read_window(&path, None, None, 2, true).expect("window");
assert_eq!(newest.iter().map(|e| e.seq).collect::<Vec<_>>(), [6, 7]); assert_eq!(newest.iter().map(|e| e.seq).collect::<Vec<_>>(), [6, 7]);
} }
#[test]
fn a_line_read_back_is_the_line_that_was_written() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl");
// A timestamp with enough digits to be lost: the clock produces these all day, and this
// one is real (2026-09-04). serde_json's default float parser is not correctly rounded,
// so it read this back as ...0755 and every reader got a line one bit different from the
// one in the file -- while the SSE stream, which serializes the same struct, had already
// sent the original. Two answers to "what is line 1", indistinguishable by eye.
//
// Nothing on screen showed it: a `ts` is drawn as a relative time. What found it was the
// phone's transcript cache, which keeps the line it was sent and checks it against the
// server's own answer before resuming a stream from it -- so the mismatch turned into a
// cache thrown away and a transcript downloaded again, silently and only sometimes. The
// `float_roundtrip` feature in Cargo.toml is the fix; this is what keeps it.
let mut transcript = Transcript::open(&path).expect("open");
transcript
.append(text("hello"), 1788546972.6030757)
.expect("append");
drop(transcript);
let written = std::fs::read_to_string(&path).expect("read");
let entry = read_window(&path, None, None, 10, false).expect("window");
assert_eq!(
serde_json::to_string(&entry[0]).expect("serialize"),
written.trim()
);
}
#[test] #[test]
fn a_missing_file_reads_as_empty() { fn a_missing_file_reads_as_empty() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
+64 -84
View File
@@ -1,26 +1,23 @@
//! Where a session's process runs, and the only place that knows how. //! Where a session's process runs, and the only place that knows how.
//! //!
//! A driver says *what* to run -- a [`Launch`] -- and hands it here. //! A driver says *what* to run -- a [`Launch`] -- and hands it here. Whether
//! Whether that becomes a child of this process or an `ssh host …` //! that becomes a child of this process or an `ssh host …` invocation is
//! invocation is settled in this module, so a driver carries no transport //! settled in this module, so a driver carries no transport knowledge and a
//! knowledge and a second one cannot forget to handle the remote case. It //! second one cannot forget to handle the remote case. It also means the
//! also means the wrapping is honest about drivers that run nothing at //! wrapping is honest about drivers that run nothing at all: `EchoDriver`
//! all: `EchoDriver` builds no [`Launch`], so there is nothing to wrap and //! builds no [`Launch`], so there is no host for it to appear to honour.
//! no host for it to appear to honour.
//! //!
//! The quoting, the forced ssh options and the remote script are //! The quoting, the forced ssh options and the remote script are
//! `crate::ssh`'s, which this dispatches to. That split is deliberate: //! `crate::ssh`'s: this module decides *which* transport, that one knows what a
//! this module decides *which* transport, that one knows what a correct //! correct ssh invocation is.
//! ssh invocation is.
//! //!
//! A transport is therefore two operations rather than one: **run this**, //! A transport is therefore two operations rather than one: **run this** and
//! and **reach this port**. The second is what a managed `llama-server` //! **reach this port**. The second is what a managed `llama-server` needs -- it
//! needs -- it is spawned as a process and then spoken to over HTTP -- and //! is spawned as a process and then spoken to over HTTP -- and it is a no-op
//! it is a no-op locally, where the port a program binds is already a port //! locally, where the port a program binds is already one this machine can
//! this machine can dial. Over ssh it is an `-L` tunnel carried by the //! dial. Over ssh it is an `-L` tunnel on the same connection that runs the
//! same connection that runs the command, so the model server binds //! command, so the model server binds loopback on the far machine and is never
//! loopback on the far machine and is never exposed to its network. See //! exposed to its network. See [`Transport::reserve_port`].
//! [`Transport::reserve_port`] and PLAN.md's SSH section.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
@@ -31,12 +28,10 @@ use tokio::process::Child;
use crate::config::SshConfig; use crate::config::SshConfig;
pub use crate::ssh::Forward; pub use crate::ssh::Forward;
/// What a driver needs run in order to exist as a process. /// What a driver needs run in order to exist as a process. Deliberately just
/// /// what every transport can carry -- the command, where it runs, and a port the
/// Deliberately just what every transport can carry: the command, where /// caller needs to reach; anything a particular machine needs is the
/// it runs, and a port the caller needs to reach. Anything a particular /// transport's own configuration, not something a driver states.
/// machine needs -- a key, extra ssh options, which address to dial -- is
/// the transport's own configuration, not something a driver states.
pub struct Launch { pub struct Launch {
pub program: String, pub program: String,
pub args: Vec<String>, pub args: Vec<String>,
@@ -74,22 +69,20 @@ impl Launch {
/// How a launched process's standard streams are connected. /// How a launched process's standard streams are connected.
/// ///
/// The choice is not the transport's and not the driver's dialect: it is /// The choice is not the transport's and not the driver's dialect: it is
/// whether the process is expected to outlive this server. A probe is /// whether the process is expected to outlive this server. A probe answers
/// asked a question and answers within one call, so pipes this server /// within one call, so pipes this server drains are right. A session is a
/// drains are right and dying with it is right. A session is a
/// conversation somebody is having, so its streams live in the session /// conversation somebody is having, so its streams live in the session
/// directory where a later run of this server can pick them up again -- /// directory where a later run of this server can pick them up.
/// see `session::process`.
pub enum Streams { pub enum Streams {
/// Pipes owned by this server; the child is killed when they drop. /// Pipes owned by this server; the child is killed when they drop.
Piped, Piped,
/// The same, except that stdin is already open on something this /// The same, except that stdin is already open on something this server
/// server holds -- the file being copied to another machine. Bytes /// holds -- the file being copied to another machine. Bytes this process has
/// this process has in memory do not need this: [`Streams::Piped`] /// in memory do not need this: [`Streams::Piped`] gives a pipe to write them
/// gives a pipe to write them into as the child reads. /// into as the child reads.
PipedFrom(Stdio), PipedFrom(Stdio),
/// Files -- and, for stdin, a fifo the child itself holds open so it /// Files -- and, for stdin, a fifo the child itself holds open so it never
/// never reads EOF -- that outlast this process. /// reads EOF -- that outlast this process.
Detached { Detached {
stdin: Stdio, stdin: Stdio,
stdout: Stdio, stdout: Stdio,
@@ -103,8 +96,7 @@ pub enum Transport {
Here, Here,
/// Reached with the system `ssh` client. Owns its entry rather than /// Reached with the system `ssh` client. Owns its entry rather than
/// borrowing it, so a session keeps working against the config it was /// borrowing it, so a session keeps working against the config it was
/// spawned with even if the setup is edited afterwards. Carries the /// spawned with even if the setup is edited afterwards.
/// setup's name only to say where things are running.
Ssh { name: String, ssh: SshConfig }, Ssh { name: String, ssh: SshConfig },
} }
@@ -120,12 +112,10 @@ impl Transport {
} }
} }
/// Starts `launch` with its streams connected as `streams` says. /// Starts `launch` with its streams connected as `streams` says. The failure
/// /// names what to check, and the two transports fail for genuinely different
/// The failure names what to check, and the two transports fail for /// reasons -- a missing ssh client here versus a program not on the remote
/// genuinely different reasons -- a missing ssh client here versus a /// PATH -- so each says its own thing.
/// program that is not on the remote PATH -- so each says its own
/// thing rather than one message hedging between them.
pub fn spawn(&self, launch: &Launch, streams: Streams) -> Result<Child> { pub fn spawn(&self, launch: &Launch, streams: Streams) -> Result<Child> {
let host = match self { let host = match self {
Self::Here => None, Self::Here => None,
@@ -159,11 +149,9 @@ impl Transport {
stderr, stderr,
} => { } => {
command.stdin(stdin).stdout(stdout).stderr(stderr); command.stdin(stdin).stdout(stdout).stderr(stderr);
// No `kill_on_drop`: outliving this server is the point. // No `kill_on_drop`: outliving this server is the point. Its own
// Its own process group as well, so a signal sent to the // process group as well, so a signal sent to the server's group
// server's group -- which is how a terminal or a // does not travel to a session meant to survive being stopped.
// supervisor stops it -- does not travel to a session that
// is meant to survive being stopped.
command.process_group(0); command.process_group(0);
} }
} }
@@ -181,13 +169,10 @@ impl Transport {
}) })
} }
/// Runs `launch` to completion and returns its stdout, blocking. /// Runs `launch` to completion and returns its stdout, blocking. The
/// /// synchronous twin of `capture`, for callers already on a blocking task that
/// The synchronous twin of `capture`, for callers that are already on a /// would otherwise need a runtime to ask a machine a question. Both build the
/// blocking task and would otherwise need a runtime to ask a machine a /// invocation the same way.
/// question. Both build the invocation the same way -- see
/// `crate::ssh::command` -- so there is still only one description of
/// what running something on another machine means.
pub fn capture_blocking(&self, launch: &Launch) -> Result<String> { pub fn capture_blocking(&self, launch: &Launch) -> Result<String> {
let host = match self { let host = match self {
Self::Here => None, Self::Here => None,
@@ -216,21 +201,19 @@ impl Transport {
/// Runs `launch` with `input` on its stdin and reports everything it /// Runs `launch` with `input` on its stdin and reports everything it
/// produced -- stdout as bytes, stderr as text, and the exit status. /// produced -- stdout as bytes, stderr as text, and the exit status.
/// ///
/// The one description of "run this there, with this on stdin", so /// The one description of "run this there, with this on stdin", so that
/// that shipping an attachment and writing a file through the explorer /// shipping an attachment and writing a file through the explorer are the
/// are the same operation rather than two. It is also the only capture /// same operation rather than two. It is also the only capture that hands
/// that hands back the **status**: a script can then answer with an /// back the **status**: a script can answer with an exit code the caller
/// exit code the caller distinguishes (the explorer's write says /// distinguishes (the explorer's write says `exit 3` for "this file is not
/// `exit 3` for "this file is not the one you read"), which /// the one you read"), which [`Transport::capture`] cannot express.
/// [`Transport::capture`] cannot express because it turns every
/// failure into one error.
/// ///
/// Bytes rather than a `String`, because a file's contents are not /// Bytes rather than a `String`, because a file's contents are not text
/// text until something has checked, and lossy decoding would replace /// until something has checked, and lossy decoding would replace the
/// the evidence that they are not. /// evidence that they are not.
/// ///
/// `Err` means the process could not be started at all; a process that /// `Err` means the process could not be started at all; a process that ran
/// ran and failed is a [`Captured`] with a status saying so. /// and failed is a [`Captured`] with a status saying so.
pub async fn capture_with_input(&self, launch: &Launch, input: Input) -> Result<Captured> { pub async fn capture_with_input(&self, launch: &Launch, input: Input) -> Result<Captured> {
let (streams, to_write) = match input { let (streams, to_write) = match input {
Input::None => (Streams::Piped, None), Input::None => (Streams::Piped, None),
@@ -239,13 +222,11 @@ impl Transport {
}; };
let mut child = self.spawn(launch, streams)?; let mut child = self.spawn(launch, streams)?;
if let Some(bytes) = to_write { if let Some(bytes) = to_write {
// Written from a task rather than before the wait, because the // Written from a task rather than before the wait, because the child
// child may not read all of it -- the write script exits // may not read all of it -- the write script exits without reading
// without reading when the file has changed underneath -- and // when the file has changed underneath -- and a caller blocked on
// a caller blocked on filling a pipe nobody is draining would // filling a pipe nobody is draining would deadlock instead of getting
// deadlock instead of getting that answer. The broken pipe is // that answer. The broken pipe is the expected end of this write.
// the expected end of this write, so it is dropped: what
// happened is the exit status below.
let mut stdin = child.stdin.take().context("the child has no stdin")?; let mut stdin = child.stdin.take().context("the child has no stdin")?;
tokio::spawn(async move { tokio::spawn(async move {
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
@@ -308,11 +289,11 @@ const FAR_PORTS: std::ops::Range<u16> = 20000..30000;
/// What a command is given on its standard input. /// What a command is given on its standard input.
/// ///
/// Three cases rather than an `Option<Stdio>` because they are three /// Three cases rather than an `Option<Stdio>` because they are three genuinely
/// genuinely different arrangements and only this knows which: nothing to /// different arrangements and only this knows which: nothing to say, bytes this
/// say, bytes this process is holding, or a file it has open. The last one /// process is holding, or a file it has open. The last is how a
/// is how a several-hundred-megabyte attachment reaches another machine /// several-hundred-megabyte attachment reaches another machine without passing
/// without passing through this server's memory. /// through this server's memory.
pub enum Input { pub enum Input {
None, None,
Bytes(Vec<u8>), Bytes(Vec<u8>),
@@ -323,10 +304,9 @@ pub enum Input {
pub struct Captured { pub struct Captured {
pub status: std::process::ExitStatus, pub status: std::process::ExitStatus,
pub stdout: Vec<u8>, pub stdout: Vec<u8>,
/// Trimmed, and what a failure is reported as: ssh's own refusals and /// Trimmed, and what a failure is reported as: ssh's own refusals and a
/// a tool's own message about the file it could not open are both the /// tool's own message about the file it could not open are both the useful
/// useful half of why something did not work, and both are written to /// half of why something did not work.
/// name the thing.
pub stderr: String, pub stderr: String,
} }
+53 -70
View File
@@ -1,33 +1,28 @@
//! Finding out what a machine can run, rather than being told. //! Finding out what a machine can run, rather than being told.
//! //!
//! The phone adds a machine by giving connection details; this asks the //! The phone adds a machine by giving connection details; this asks the machine
//! machine itself which of the known programs it has, and the answer //! itself which of the known programs it has, and the answer becomes its
//! becomes its providers. That is a security property, not a convenience: //! providers. That is a security property, not a convenience: **no route accepts
//! **no route accepts a command from the phone.** If it did, the enrolled //! a command from the phone.** If it did, the enrolled token could introduce
//! token would be able to introduce arbitrary programs to run on every //! arbitrary programs to run on every machine a setup names.
//! machine a setup names, and the transport already reaches those over
//! ssh. Here the phone's authority is "add this machine", never "run
//! this".
//! //!
//! It is also the better interface. Nobody wants to type an absolute path //! It is also the better interface: nobody wants to type an absolute path on a
//! on a phone keyboard, and a machine that has moved its binaries answers //! phone keyboard, and a machine that has moved its binaries answers correctly
//! correctly on the next probe without anyone editing anything. //! on the next probe.
//! //!
//! The cost is that a program somewhere unusual is invisible. That is a //! The cost is that a program somewhere unusual is invisible. The escape hatch
//! deliberate trade rather than an oversight: the escape hatch is editing //! is editing `config.ron` on the backend, which is exactly the authority the
//! `config.ron` on the backend, which is exactly the authority the phone //! phone is not being given.
//! is not being given.
use anyhow::Result; use anyhow::Result;
use crate::config::{DriverKind, ProviderConfig}; use crate::config::{DriverKind, ProviderConfig};
use crate::session::transport::{Launch, Transport}; use crate::session::transport::{Launch, Transport};
/// What is looked for, and what finding it makes. /// What is looked for, and what finding it makes. Extending this is how a new
/// /// driver becomes discoverable -- one row, not a branch anywhere. The name is
/// Extending this is how a new driver becomes discoverable -- one row, not /// what the provider gets called, so it is what the phone shows and what a
/// a branch anywhere. The name is what the provider gets called, so it is /// session stores.
/// what the phone shows and what a session stores.
const PROBES: &[(&str, &str, DriverKind)] = &[ const PROBES: &[(&str, &str, DriverKind)] = &[
("claude-cli", "claude", DriverKind::ClaudeCli), ("claude-cli", "claude", DriverKind::ClaudeCli),
// Named for the program rather than for where it runs: it runs // Named for the program rather than for where it runs: it runs
@@ -36,18 +31,16 @@ const PROBES: &[(&str, &str, DriverKind)] = &[
("llama-cpp", "llama-server", DriverKind::LlamaCpp), ("llama-cpp", "llama-server", DriverKind::LlamaCpp),
]; ];
/// Models offered for a discovered Claude CLI. A shortcut list for the /// Models offered for a discovered Claude CLI. A shortcut list for the spawn
/// spawn screen, not a restriction -- the field stays free text. /// screen, not a restriction -- the field stays free text.
const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"]; const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"];
/// Asks `transport`'s machine which of [`PROBES`] it has. /// Asks `transport`'s machine which of [`PROBES`] it has.
/// ///
/// One round trip rather than one per program: over ssh each would be a /// One round trip rather than one per program: over ssh each would be a separate
/// separate connection and handshake, and a person waiting on "test this /// connection and handshake. `command -v` is POSIX and a shell builtin, so it
/// setup" notices. `command -v` is POSIX and a shell builtin, so it works /// works whatever is installed -- and `|| true` keeps a missing program from
/// whatever is installed -- and `|| true` keeps a missing program from /// ending the loop, since the caller wants the whole answer.
/// ending the loop, since the caller wants the whole answer rather than
/// the first failure.
pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> { pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect(); let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect();
let script = format!( let script = format!(
@@ -58,9 +51,9 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
let found = transport.capture(&launch).await.map_err(explain)?; let found = transport.capture(&launch).await.map_err(explain)?;
let mut providers = Vec::new(); let mut providers = Vec::new();
// Echo runs inside this server, so it exists exactly where this server // Echo runs inside this server, so it exists exactly where this server does
// does and nowhere else. Nothing to probe for, and offering it on a // and nowhere else. Offering it on a remote machine would be a choice that
// remote machine would be a choice that changes nothing. // changes nothing.
if matches!(transport, Transport::Here) { if matches!(transport, Transport::Here) {
providers.push(ProviderConfig { providers.push(ProviderConfig {
name: crate::config::ECHO_PROVIDER.to_string(), name: crate::config::ECHO_PROVIDER.to_string(),
@@ -81,8 +74,8 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
name: (*name).to_string(), name: (*name).to_string(),
kind: *kind, kind: *kind,
// The resolved path rather than the bare name: PATH under a // The resolved path rather than the bare name: PATH under a
// non-interactive ssh session is not the one a person sees // non-interactive ssh session is not the one a person sees when they
// when they log in, so "it is on my PATH" is not enough. // log in, so "it is on my PATH" is not enough.
command: Some(path.to_string()), command: Some(path.to_string()),
models: match kind { models: match kind {
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(), DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
@@ -95,16 +88,14 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
/// Adds what to do to failures whose own wording does not say. /// Adds what to do to failures whose own wording does not say.
/// ///
/// ssh's messages are written for someone at a terminal on the backend, /// ssh's messages are written for someone at a terminal on the backend, which is
/// which is exactly who is not reading this one. Host key verification is /// exactly who is not reading this one. Host key verification is the case that
/// the case that matters: **every** machine fails it the first time, /// matters: **every** machine fails it the first time, so without this, adding a
/// because its key is not in `known_hosts` yet -- so without this, adding /// machine from the phone looks broken rather than unfinished.
/// a machine from the phone looks broken rather than unfinished.
/// ///
/// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking` /// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking` stays
/// stays at its default, so a first connection is a decision somebody /// at its default, so a first connection is a decision somebody makes on the
/// makes on the backend with the key in front of them, rather than /// backend with the key in front of them.
/// something this app quietly accepts on their behalf.
fn explain(err: anyhow::Error) -> anyhow::Error { fn explain(err: anyhow::Error) -> anyhow::Error {
let message = format!("{err:#}"); let message = format!("{err:#}");
if message.contains("Host key verification failed") { if message.contains("Host key verification failed") {
@@ -123,11 +114,9 @@ fn explain(err: anyhow::Error) -> anyhow::Error {
err err
} }
/// A short, stable, filename-safe id derived from a label. /// A short, stable, filename-safe id derived from a label. Derived once when a
/// /// setup is added and then fixed, so the label stays editable. Collisions are
/// Derived once when a setup is added and then fixed, so the label stays /// resolved by the caller, which is the only place that knows what exists.
/// editable. Collisions are resolved by the caller, which is the only
/// place that knows what already exists.
pub fn id_from(label: &str) -> String { pub fn id_from(label: &str) -> String {
let slug: String = label let slug: String = label
.chars() .chars()
@@ -163,25 +152,20 @@ pub fn tidy(value: &str) -> Option<String> {
}) })
} }
/// The inverse of [`tidy`]'s expansion: an absolute path under this /// The inverse of [`tidy`]'s expansion: an absolute path under this machine's
/// machine's home, written back as `~/…`. /// home, written back as `~/…`, so that a working directory reads on a phone the
/// way it is written by hand.
/// ///
/// So that a working directory reads on a phone the way it is written by /// Applied only to paths on **this** machine. `$HOME` here says nothing about
/// hand. `/home/bob/repos/ai-app-2` is most of a line on that screen and /// the home directory of a machine reached over ssh, so a remote path is stored
/// almost all of it is the part nobody is reading. /// exactly as it was typed and the remote shell is what expands it.
///
/// Applied only to paths on **this** machine. `$HOME` here says nothing
/// about the home directory of a machine reached over ssh, so a remote
/// path is stored exactly as it was typed -- where a `~` somebody wrote
/// stays a `~`, and the remote shell is what expands it
/// (`ssh::quote_path`).
pub fn shorten_home(path: &str) -> String { pub fn shorten_home(path: &str) -> String {
let Some(home) = std::env::home_dir() else { let Some(home) = std::env::home_dir() else {
return path.to_string(); return path.to_string();
}; };
let home = home.to_string_lossy(); let home = home.to_string_lossy();
// The separator has to be part of the match, or `/home/bobby` would be // The separator has to be part of the match, or `/home/bobby` would be read
// read as a path inside `/home/bob`. // as a path inside `/home/bob`.
match path.strip_prefix(home.as_ref()) { match path.strip_prefix(home.as_ref()) {
Some("") => "~".to_string(), Some("") => "~".to_string(),
Some(rest) if rest.starts_with('/') => format!("~{rest}"), Some(rest) if rest.starts_with('/') => format!("~{rest}"),
@@ -191,11 +175,10 @@ pub fn shorten_home(path: &str) -> String {
/// Runs a launch to completion and returns its stdout as text. /// Runs a launch to completion and returns its stdout as text.
/// ///
/// The common case of [`Transport::capture_with_input`]: nothing on stdin, /// The common case of [`Transport::capture_with_input`]: nothing on stdin, a
/// a failure reported as the machine's own words (ssh's "Permission /// failure reported as the machine's own words (ssh's "Permission denied" is the
/// denied" or "Could not resolve hostname" is the useful half of why a /// useful half of why a setup cannot be reached), and the output read as text
/// setup cannot be reached), and the output read as text because every /// because every caller here is asking a question whose answer is words.
/// caller here is asking a question whose answer is words.
impl Transport { impl Transport {
pub async fn capture(&self, launch: &Launch) -> Result<String> { pub async fn capture(&self, launch: &Launch) -> Result<String> {
let captured = self let captured = self
@@ -209,9 +192,9 @@ impl Transport {
mod tests { mod tests {
use super::*; use super::*;
/// The two halves of a home-relative path, which have to be inverses: /// The two halves of a home-relative path, which have to be inverses: what
/// what is stored is what the phone draws, and what the phone sends /// is stored is what the phone draws, and what the phone sends back is what
/// back is what a process is started in. /// a process is started in.
#[test] #[test]
fn a_home_path_shortens_and_expands_back() { fn a_home_path_shortens_and_expands_back() {
let Some(home) = std::env::home_dir() else { let Some(home) = std::env::home_dir() else {
@@ -223,8 +206,8 @@ mod tests {
assert_eq!(shorten_home(&home.to_string_lossy()), "~"); assert_eq!(shorten_home(&home.to_string_lossy()), "~");
assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref())); assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref()));
// Not a prefix match on the characters: a sibling directory whose // Not a prefix match on the characters: a sibling directory whose name
// name merely starts with the home directory's is not inside it. // merely starts with the home directory's is not inside it.
let sibling = format!("{}-backup/notes", home.to_string_lossy()); let sibling = format!("{}-backup/notes", home.to_string_lossy());
assert_eq!(shorten_home(&sibling), sibling); assert_eq!(shorten_home(&sibling), sibling);
assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts"); assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts");
+98 -134
View File
@@ -1,28 +1,26 @@
//! Building the command a driver actually spawns -- locally, or wrapped in //! Building the command a driver actually spawns -- locally, or wrapped in
//! `ssh` when the session names a host to run on. //! `ssh` when the session names a host to run on.
//! //!
//! The whole point of the session design is that a driver speaks JSONL over //! A driver speaks JSONL over a child process's stdio and doesn't care what
//! a child process's stdio and doesn't care what that child is. A remote //! that child is, so a remote session is the identical command with `ssh host …`
//! session is therefore the identical command with `ssh host …` in front: //! in front.
//! stdio doesn't care, so nothing downstream of here changes.
//! //!
//! Uses the system `ssh` client rather than a Rust SSH library, so //! Uses the system `ssh` client rather than a Rust SSH library, so
//! `~/.ssh/config`, agents, and jump hosts all keep working and there is //! `~/.ssh/config`, agents and jump hosts all keep working and there is only
//! only one place to configure connections (PLAN.md, rule 23). //! one place to configure connections.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use crate::config::SshConfig; use crate::config::SshConfig;
/// A port on the machine a command runs on, and the port that reaches it /// A port on the machine a command runs on, and the port that reaches it from
/// from the backend. /// the backend.
/// ///
/// The second half of what a transport is (PLAN.md's SSH section): "run /// The second half of what a transport is (PLAN.md's SSH section): "run this"
/// this" plus "reach this port". Locally the two numbers are the same one /// plus "reach this port". Locally the two numbers are one and nothing is
/// and nothing is forwarded; over ssh the connection carries an `-L` /// forwarded; over ssh the connection carries an `-L` tunnel, so a model server
/// tunnel, so a model server binds loopback on the far machine and is /// binds loopback on the far machine and is never exposed to its network.
/// never exposed to its network.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Forward { pub struct Forward {
/// What the launched program should listen on, on its own machine. /// What the launched program should listen on, on its own machine.
@@ -32,31 +30,26 @@ pub struct Forward {
pub here: u16, pub here: u16,
} }
/// Options forced onto every connection. `BatchMode` makes a missing key /// Options forced onto every connection. `BatchMode` makes a missing key fail
/// fail immediately with a readable message instead of hanging on a /// immediately with a readable message instead of hanging on a password prompt
/// password prompt that nothing can answer; the keepalives turn a silently /// nothing can answer; the keepalives turn a silently dropped link into a
/// dropped link into a process exit, which the session reports as `exited` /// process exit, which the session reports as `exited` rather than hanging.
/// rather than appearing to hang forever.
const SSH_OPTIONS: [&str; 3] = [ const SSH_OPTIONS: [&str; 3] = [
"BatchMode=yes", "BatchMode=yes",
"ServerAliveInterval=30", "ServerAliveInterval=30",
"ServerAliveCountMax=3", "ServerAliveCountMax=3",
]; ];
/// Builds the child process for `program args…`, run in `cwd`, either on /// Builds the child process for `program args…`, run in `cwd`, either on this
/// this machine (`ssh` absent) or on the machine it describes. /// machine (`ssh` absent) or on the machine it describes.
/// ///
/// Stdio is left alone: how the streams are connected is the caller's /// Stdio is left alone: how the streams are connected is the caller's decision
/// decision and differs by more than the transport does -- a probe wants /// and differs by more than the transport does -- a probe wants pipes it will
/// pipes it will drain, a session wants files that outlive this server -- /// drain, a session wants files that outlive this server.
/// so `Transport::spawn` applies it rather than this.
/// ///
/// A plain [`std::process::Command`], which `tokio` converts from, because /// A plain [`std::process::Command`], which `tokio` converts from, because not
/// not every caller is async: the usage fetch is blocking by nature (it /// every caller is async: the usage fetch is blocking by nature and should not
/// makes a blocking HTTP call) and reads a file from the same machine on /// have to build an ssh invocation of its own.
/// the way, and it should not have to build an ssh invocation of its own
/// to do that. One place knows what a correct invocation is; how it is run
/// is the caller's business.
pub fn command( pub fn command(
remote: Option<&SshConfig>, remote: Option<&SshConfig>,
program: &str, program: &str,
@@ -68,14 +61,11 @@ pub fn command(
let mut command = Command::new(program); let mut command = Command::new(program);
command.args(args); command.args(args);
if let Some(cwd) = cwd { if let Some(cwd) = cwd {
// Expanded here for the same reason `quote_path` expands it on // Expanded here for the same reason `quote_path` expands it on the
// the far side: a working directory typed as `~/repos/ai-app` // far side: a working directory typed as `~/repos/ai-app` has to
// has to mean the same thing whichever machine runs it. There // mean the same thing whichever machine runs it. There is no shell
// is no shell in this branch, so nothing else would -- // in this branch, so nothing else would -- `current_dir` would be
// `current_dir` would be handed the literal one-character // handed the literal one-character directory `~`.
// directory `~`, and the session would fail to start with an
// error naming a path nobody typed. Only the cwd, matching
// the remote side, where arguments stay literal.
command.current_dir(expand_home(cwd)); command.current_dir(expand_home(cwd));
} }
return command; return command;
@@ -83,39 +73,28 @@ pub fn command(
let mut command = Command::new("ssh"); let mut command = Command::new("ssh");
if let Some(forward) = forward { if let Some(forward) = forward {
// A forwarded process is not spoken to over stdio, and that // A forwarded process is not spoken to over stdio, and that changes how
// changes how it has to be shut down. Everything else here is a // it is shut down. Everything else here is a CLI reading its stdin, so
// CLI reading its stdin, so killing the ssh client closes that // killing the ssh client ends it; a `llama-server` never reads its own,
// stdin and the far process ends; a `llama-server` never reads // so the same kill left it running on the far machine with the model
// its own, so the same kill left it running on the far machine // loaded -- measured 2026-09-04, an orphan per stopped session. A pty
// holding the model in memory -- measured 2026-09-04, an orphan // is what makes sshd hang the far side up. `-tt` because this client
// per stopped session. A pty is what makes sshd hang the far side // has no terminal to inherit one from. The cost is a log that arrives
// up: when the connection goes, the master closes and the session // through a line discipline, which nothing parses.
// takes SIGHUP. `-tt` because this client has no terminal of its
// own to inherit one from.
//
// The cost is that its log arrives through a line discipline
// (CRLF, and whatever the program does when it thinks it is on a
// terminal). Nothing parses that log, so it is a fair trade for a
// process that reliably goes away.
command.arg("-tt"); command.arg("-tt");
// Loopback on both ends: the far side binds 127.0.0.1, so the // Loopback at both ends: the far side binds 127.0.0.1, so what it
// port it serves is reachable only through this connection and // serves is reachable only through this connection.
// never from that machine's network -- and the near end is bound
// to this host alone for the same reason.
command.args([ command.args([
"-L", "-L",
&format!("127.0.0.1:{}:127.0.0.1:{}", forward.here, forward.there), &format!("127.0.0.1:{}:127.0.0.1:{}", forward.here, forward.there),
]); ]);
// Without this a forward that cannot be set up is a warning on // Without this a forward that cannot be set up is a warning on stderr
// stderr and a session that runs anyway, answering nothing: the // and a session that runs anyway, answering nothing -- which would
// failure would arrive as "the model never became ready", which // arrive as "the model never became ready".
// is the wrong thing to go looking at.
command.args(["-o", "ExitOnForwardFailure=yes"]); command.args(["-o", "ExitOnForwardFailure=yes"]);
} else { } else {
// -T: no pty. This carries JSONL, and a pty would rewrite it // -T: no pty. This carries JSONL, and a pty would rewrite it (echo,
// (echo, CRLF translation, ^C handling) into something the parser // CRLF translation, ^C handling) into something the parser can't read.
// can't read.
command.arg("-T"); command.arg("-T");
} }
for option in SSH_OPTIONS { for option in SSH_OPTIONS {
@@ -129,9 +108,8 @@ pub fn command(
} }
if let Some(identity) = &ssh.identity_file { if let Some(identity) = &ssh.identity_file {
command.arg("-i").arg(identity); command.arg("-i").arg(identity);
// Without this, ssh may offer an agent key first and authenticate // Without this, ssh may offer an agent key first and authenticate as
// as somebody else entirely -- silently, and with different // somebody else entirely -- silently, and with different permissions.
// permissions than intended.
command.args(["-o", "IdentitiesOnly=yes"]); command.args(["-o", "IdentitiesOnly=yes"]);
} }
command.arg(&ssh.address); command.arg(&ssh.address);
@@ -139,11 +117,10 @@ pub fn command(
command command
} }
/// The single argument handed to the remote login shell. /// The single argument handed to the remote login shell. `exec` so the CLI
/// /// replaces that shell: the process the connection is attached to is then the
/// `exec` so the CLI replaces that shell: the process the connection is /// CLI itself, and dropping the connection takes it down rather than leaving an
/// attached to is then the CLI itself, and dropping the connection takes /// orphan behind a live wrapper.
/// it down rather than leaving an orphan behind a live wrapper.
fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String { fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
let mut script = String::new(); let mut script = String::new();
if let Some(cwd) = cwd { if let Some(cwd) = cwd {
@@ -163,11 +140,9 @@ fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
/// A path with a leading `~` replaced by this machine's home directory. /// A path with a leading `~` replaced by this machine's home directory.
/// ///
/// The local half of the rule [`quote_path`] states for the remote one, and /// The local half of the rule [`quote_path`] states for the remote one, and
/// the two are deliberately the same shape: the tilde is expanded, `~user` /// deliberately the same shape: the tilde is expanded, `~user` is not, and
/// is not (there is no portable expansion for another account's home), and /// nothing else in the path gains a meaning. A machine with no home directory
/// nothing else in the path gains a meaning. A machine with no home /// leaves the path alone, which fails with the operating system's own message.
/// directory at all leaves the path alone, which fails with the operating
/// system's own message rather than with a guess.
pub(crate) fn expand_home(path: &Path) -> PathBuf { pub(crate) fn expand_home(path: &Path) -> PathBuf {
let Some(rest) = path.to_str().and_then(|p| { let Some(rest) = path.to_str().and_then(|p| {
if p == "~" { if p == "~" {
@@ -187,23 +162,19 @@ pub(crate) fn expand_home(path: &Path) -> PathBuf {
/// Quotes a path, expanding a leading `~` and nothing else. /// Quotes a path, expanding a leading `~` and nothing else.
/// ///
/// [`quote`] is right for every other word crossing to the remote side and /// [`quote`] is right for every other word crossing to the remote side and
/// wrong for exactly one character. `~` means "expand me", and single /// wrong for exactly one character. `~` means "expand me", and single quotes
/// quotes are what stop expansion -- so a working directory typed as /// are what stop expansion -- so a working directory typed as `~/repos/ai-app`
/// `~/repos/ai-app` arrived as the literal four-character directory `~`, /// arrived as the literal four-character directory `~`, and the remote shell
/// and the remote shell said it did not exist. Which is true, and reads /// said it did not exist, which reads like the path being wrong.
/// like the path being wrong rather than the quoting.
/// ///
/// `"$HOME"` rather than handing the tilde to the shell unquoted: the /// `"$HOME"` rather than handing the tilde to the shell unquoted: the variable
/// variable is expanded, the expansion is not re-split or globbed because /// is expanded, the expansion is not re-split or globbed because it is
/// it is double-quoted, and everything after it stays single-quoted and /// double-quoted, and everything after it stays single-quoted and literal.
/// literal. So the one character that has to mean something keeps meaning /// `$HOME` is set by every shell this can land in, including the fish login
/// it, and nothing else gains a meaning. `$HOME` is set by every shell /// shell on the dev VM, so this does not depend on the remote shell being POSIX.
/// this can land in, including the fish login shell on the dev VM, which
/// is why this does not depend on the remote shell being POSIX.
/// ///
/// `~user` is deliberately not handled: there is no portable expansion for /// `~user` is deliberately not handled: there is no portable expansion for it,
/// it, and inventing one would mean guessing another account's home /// and inventing one would mean guessing another account's home directory.
/// directory. It stays literal and fails with the shell's own message.
pub(crate) fn quote_path(path: &str) -> String { pub(crate) fn quote_path(path: &str) -> String {
if path == "~" { if path == "~" {
return "\"$HOME\"".to_string(); return "\"$HOME\"".to_string();
@@ -214,15 +185,13 @@ pub(crate) fn quote_path(path: &str) -> String {
} }
} }
/// Single-quotes one word for a POSIX shell. /// Single-quotes one word for a POSIX shell. Everything crossing to the remote
/// /// side goes through here: paths, model names and prompts-as-arguments are all
/// Everything crossing to the remote side goes through here: paths, model /// attacker-adjacent input in a server whose whole job is running commands, and
/// names, and prompts-as-arguments are all attacker-adjacent input in a /// unquoted they would be shell syntax rather than data.
/// server whose whole job is running commands, and unquoted they would be
/// shell syntax rather than data.
pub(crate) fn quote(word: &str) -> String { pub(crate) fn quote(word: &str) -> String {
// Inside single quotes every character is literal except `'` itself, // Inside single quotes every character is literal except `'` itself, which
// which is closed, escaped, and reopened. // is closed, escaped, and reopened.
format!("'{}'", word.replace('\'', r"'\''")) format!("'{}'", word.replace('\'', r"'\''"))
} }
@@ -243,8 +212,8 @@ mod tests {
} }
/// A host with nothing configured but a name to dial, so `~/.ssh/config` /// A host with nothing configured but a name to dial, so `~/.ssh/config`
/// decides everything else -- the case that proves this adds no flags of /// decides everything else -- the case that proves this adds no flags of its
/// its own when it was not told to. /// own when it was not told to.
fn bare_host() -> SshConfig { fn bare_host() -> SshConfig {
SshConfig { SshConfig {
address: "vm".to_string(), address: "vm".to_string(),
@@ -311,13 +280,13 @@ mod tests {
assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string())); assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string()));
} }
/// The second half of a transport: the connection that runs the /// The second half of a transport: the connection that runs the command also
/// command also carries the port that reaches it. /// carries the port that reaches it.
/// ///
/// Both ends are pinned to loopback, which is the property that keeps /// Both ends are pinned to loopback, which is what keeps a model server off
/// a model server off the far machine's network -- asserted here /// the far machine's network -- asserted rather than trusted, because
/// rather than trusted, because dropping the addresses is a one-word /// dropping the addresses is a one-word edit that still works on a machine
/// edit that still works on a machine nobody else can reach. /// nobody else can reach.
#[test] #[test]
fn a_forwarded_port_rides_the_same_connection_as_the_command() { fn a_forwarded_port_rides_the_same_connection_as_the_command() {
let ssh = bare_host(); let ssh = bare_host();
@@ -337,9 +306,8 @@ mod tests {
.expect("a forward"); .expect("a forward");
assert_eq!(rendered[forward + 1], "127.0.0.1:41000:127.0.0.1:24242"); assert_eq!(rendered[forward + 1], "127.0.0.1:41000:127.0.0.1:24242");
assert!(rendered.contains(&"ExitOnForwardFailure=yes".to_string())); assert!(rendered.contains(&"ExitOnForwardFailure=yes".to_string()));
// The half that is easy to lose: without a pty the far process // The half that is easy to lose: without a pty the far process outlives
// outlives the connection, because nothing closes a stdin it // the connection, because nothing closes a stdin it never reads.
// never reads.
assert!(rendered.contains(&"-tt".to_string())); assert!(rendered.contains(&"-tt".to_string()));
assert!(!rendered.contains(&"-T".to_string())); assert!(!rendered.contains(&"-T".to_string()));
// Options come before the host, or ssh reads them as part of the // Options come before the host, or ssh reads them as part of the
@@ -350,8 +318,8 @@ mod tests {
"exec 'llama-server' '--port' '24242'" "exec 'llama-server' '--port' '24242'"
); );
// Nothing forwarded is nothing added: every other session is one // Nothing forwarded is nothing added: every other session is one of
// of these, and an -L on it would bind a port for no reason. // these, and an -L on it would bind a port for no reason.
let plain = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None)); let plain = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None));
assert!(!plain.contains(&"-L".to_string())); assert!(!plain.contains(&"-L".to_string()));
// And a session that *is* spoken to over stdio keeps its raw pipe. // And a session that *is* spoken to over stdio keeps its raw pipe.
@@ -359,19 +327,17 @@ mod tests {
assert!(!plain.contains(&"-tt".to_string())); assert!(!plain.contains(&"-tt".to_string()));
} }
/// The one character quoting must not swallow. /// The one character quoting must not swallow. A working directory typed as
/// /// `~/repos/ai-app` was arriving as the literal directory `~`, and the
/// A working directory typed as `~/repos/ai-app` was arriving as the /// remote shell reported it missing -- which reads as the path being wrong
/// literal directory `~`, and the remote shell reported it missing -- /// rather than the quoting being wrong, and cost an evening.
/// which reads as the path being wrong rather than the quoting being
/// wrong, and cost an evening on exactly that misreading.
#[test] #[test]
fn a_leading_tilde_expands_and_nothing_else_does() { fn a_leading_tilde_expands_and_nothing_else_does() {
assert_eq!(quote_path("~"), "\"$HOME\""); assert_eq!(quote_path("~"), "\"$HOME\"");
assert_eq!(quote_path("~/repos/ai-app"), "\"$HOME\"/'repos/ai-app'"); assert_eq!(quote_path("~/repos/ai-app"), "\"$HOME\"/'repos/ai-app'");
// Only leading, and only its own segment: a tilde anywhere else is // Only leading, and only its own segment: a tilde anywhere else is an
// an ordinary character in a filename, and `~user` has no portable // ordinary character in a filename, and `~user` has no portable
// expansion so it stays literal and fails with the shell's message. // expansion so it stays literal.
assert_eq!(quote_path("/tmp/~/x"), "'/tmp/~/x'"); assert_eq!(quote_path("/tmp/~/x"), "'/tmp/~/x'");
assert_eq!(quote_path("~user/x"), "'~user/x'"); assert_eq!(quote_path("~user/x"), "'~user/x'");
@@ -382,13 +348,11 @@ mod tests {
); );
} }
/// The same character, on the transport with no shell to expand it. /// The same character, on the transport with no shell to expand it. The
/// /// local branch runs the program directly, so a working directory of
/// The local branch runs the program directly, so a working directory /// `~/repos/ai-app` would reach `current_dir` as the literal one-character
/// of `~/repos/ai-app` would reach `current_dir` as the literal /// directory `~`. The two transports have to agree about what a tilde means
/// one-character directory `~` -- a session that fails to start, /// or a path is only portable by accident.
/// naming a path nobody typed. The two transports have to agree about
/// what a tilde means or a path is only portable by accident.
#[test] #[test]
fn a_local_cwd_expands_its_tilde_the_same_way() { fn a_local_cwd_expands_its_tilde_the_same_way() {
let Some(home) = std::env::home_dir() else { let Some(home) = std::env::home_dir() else {
@@ -415,9 +379,9 @@ mod tests {
#[test] #[test]
fn shell_metacharacters_cross_as_data_not_syntax() { fn shell_metacharacters_cross_as_data_not_syntax() {
// Expanding $HOME must not open a door for anything else: the rest // Expanding $HOME must not open a door for anything else: the rest stays
// stays single-quoted, so this remains one absurd path rather than // single-quoted, so this remains one absurd path rather than three
// three commands. // commands.
assert_eq!( assert_eq!(
quote_path("~/'; touch /tmp/pwned; '"), quote_path("~/'; touch /tmp/pwned; '"),
r#""$HOME"/''\''; touch /tmp/pwned; '\'''"#, r#""$HOME"/''\''; touch /tmp/pwned; '\'''"#,
@@ -429,8 +393,8 @@ mod tests {
assert_eq!(quote("$(whoami)"), "'$(whoami)'"); assert_eq!(quote("$(whoami)"), "'$(whoami)'");
assert_eq!(quote("it's"), r"'it'\''s'"); assert_eq!(quote("it's"), r"'it'\''s'");
// The end-to-end version of the same worry: a working directory // The end-to-end version of the same worry: a working directory that
// that tries to close the quote and start a new command. // tries to close the quote and start a new command.
let ssh = bare_host(); let ssh = bare_host();
let evil = Path::new("/tmp/'; touch /tmp/pwned; '"); let evil = Path::new("/tmp/'; touch /tmp/pwned; '");
let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil), None)); let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil), None));
+94 -114
View File
@@ -3,36 +3,31 @@
//! Polls `https://api.anthropic.com/api/oauth/usage` with the OAuth access //! Polls `https://api.anthropic.com/api/oauth/usage` with the OAuth access
//! token from Claude Code's local credential store. The endpoint is //! token from Claude Code's local credential store. The endpoint is
//! undocumented and has changed before, so everything here is best-effort: //! undocumented and has changed before, so everything here is best-effort:
//! every field is optional, and failure degrades to an "unavailable" //! every field is optional, and failure degrades to an "unavailable" snapshot
//! snapshot with the reason, never an error that breaks the screen. //! with the reason, never an error that breaks the screen.
//! //!
//! Two rules learned from others hitting this endpoint (see PLAN.md's //! Two rules learned from others hitting this endpoint: send `User-Agent:
//! references): send `User-Agent: claude-code/<version>` (without it, //! claude-code/<version>` (without it, requests land in an aggressively
//! requests land in an aggressively rate-limited bucket) and poll no more //! rate-limited bucket) and poll no more often than every 180 s. The cache
//! often than every 180 s. The cache below enforces the latter across any //! below enforces the latter across any number of phone refreshes; there is no
//! number of phone refreshes; there is no background poll at all -- the //! background poll at all.
//! screen's fetch is the trigger, so no session activity means no traffic.
//! //!
//! One [`UsageProvider`] per paid service, so a second service later is a //! One [`UsageProvider`] per paid service, so a second service later is a new
//! new impl behind the same snapshot shape, not a parallel screen. //! impl behind the same snapshot shape, not a parallel screen.
//! //!
//! **Asked of the machine that spends the tokens, not of this one.** A //! **Asked of the machine that spends the tokens, not of this one.** A session
//! session runs wherever its setup says, so the account being billed is //! runs wherever its setup says, so the account being billed is that machine's.
//! that machine's, and reading this machine's credentials reports on an //! In the layout this project aims at, `ai-server` is on the host, the host has
//! account that may have run nothing. In the layout this project is aiming //! no `claude` CLI, and the CLI machine is a remote -- so the one set of numbers
//! at that is not a rounding error: `ai-server` belongs on the host, the //! the screen could show would be an account with no sessions. Credentials are
//! host has no `claude` CLI, and the CLI machine is a remote -- so the one //! read through the session `Transport`, one snapshot per setup that offers
//! set of numbers the screen could show would be the numbers of an account //! Claude.
//! with no sessions. Credentials are therefore read through the session
//! `Transport`, one snapshot per setup that offers Claude.
//! //!
//! The token is read *to* the backend and the HTTP call is made from here, //! The token is read *to* the backend and the HTTP call is made from here, so
//! rather than running the request on the far machine: it needs no tooling //! the far machine needs nothing beyond a shell and the wire format stays in
//! there beyond a shell, and it keeps the one place that knows the wire //! one place. The cost is that a remote machine's token is in this process's
//! format in one place. The cost is that a remote machine's token is in //! memory for the length of a fetch, which is the same trust the backend
//! this process's memory for the length of a fetch, which is the same //! already has over that machine.
//! trust the backend already has over that machine (it can start processes
//! on it).
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -54,15 +49,13 @@ const USER_AGENT: &str = "claude-code/2.1.237";
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UsageWindow { pub struct UsageWindow {
/// The API's own word for which window this is -- `session` for the /// The API's own word for which window this is -- `session` for the
/// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind /// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind it
/// it starts sending. /// starts sending.
/// ///
/// Carried beside the label because a caller that wants one /// Carried beside the label because a caller that wants one particular
/// particular window has to be able to ask for it without matching on /// window has to ask for it without matching on display text: the label is
/// display text: the label is written for a person, is translated the /// written for a person and would silently select nothing the day it
/// moment anybody translates this app, and would silently select /// changes.
/// nothing the day it changes. The session screen's bar picks
/// `session` by this field.
pub kind: String, pub kind: String,
pub label: String, pub label: String,
/// 0-100. /// 0-100.
@@ -77,13 +70,11 @@ pub struct UsageWindow {
/// What came back when a machine was asked about its limits. /// What came back when a machine was asked about its limits.
/// ///
/// Four answers rather than a flag and a message, because the screen has to /// Four answers rather than a flag and a message, because the screen has to
/// treat them differently and a reader has to. "Nobody is logged in here" /// treat them differently. "Nobody is logged in here" is a machine working
/// is a machine working exactly as configured -- somebody chose not to put /// exactly as configured, while "I could not reach it" is a fault worth
/// an account on it -- while "I could not reach it" is a fault worth /// chasing, and "the endpoint refused me" says nothing about the machine at
/// chasing, and "the endpoint refused me" is a third thing that says /// all. Collapsing them into one `error` string made the first look like the
/// nothing about the machine at all. Collapsing them into one `error` /// last, so a perfectly healthy setup read as broken.
/// string made the first look like the last, so a perfectly healthy setup
/// read as broken.
#[derive(Debug, Clone, Serialize, PartialEq)] #[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "state", rename_all = "camelCase")] #[serde(tag = "state", rename_all = "camelCase")]
pub enum UsageState { pub enum UsageState {
@@ -102,11 +93,11 @@ pub enum UsageState {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UsageSnapshot { pub struct UsageSnapshot {
pub provider: String, pub provider: String,
/// Which machine these are the numbers for. The point of the whole /// Which machine these are the numbers for. The point of the whole module:
/// module: they belong to an account on a particular box. /// they belong to an account on a particular box.
pub setup: String, pub setup: String,
/// That machine's current label, resolved when the snapshot is built, /// That machine's current label, resolved when the snapshot is built, so
/// so renaming a setup renames it here too. /// renaming a setup renames it here too.
pub setup_name: String, pub setup_name: String,
#[serde(flatten)] #[serde(flatten)]
pub state: UsageState, pub state: UsageState,
@@ -142,9 +133,9 @@ pub trait UsageProvider: Send + Sync {
} }
} }
/// Reads the numbers behind Claude Code's `/usage` from one machine, using /// Reads the numbers behind Claude Code's `/usage` from one machine, using the
/// the credentials that machine stores -- nothing to configure, and it /// credentials that machine stores -- nothing to configure, and it reports on
/// reports on exactly the account whose CLI runs the sessions there. /// exactly the account whose CLI runs the sessions there.
pub struct ClaudeUsage { pub struct ClaudeUsage {
pub setup: String, pub setup: String,
pub setup_name: String, pub setup_name: String,
@@ -152,9 +143,9 @@ pub struct ClaudeUsage {
pub transport: Transport, pub transport: Transport,
} }
/// Where Claude Code keeps its credentials, as a shell word rather than a /// Where Claude Code keeps its credentials, as a shell word rather than a path:
/// path: `$HOME` is expanded by the shell on the machine being asked, /// `$HOME` is expanded by the shell on the machine being asked, which is the
/// which is the only place that knows what it is. /// only place that knows what it is.
const CREDENTIALS: &str = "$HOME/.claude/.credentials.json"; const CREDENTIALS: &str = "$HOME/.claude/.credentials.json";
impl ClaudeUsage { impl ClaudeUsage {
@@ -169,12 +160,9 @@ impl ClaudeUsage {
} }
} }
/// The machine's stored OAuth token, or which of the two ways of not /// The machine's stored OAuth token, or which of the two ways of not having
/// having one this is. /// one this is. Read through `sh -c` so `$HOME` resolves on the far machine;
/// /// a path built here would be this machine's home directory.
/// Read through `sh -c` so `$HOME` resolves on the far machine; a path
/// built here would be this machine's home directory, which over ssh
/// is somebody else's.
fn access_token(&self) -> Result<String, UsageState> { fn access_token(&self) -> Result<String, UsageState> {
let launch = Launch::new( let launch = Launch::new(
"sh", "sh",
@@ -194,8 +182,8 @@ impl ClaudeUsage {
.as_str() .as_str()
.map(String::from) .map(String::from)
}) })
// A file that exists but carries no token is the same situation // A file that exists but carries no token is the same situation as
// as no file: nobody has logged in here yet. // no file: nobody has logged in here yet.
.ok_or(UsageState::NotLoggedIn) .ok_or(UsageState::NotLoggedIn)
} }
} }
@@ -245,19 +233,17 @@ impl UsageProvider for ClaudeUsage {
/// Which kind of "no credentials" a failed read was. /// Which kind of "no credentials" a failed read was.
/// ///
/// The distinction is the point of having both states. `cat` failing /// The distinction is the point of having both states. `cat` failing because
/// because the file is not there is a machine nobody has logged in on -- /// the file is not there is a machine nobody has logged in on -- a decision
/// a decision somebody made, with nothing to fix. Anything else is a /// somebody made, with nothing to fix. Anything else is a machine this server
/// machine this server could not ask, which is a fault and reads as one. /// could not ask, which is a fault and reads as one.
/// ///
/// Matched on the shell's own words rather than an exit status because /// Matched on the shell's own words rather than an exit status because there is
/// there is only one: `cat` exits 1 for a missing file and ssh exits 255 /// only one that survives being wrapped in `sh -c` and passed back through ssh.
/// for a connection it could not make, but the message is what survives
/// being wrapped in `sh -c` and passed back through ssh.
fn why_no_credentials(detail: &str) -> UsageState { fn why_no_credentials(detail: &str) -> UsageState {
// "No such file or directory" is GNU and BSD coreutils; busybox says // "No such file or directory" is GNU and BSD coreutils; busybox says "can't
// "can't open". Anything unrecognised is treated as unreachable, // open". Anything unrecognised is treated as unreachable, which is the
// which is the answer that gets looked at rather than ignored. // answer that gets looked at rather than ignored.
let missing = ["No such file", "no such file", "can't open", "cannot open"]; let missing = ["No such file", "no such file", "can't open", "cannot open"];
if missing.iter().any(|phrase| detail.contains(phrase)) { if missing.iter().any(|phrase| detail.contains(phrase)) {
UsageState::NotLoggedIn UsageState::NotLoggedIn
@@ -268,10 +254,9 @@ fn why_no_credentials(detail: &str) -> UsageState {
} }
} }
/// Pulls the `limits` array apart, defensively: entries with no percent /// Pulls the `limits` array apart, defensively: entries with no percent are
/// are skipped, unknown kinds keep their raw name as the label rather /// skipped, and unknown kinds keep their raw name as the label rather than
/// than being dropped -- a new window appearing should show up, not /// being dropped -- a new window appearing should show up, not vanish.
/// vanish.
fn parse_windows(body: &Value) -> Vec<UsageWindow> { fn parse_windows(body: &Value) -> Vec<UsageWindow> {
let Some(limits) = body.get("limits").and_then(Value::as_array) else { let Some(limits) = body.get("limits").and_then(Value::as_array) else {
return Vec::new(); return Vec::new();
@@ -529,16 +514,15 @@ impl UsageProvider for EchoUsage {
/// Which paid services a machine can be asked about. /// Which paid services a machine can be asked about.
/// ///
/// Derived from what the setup says it can run, so a machine with no /// Derived from what the setup says it can run, so a machine with no Claude
/// Claude provider is not asked about Claude limits -- it has none, and a /// provider is not asked about Claude limits -- it has none, and a row saying
/// row saying so would be a fact about nothing. /// so would be a fact about nothing.
/// ///
/// Which meter a provider has is [`DriverKind::usage_provider`]'s answer /// Which meter a provider has is [`DriverKind::usage_provider`]'s answer rather
/// rather than a second match on kinds here, because the phone pairs a /// than a second match on kinds here, because the phone pairs a session with
/// session with one of these rows by that same name: two lists that /// one of these rows by that same name: two lists that disagreed would leave a
/// disagree would leave a session looking for a snapshot nothing /// session looking for a snapshot nothing produces. A second service later is a
/// produces, and nothing on screen could say why. A second service later /// name there and an impl beside [`ClaudeUsage`], not a screen.
/// is a name there and an impl beside [`ClaudeUsage`], not a screen.
fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsageProvider>> { fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsageProvider>> {
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new(); let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
for provider in &setup.providers { for provider in &setup.providers {
@@ -571,19 +555,17 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
found found
} }
/// The cache in front of whatever machines exist: at most one real fetch
/// per machine per service per [`MIN_POLL_INTERVAL`], no matter how often
/// the phone asks.
///
/// One machine's numbers for one service, and when they were fetched. /// One machine's numbers for one service, and when they were fetched.
/// ///
/// Keyed by the machine and the service rather than by position: the set /// Keyed by the machine and the service rather than by position: the set is not
/// is no longer fixed at startup -- setups are added, renamed and removed /// fixed at startup -- setups are added, renamed and removed from the phone --
/// from the phone -- and a positional cache would hand one machine's /// and a positional cache would hand one machine's numbers to another the
/// numbers to another the moment the list shifted. /// moment the list shifted.
type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>; type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>;
#[derive(Default)] #[derive(Default)]
/// The cache in front of whatever machines exist: at most one real fetch per
/// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks.
pub struct UsageMonitor { pub struct UsageMonitor {
cache: Mutex<Cached>, cache: Mutex<Cached>,
/// The invented meter an echo session can put up; empty unless one /// The invented meter an echo session can put up; empty unless one
@@ -600,12 +582,12 @@ impl UsageMonitor {
} }
} }
/// One snapshot per machine that offers a paid service, in the order /// One snapshot per machine that offers a paid service, in the order the
/// the machines are configured. /// machines are configured.
/// ///
/// Blocking -- call via `spawn_blocking`. Takes the setups rather than /// Blocking -- call via `spawn_blocking`. Takes the setups rather than
/// holding the manager, so this module stays below the session layer /// holding the manager, so this module stays below the session layer rather
/// rather than reaching up into it. /// than reaching up into it.
pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> { pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> {
let mut fresh = Vec::new(); let mut fresh = Vec::new();
for setup in setups { for setup in setups {
@@ -614,19 +596,17 @@ impl UsageMonitor {
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key) if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
&& fetched.elapsed() < provider.poll_interval() && fetched.elapsed() < provider.poll_interval()
{ {
// Cached numbers, but the machine's *name* is read // Cached numbers, but the machine's *name* is read fresh: a
// fresh: a rename should show immediately rather than // rename should show immediately rather than waiting out a
// waiting out the poll interval it has nothing to do // poll interval it has nothing to do with.
// with.
let mut snapshot = snapshot.clone(); let mut snapshot = snapshot.clone();
snapshot.setup_name = setup.name.clone(); snapshot.setup_name = setup.name.clone();
fresh.push(snapshot); fresh.push(snapshot);
continue; continue;
} }
// Fetched without the lock held: this makes a network call // Fetched without the lock held: this makes a network call per
// per machine, and holding the cache across them would // machine, and holding the cache across them would serialise
// serialise every phone asking for the screen behind the // every phone asking for the screen behind the slowest ssh.
// slowest ssh connection.
let snapshot = provider.fetch(); let snapshot = provider.fetch();
self.cache self.cache
.lock() .lock()
@@ -676,8 +656,8 @@ mod tests {
assert_eq!(windows[3].resets_at, None); assert_eq!(windows[3].resets_at, None);
} }
/// A setup naming a machine that cannot be dialled, so nothing here /// A setup naming a machine that cannot be dialled, so nothing here touches
/// touches the network beyond ssh failing to resolve it. /// the network beyond ssh failing to resolve it.
fn unreachable_setup() -> SetupConfig { fn unreachable_setup() -> SetupConfig {
SetupConfig { SetupConfig {
id: "far".to_string(), id: "far".to_string(),
@@ -707,9 +687,9 @@ mod tests {
transport: Transport::for_setup(&unreachable_setup()), transport: Transport::for_setup(&unreachable_setup()),
}; };
let snapshot = provider.fetch(); let snapshot = provider.fetch();
// The distinction the old single `error` string could not make: // The distinction the old single `error` string could not make: this
// this machine was never reached, which is not the same as a // machine was never reached, which is not the same as a machine that
// machine that answered and has nobody logged in. // answered and has nobody logged in.
assert!( assert!(
matches!(snapshot.state, UsageState::Unreachable { .. }), matches!(snapshot.state, UsageState::Unreachable { .. }),
"{:?}", "{:?}",
@@ -722,8 +702,8 @@ mod tests {
#[test] #[test]
fn a_missing_credential_file_is_a_choice_and_anything_else_is_a_fault() { fn a_missing_credential_file_is_a_choice_and_anything_else_is_a_fault() {
// What a real shell says when nobody has logged in on that // What a real shell says when nobody has logged in on that machine.
// machine. Nothing to fix, so it must not read as an error. // Nothing to fix, so it must not read as an error.
assert_eq!( assert_eq!(
why_no_credentials("cat: /home/x/.claude/.credentials.json: No such file or directory"), why_no_credentials("cat: /home/x/.claude/.credentials.json: No such file or directory"),
UsageState::NotLoggedIn UsageState::NotLoggedIn
@@ -733,16 +713,16 @@ mod tests {
UsageState::NotLoggedIn UsageState::NotLoggedIn
); );
// What ssh says when the machine is not there. Worth chasing, and // What ssh says when the machine is not there. Worth chasing, and the
// the detail is carried so somebody can. // detail is carried so somebody can.
let refused = why_no_credentials("ssh: connect to host vm port 22: Connection refused"); let refused = why_no_credentials("ssh: connect to host vm port 22: Connection refused");
assert!( assert!(
matches!(&refused, UsageState::Unreachable { detail } if detail.contains("refused")), matches!(&refused, UsageState::Unreachable { detail } if detail.contains("refused")),
"{refused:?}" "{refused:?}"
); );
// Anything unrecognised errs towards the state that gets looked // Anything unrecognised errs towards the state that gets looked at,
// at, rather than silently claiming nobody is logged in. // rather than silently claiming nobody is logged in.
assert!(matches!( assert!(matches!(
why_no_credentials("something nobody has seen before"), why_no_credentials("something nobody has seen before"),
UsageState::Unreachable { .. } UsageState::Unreachable { .. }