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:
commit
3c0214ece8
94 files changed
+8298
-9453
No files matched your search
+225
-378
@@ -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.
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+323
-460
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 { .. }
|
||||||
|
|||||||
Reference in new issue
Block a user