Compare commits
103
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
800da46188 | ||
|
|
00767eed4d | ||
|
|
683db4908a | ||
|
|
8d23a20792 | ||
|
|
d01c105037 | ||
|
|
88631f5e8b | ||
|
|
e6924298bc | ||
|
|
68b48cfd14 | ||
|
|
e6c884a0cd | ||
|
|
a6cb9a9082 | ||
|
|
0be6a571c4 | ||
|
|
bfe93c4188 | ||
|
|
5b7dc0e4e2 | ||
|
|
621f08d725 | ||
|
|
e49d0e606f | ||
|
|
e2a1fadbec | ||
|
|
0e4629361b | ||
|
|
1e7b1cddb7 | ||
|
|
470f8e5019 | ||
|
|
cf10b17c5b | ||
|
|
7ae53ad797 | ||
|
|
d17040b601 | ||
|
|
bf5087a598 | ||
|
|
9fa09b0af1 | ||
|
|
aa3d11471f | ||
|
|
78aff64844 | ||
|
|
9a33cb5384 | ||
|
|
45ced405f3 | ||
|
|
62199aa3a7 | ||
|
|
b133d85943 | ||
|
|
ba6817fee5 | ||
|
|
73ee63bc1b | ||
|
|
8f0aec449a | ||
|
|
6d5fd64bb0 | ||
|
|
e5880c33f4 | ||
|
|
a853eb5a4d | ||
|
|
eff5c8b0c0 | ||
|
|
7b63330aaa | ||
|
|
22d5c6585a | ||
|
|
b063fbd7f9 | ||
|
|
3f25e7ebca | ||
|
|
0af4c88d08 | ||
|
|
ceabd00805 | ||
|
|
32a5256a0d | ||
|
|
4cfe0ef6e6 | ||
|
|
c9b273ff16 | ||
|
|
8adda94a7a | ||
|
|
3a9208f38b | ||
|
|
e898370bf4 | ||
|
|
6e0bd06e4d | ||
|
|
03da47e550 | ||
|
|
a2cd119985 | ||
|
|
19c36e37f2 | ||
|
|
6bdec6e785 | ||
|
|
e2873df92e | ||
|
|
ea13889a21 | ||
|
|
6317685d1a | ||
|
|
f79bd7ca71 | ||
|
|
9c935f8ce8 | ||
|
|
982449293d | ||
|
|
288853c094 | ||
|
|
fba572427d | ||
|
|
85ec5416b6 | ||
|
|
643daf5637 | ||
|
|
8db0184384 | ||
|
|
1a6599e1b2 | ||
|
|
0a2f4fa1fe | ||
|
|
237886c11e | ||
|
|
e8dbcaa7db | ||
|
|
26163b25b2 | ||
|
|
762c1290a1 | ||
|
|
62dd6b7912 | ||
|
|
bc3db183e3 | ||
|
|
e0a473e090 | ||
|
|
1c937e2f48 | ||
|
|
d194d73439 | ||
|
|
4400966928 | ||
|
|
4821a02bd3 | ||
|
|
6e49ce8c92 | ||
|
|
1ff662c7c3 | ||
|
|
79b9cd789a | ||
|
|
c70a670356 | ||
|
|
43743ba171 | ||
|
|
1a97d0ef5c | ||
|
|
ff7e9c0435 | ||
|
|
68a7f41ed0 | ||
|
|
9b331a5e93 | ||
|
|
3fc224b584 | ||
|
|
10500ae8aa | ||
|
|
8d441d3d59 | ||
|
|
e4f0935f98 | ||
|
|
3c0214ece8 | ||
|
|
e0ee7d6e94 | ||
|
|
b6b0928087 | ||
|
|
12221ea025 | ||
|
|
5e23c8b0c0 | ||
|
|
caaa733caa | ||
|
|
127b25e60a | ||
|
|
4ab26f068e | ||
|
|
0f8ba49f4a | ||
|
|
1fcaa2d72d | ||
|
|
edc39c7371 | ||
|
|
79682f03a7 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
|
||||
# every crate in this repo into one workspace -- they are deliberately
|
||||
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
|
||||
# from the repo root runs xtask/src/main.rs directly.
|
||||
[alias]
|
||||
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
|
||||
@@ -0,0 +1,244 @@
|
||||
---
|
||||
name: ai-app-rigs
|
||||
description: ai-app's test rigs, harness scripts and reference measurements - ui-sandbox.sh, debug-transcript.sh, transcript-bench.sh, stream-bench.sh, trace-draw.sh, the /usage fixture vocabulary, the fake CLI, the rule that no UI-driving script may tap a coordinate, how to test llama.cpp and ssh on this machine, how importing behaves, and the scroll/stream/explorer numbers not worth re-measuring. Read before running or writing a benchmark, driving the app's UI from a script, exercising the session lifecycle, testing a llama or remote session, or touching the import screen.
|
||||
---
|
||||
|
||||
# ai-app: rigs, harnesses and measurements
|
||||
|
||||
Moved out of `AGENTS.md` on 2026-09-04 so it is read when it is relevant
|
||||
rather than sent with every request in this repo -- it was 12 KB of the 35 KB
|
||||
that file cost on every one. Unchanged in the move, and still the only copy.
|
||||
|
||||
## The rigs
|
||||
|
||||
Each exists because something was invisible without it.
|
||||
|
||||
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
|
||||
and data directory, holding eight invented Claude Code transcripts and a
|
||||
`claude` that is two lines of shell. **That isolation is the point**: the
|
||||
import screen lists whatever is in `~/.claude/projects`, which in this VM is
|
||||
real agent transcripts, so exercising *delete* against the ordinary server
|
||||
deletes somebody's conversation and exercising *import* starts a real
|
||||
`--resume` on the owner's account.
|
||||
Its port and root derive from the checkout's name, so two checkouts'
|
||||
sandboxes cannot reach each other, and its token is generated once into
|
||||
`~/.config/ai-app/sandbox-token` and carried across restarts along with any
|
||||
the enrolment flow appended — so the emulator app is enrolled **once** (the
|
||||
start banner prints the command) and stays enrolled. It shares the real TLS
|
||||
certificates, because the installed APK pins that CA.
|
||||
Driving verbs, so none of this is re-derived per session:
|
||||
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
|
||||
`./ui-sandbox.sh send SID text|@file`, and
|
||||
`./ui-sandbox.sh api /path [curl args]`.
|
||||
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
|
||||
enrolment already there — for when the fixture under test was expensive to
|
||||
build; plain `start` wipes them, which is right for the list-screen
|
||||
fixtures and wrong for that.
|
||||
It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large
|
||||
transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the
|
||||
fake CLI slow to start. Both exist because operations that finish in
|
||||
milliseconds have states on the way that nothing can observe, and an
|
||||
unobservable state is one where broken and working look identical.
|
||||
It also builds a fixture tree at the sandbox home's `~/files` for the
|
||||
explorer, holding the states otherwise only reachable by finding a real
|
||||
machine in one: an empty directory, a name with a tab and one with an
|
||||
apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a
|
||||
symlink to a directory and a broken one, a source file per language, and
|
||||
the three sizes the limits were measured against (`edit-32k.rs`,
|
||||
`edit-128k.rs`, `big-source.rs`). Point a session at it with
|
||||
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
|
||||
The explorer's 409 is produced by editing the file on the machine
|
||||
(`printf … > file`) between pressing the pencil and pressing save.
|
||||
- **`app/debug-transcript.sh`** — a real conversation on the emulator. The
|
||||
echo driver is the right rig for most things and the wrong one for anything
|
||||
whose cost scales with what was actually written: a real reply is longer,
|
||||
is real markdown, and carries tool calls whose input and output are
|
||||
kilobytes. Two faults were invisible until a real transcript was loaded — a
|
||||
page of history landing mid-fling threw the reader back to the newest end,
|
||||
and parsing one real reply took 51ms against 4.6ms for a synthetic one.
|
||||
`-b` takes the biggest conversation on the machine rather than the newest,
|
||||
which is what a scrolling test wants; `--stop` takes it down.
|
||||
It copies the transcript into `/tmp` and gives the server a `HOME` of its
|
||||
own, so the import can only see the copy — importing spawns `claude
|
||||
--resume`, and against the real file that is a second CLI writing to a
|
||||
conversation somebody may still be in. **A transcript never goes in this
|
||||
repository**: they hold whatever was said, read and written in that
|
||||
session, and `~/repos` is shared with the host besides.
|
||||
- **`/usage` in an echo session puts up an invented meter**, which is how the
|
||||
rate-limit screens' states are reached without spending quota: `/usage 42`,
|
||||
`/usage 95 20` (minutes left), `/usage 42 never` (the between-blocks window
|
||||
with no reset time), `/usage 42 unreadable`, `/usage notloggedin`,
|
||||
`/usage unreachable`, `/usage failed`, `/usage off`. The vocabulary is
|
||||
`usage::Fixture`'s, since those are its states. With none set an echo
|
||||
session meters nothing, which is the ordinary case and draws no bar.
|
||||
- **A fake CLI exercises the process lifecycle without a token.** Point a
|
||||
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
|
||||
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
|
||||
it holds the fifo open, records a real pid, writes nothing, and dies on a
|
||||
signal. So adopt, stop, restart and start are all drivable without a real
|
||||
`--resume` and without spending a turn on somebody's account. Reach for
|
||||
this when what is under test is *whether a process is running*, and for
|
||||
`debug-transcript.sh` when it is *what the transcript draws*.
|
||||
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
|
||||
the first session (or `-k` keeps the current screen), scrolls a fixed
|
||||
gesture loop, and prints the app's render report — the same one the in-app
|
||||
copy button produces, whose `on screen:` line names what the viewport was
|
||||
holding. Compare two runs with the same gestures; the emulator's absolute
|
||||
frame times transfer nothing, the report's accounting does. Run it either
|
||||
side of any change under `Markdown*.kt`, `Transcript*.kt` or
|
||||
`SessionScreen.kt`'s list, and put the report in the commit. The numbers
|
||||
that move first are the worst `record: one block`, the reparse mean while
|
||||
streaming, and the draw phase's accounting line.
|
||||
- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still
|
||||
arriving. It taps "Jump to latest" so the list is pinned to the newest end,
|
||||
resets the report, sends FILE, waits for the transcript to stop growing,
|
||||
and prints. Both of those are corrections to a first version that measured
|
||||
nothing: a transcript parked further back never redraws while a reply
|
||||
streams into it, and a session is idle at *both* ends of a turn, so polling
|
||||
for idle answers before the turn has started.
|
||||
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
|
||||
framework, from `atrace` text output with no trace processor needed. It is
|
||||
how the cost of a layout node per link was attributed to the framework
|
||||
rather than guessed at.
|
||||
|
||||
### Driving the UI
|
||||
|
||||
**No script that drives this app's UI presses a coordinate.** Every control
|
||||
is found by the name it already carries for assistive technology —
|
||||
`ui-trace record --do "tap 'Session settings'"` — which resolves the label
|
||||
against the screen at the moment of the gesture and fails the whole run when
|
||||
it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A
|
||||
coordinate is a position measured once by hand, and anything that moves the
|
||||
control makes the tap land on whatever now sits there — the bench then
|
||||
reports a number that was never measured, which reads exactly like a result.
|
||||
Both bench scripts pressed the render report at `tap 723 205` until that
|
||||
button moved into the session settings dialog on 2026-09-03. The check that
|
||||
none has crept back:
|
||||
|
||||
grep -n "tap [0-9]" app/*.sh
|
||||
|
||||
Swipes are still coordinates, deliberately: a gesture across a scrolling area
|
||||
is a distance rather than a control.
|
||||
|
||||
**Two traps in the emulator bench loop**, each of which cost a run.
|
||||
`adb shell pm clear` removes the enrolment and the notification permission
|
||||
along with the saved anchors, so the next run measures a permission dialog —
|
||||
re-enrol with the command `ui-sandbox.sh` prints, and
|
||||
`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id,
|
||||
so the only way two builds start a scroll from the same place is a *fresh
|
||||
session for each*.
|
||||
|
||||
**The emulator is `~/repos/emulator-tools`' business, not this repo's.**
|
||||
`emu up` creates and boots the AVD named after this checkout — whatever `emu
|
||||
name` prints, never a name typed out here, since this file is the same in
|
||||
every clone. `run-android.sh` is that plus a build and an install. The `adb`
|
||||
on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills
|
||||
in `-s` from the same rule. Gradle does not go through it, so a Gradle init
|
||||
script from `emulator-tools` runs `emu check` before `installDebug`,
|
||||
`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out
|
||||
to every attached device; when it refuses, say which device you mean at the
|
||||
moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
|
||||
|
||||
### Testing llama.cpp and ssh here
|
||||
|
||||
**Both are set up here as of 2026-09-04** and need nothing typed. The
|
||||
prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp`
|
||||
(the 15 MB `ubuntu-x64` release asset) and is symlinked as
|
||||
`/usr/local/bin/llama-server`, which is what makes **discovery find it over
|
||||
ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets.
|
||||
It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is
|
||||
needed. One model is downloaded — `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`,
|
||||
639 MB under `~/.local/share/ai-app/models` — and answers at usable speed on
|
||||
this VM's 8 cores. **Do not test with a 2-bit quant**: the
|
||||
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
|
||||
broken driver — `llama-cli` produces the same from the file directly, which
|
||||
is how to tell the two apart in a hurry.
|
||||
|
||||
There is no second machine, so **ssh this VM to itself**. That is set up
|
||||
too: the key is `~/.config/ai-app/ssh-self` (its public half is in
|
||||
`~/.ssh/authorized_keys`, labelled removable), and the real config carries a
|
||||
setup called **"this vm over ssh"** — `bob@127.0.0.1` with that
|
||||
`identityFile` plus
|
||||
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
|
||||
so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the
|
||||
whole rig for "does a remote llama session work", since the far machine is
|
||||
this one and the model file is the same file. For a throwaway setup of your
|
||||
own, point a provider's `command` at something harmless like `/bin/echo`
|
||||
rather than at `claude`: the transport is what is under test, the process
|
||||
exiting immediately is the signal, and it costs no tokens. The remote login
|
||||
shell here is **fish**; the
|
||||
remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in
|
||||
both, but that is luck rather than design, and a shell that is neither is the
|
||||
thing to suspect first if a remote spawn ever mangles an argument.
|
||||
|
||||
## Importing
|
||||
|
||||
The import list reports each session's **size as well as its line count**,
|
||||
because the two disagree in the way that matters: these transcripts embed
|
||||
screenshots as base64, so one line can be a megabyte. On this machine a 69 MB
|
||||
session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line
|
||||
count tells you what continuing a session will cost. Shown, not warned about;
|
||||
importing a large session is a choice somebody is entitled to make.
|
||||
|
||||
**Never import a Claude Code session that is open in a terminal.** The app
|
||||
refuses it — see PLAN.md for the incident that made that a refusal rather
|
||||
than a warning.
|
||||
|
||||
**One Claude Code session id can name two files, and the listing offers it
|
||||
once.** Resuming from a different working directory makes the CLI write a
|
||||
second transcript with the same id under that directory's project folder — an
|
||||
ordinary state of a machine, not corruption. Everything downstream addresses
|
||||
a session by id, and the phone keyed its list on it, so two rows sharing one
|
||||
**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps
|
||||
the copy with the most lines, because the other is usually a few-hundred-byte
|
||||
stub and is often the *newer* of the two, so recency is the wrong key.
|
||||
Deleting removes every copy rather than the first, or the row came back after
|
||||
a delete that reported success. The phone's half is `uniqueItems`, which
|
||||
every list keyed on a server-chosen id goes through: a repeat there must
|
||||
never be able to close the app, whatever produced it.
|
||||
|
||||
**Deleting a session offers to take the machine's own transcript with it** —
|
||||
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
|
||||
confirmation, and only where the driver keeps a record of its own
|
||||
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
|
||||
because leaving that copy is what makes an ordinary delete recoverable — and
|
||||
the dialog's paragraph is rewritten when it is on rather than appended to,
|
||||
since the sentence promising the conversation "should still be there to
|
||||
import again" is exactly the one the switch makes false. The server deletes
|
||||
the machine's copy *first*, so a machine it cannot reach leaves the session
|
||||
where it was instead of half-deleted.
|
||||
|
||||
## Measurements worth not re-taking
|
||||
|
||||
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
|
||||
emulator against a real imported transcript with the server at
|
||||
`--delay 120`. Settled and flinging fast, both into fresh history and back
|
||||
through rows already drawn: **5.2–5.9% janky frames, 99th percentile
|
||||
29–32ms, 0–2 slow UI-thread frames.** The stock Settings app on the same
|
||||
device is 3.3% and 38ms, so this is at the platform floor. The number that
|
||||
is *not* at the floor is the first few seconds after opening a session,
|
||||
where every row on the way is being composed for the first time; that is
|
||||
inherent to a lazy list and it is why a measurement taken before the screen
|
||||
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
|
||||
- **The reset path is not reachable by reopening a session.** Measured
|
||||
2026-09-04 against a session streaming at 20 events a second: reopening one
|
||||
with an anchor 1,800 events back connects **87–119 events behind**, well
|
||||
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
|
||||
opening page, then one span covering the whole distance. To exercise the
|
||||
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
|
||||
the app takes the reset on a live connection, clears, refills and carries
|
||||
on without reconnecting.
|
||||
- **The session screen's stream survives backgrounding here** — 20 seconds at
|
||||
the launcher while 415 events were produced brought no reconnect at all,
|
||||
which is not what the comment above that loop expects, and is most likely
|
||||
this emulator being headless rather than the phone's behaviour.
|
||||
- **Reopening a cached session costs one request for one event** (the probe),
|
||||
and scrolling the whole conversation back costs nothing more; a cold open
|
||||
of the same 500-event session is two pages, 100 events. Measured
|
||||
2026-09-04 on the emulator against the sandbox.
|
||||
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
|
||||
28,000-line file because it draws one row per line; the editor is one
|
||||
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
|
||||
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
|
||||
screen. If you make the editor faster, that number is what to move.
|
||||
EXPLORER.md's "What the measurements said" has the rest.
|
||||
@@ -55,4 +55,21 @@ components: [
|
||||
// the terminal the QR would be printed on.
|
||||
enroll: "server/enroll-link.sh",
|
||||
),
|
||||
// E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle
|
||||
// (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed
|
||||
// with the same release key as "app" above so the two can install
|
||||
// over each other -- a separate component, not a mode of "app" above,
|
||||
// because it is a different applicationId (com.example.aiapp.shell)
|
||||
// built by a different tool from different sources. No `cwd`: it
|
||||
// defaults to this checkout's root, which both the `cargo xtask`
|
||||
// alias (`.cargo/config.toml`, resolved relative to the working
|
||||
// directory cargo is run from) and `cargo xtask apk`'s own publishing
|
||||
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
|
||||
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
|
||||
// both need.
|
||||
Apk(
|
||||
name: "shell",
|
||||
modes: ["release", "debug"],
|
||||
build: "cargo xtask apk",
|
||||
),
|
||||
],
|
||||
+19
@@ -1,12 +1,20 @@
|
||||
.gradle/
|
||||
build/
|
||||
app/androidApp/build/
|
||||
app/shellApp/build/
|
||||
local.properties
|
||||
.kotlin/
|
||||
*.iml
|
||||
.idea/
|
||||
.DS_Store
|
||||
server/target/
|
||||
event-model/target/
|
||||
client-core/target/
|
||||
android-shell/target/
|
||||
|
||||
# E3's native library, built by cargo-ndk straight into the Gradle module
|
||||
# (RUST.md) -- an artifact, like server/target/ above, not source.
|
||||
app/shellApp/src/main/jniLibs/
|
||||
|
||||
# Server logs from a development run (ai-server.log by convention,
|
||||
# wg-test.log from ./test-wg-tunnel.sh).
|
||||
@@ -21,3 +29,14 @@ certs/
|
||||
config.ron
|
||||
config.json
|
||||
sessions/
|
||||
|
||||
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
||||
iris/target/
|
||||
iris/android-app/target/
|
||||
|
||||
# E5's packaging xtask (RUST.md). `build/` above already covers
|
||||
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
|
||||
# The repo root has no Cargo workspace, so this is xtask's own
|
||||
# intermediate working files (target/xtask/apk/...), not a shared one.
|
||||
xtask/target/
|
||||
/target/
|
||||
@@ -0,0 +1,44 @@
|
||||
# Decisions awaiting review
|
||||
|
||||
Choices made while working autonomously, for Bryan to keep or change. Each
|
||||
says what was picked and why; the detail is in the design doc it names.
|
||||
Delete an entry once it has been looked at.
|
||||
|
||||
## Subagent views (2026-09-05, `SUBAGENTS.md`)
|
||||
|
||||
Made on my own judgement, limited blast radius:
|
||||
|
||||
1. **A subagent is a transcript, not a session.** It has no process,
|
||||
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}`
|
||||
and stored under the session's directory, so deleting the session takes
|
||||
it. Alternative rejected: registering it as a session of its own, which
|
||||
would give it a card in the main list and a driver that can do nothing.
|
||||
2. **Read-only view is the session screen minus its controls**, rather than
|
||||
a second, simpler transcript screen. Keeps paging, caching, selection
|
||||
and rendering in one place. Cost: a `readOnly` mode threaded through
|
||||
`SessionScreen`.
|
||||
3. **The list only carries a count.** Each session row says how many
|
||||
subagents it has; their titles and statuses are fetched when the card is
|
||||
expanded. Keeps `GET /sessions` from reading every subagent transcript.
|
||||
Consequence: an expanded card's statuses refresh with the list, not live.
|
||||
4. **Expanded/collapsed is remembered per session on the phone**, not on
|
||||
the server. Collapsed by default, per the transcript convention that new
|
||||
things arrive collapsed.
|
||||
5. **Subagents of imported sessions are not shown.** The import path still
|
||||
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files
|
||||
are not read. Only subagents run while this backend was watching exist.
|
||||
6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a
|
||||
paid turn to exercise.
|
||||
|
||||
Deferred, because they reach further than this feature:
|
||||
|
||||
- **Live status on the list.** Whether the session list should follow a
|
||||
stream at all (it refreshes on demand today) decides whether subagent
|
||||
status can ever be live there. Not changed.
|
||||
- **Nested subagents.** A subagent's own Task calls are shown as tool calls
|
||||
in its transcript and are not given transcripts of their own. Supporting
|
||||
that is the same mechanism one level down, but the UI would need nested
|
||||
expanders.
|
||||
|
||||
- **The subagent status row says "context unknown".** Nothing measures a
|
||||
subagent's context; the row could leave it out rather than admit it.
|
||||
-504
@@ -1,504 +0,0 @@
|
||||
# The file explorer
|
||||
|
||||
Asked for by Bryan on 2026-09-03: replace the session screen's debug
|
||||
button with a folder icon that opens a file and directory viewer for the
|
||||
machine the session runs on. Browse directories, open files with the
|
||||
existing syntax highlighting, line numbers, no wrapping; edit a file behind
|
||||
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
|
||||
reason and what was rejected, so that when one changes it is changed here
|
||||
rather than re-argued. The operational half -- how to run it, what to press,
|
||||
what to produce on purpose -- is in AGENTS.md, where the rest of this
|
||||
project's working notes are.
|
||||
|
||||
## What it is, in one paragraph
|
||||
|
||||
A machine's filesystem, seen from the phone through the backend. The
|
||||
explorer belongs to a **setup** (a machine), not to a session: a session
|
||||
only says where to start. Every operation -- list, read, write, create --
|
||||
is one shell script run through `Transport`, exactly the way the import
|
||||
listing and the usage fetch already work, so the local and the ssh case
|
||||
are one implementation and a machine the backend cannot reach fails with
|
||||
ssh's own message. The phone draws what came back: a listing, a file with
|
||||
its lines coloured by the scanner in `Highlighter.kt`, or an editor over
|
||||
the same text.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Keyed on the machine, opened from the session
|
||||
|
||||
Routes live under `/setups/{id}/…`, beside `importable`, because a
|
||||
filesystem is a property of a machine. The session screen's folder button
|
||||
opens the explorer with the session's setup and its `cwd` as the starting
|
||||
directory; a session with no `cwd` opens at the machine's home, which the
|
||||
machine resolves (`cd` with no argument and `pwd -P`), never a path the
|
||||
phone guessed. Nothing in the explorer knows what a session is, so a later
|
||||
entry point from the setups tab is one more caller and no new code.
|
||||
|
||||
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
|
||||
need a session to exist first.
|
||||
|
||||
### 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
|
||||
"$path" …` through `Transport::capture` (or the stdin-carrying variant
|
||||
below). The path and every other value cross as **positional arguments**,
|
||||
never interpolated into the script -- the same rule `import::find` follows
|
||||
with `"$1"`, and the same reason `ssh::quote` exists: a path is
|
||||
attacker-adjacent input in a server whose job is running commands. A `~`
|
||||
prefix is handled by the same `quote_path`/`expand_home` pair every other
|
||||
path goes through; nothing new is invented for it.
|
||||
|
||||
The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`,
|
||||
`sha256sum`, `chmod --reference`). That is already what `import.rs`
|
||||
assumes (`stat -c`, `/proc`), and both machines that exist are Linux. A
|
||||
machine without them fails with that tool's own message, which names what
|
||||
is missing.
|
||||
|
||||
Rejected: `std::fs` for the local transport and scripts for ssh. Two
|
||||
implementations of "list a directory" drift -- the ordering of entries,
|
||||
what a symlink reports, how a permission error reads -- and the local one
|
||||
is the one that gets tested, so the remote one ships broken. The transport
|
||||
design exists so that a driver never learns which machine it got; the
|
||||
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`
|
||||
inherits `~/.ssh/config`, agents and jump hosts, and there is one place to
|
||||
configure a connection. SFTP would need a second one.
|
||||
|
||||
### 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
|
||||
path: the server resolves which file that is, so an enrolled token cannot
|
||||
become 'read me an arbitrary file'." The explorer's whole purpose is the
|
||||
path, so it takes one. This is recorded in PLAN.md's Security section as a
|
||||
change to the threat model paragraph, in these terms: the token already
|
||||
gates spawning a bypass-permissions agent in any directory on any machine
|
||||
a setup names, and that agent can already read and write every file its
|
||||
user can. The explorer is a shorter path to authority the token already
|
||||
holds, not new authority. The import route's rule stands where it is,
|
||||
because there a path was unnecessary and refusing it cost nothing.
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
the same wording, because where it would be depends on where nothing the
|
||||
reader can see. Every listing answers with `pwd -P` of the directory it
|
||||
listed, so the phone navigates on a resolved absolute path -- the parent
|
||||
of `/home/bob/repos/ai-app` is a string operation on that, and a `~` the
|
||||
session was spawned with is shown as what it turned out to be. The phone
|
||||
never resolves `..` itself.
|
||||
|
||||
### 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:
|
||||
|
||||
- `text` -- the content, with its size, mtime and sha256.
|
||||
- `binary` -- the content is not UTF-8. Size reported, nothing shown.
|
||||
- `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
|
||||
text and a big file cut off silently are both wrong in ways the reader
|
||||
cannot see, and "couldn't read it" must not look like "it is empty". An
|
||||
empty file is `text` with empty content and is drawn as one empty line
|
||||
numbered 1, which 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
|
||||
|
||||
`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
|
||||
code if it differs; the server answers **409** with "changed on the machine
|
||||
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 with a stale copy is the worst available outcome. The phone offers
|
||||
three ways out and says what each costs: **Overwrite** (theirs is lost),
|
||||
**Reload** (yours is lost), **Cancel** (keep editing, decide later).
|
||||
|
||||
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1"
|
||||
"$1.ai-app-tmp" && mv -f -- "$1.ai-app-tmp" "$1"`, with the bytes on
|
||||
stdin. A temp file and a rename, so a connection dropped mid-write leaves
|
||||
the old file whole rather than a truncated one; `chmod --reference` keeps
|
||||
the mode, which a fresh file would otherwise lose (an executable script
|
||||
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 check-then-write is not atomic against a writer landing between the
|
||||
two -- a window of microseconds on the same machine -- and that is accepted
|
||||
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.
|
||||
|
||||
### 7. Create refuses to overwrite
|
||||
|
||||
`POST /setups/{id}/file {path}` runs under `set -C` (noclobber) and
|
||||
`: > "$1"`, so a name that exists fails with the shell's own message rather
|
||||
than truncating somebody's file. `POST /setups/{id}/dir {path}` is `mkdir
|
||||
--` with the same property. The modal names one thing in the current
|
||||
directory and has a switch for "directory"; a created file opens straight
|
||||
into edit mode, because an empty file is not something to look at.
|
||||
|
||||
Rejected: create-with-content in one request. The editor is the place
|
||||
content is typed, and a modal with a text area is a second editor.
|
||||
|
||||
### 8. The viewer is a list of lines, coloured once
|
||||
|
||||
The file is scanned once, off the main thread, by `scan` in
|
||||
`Highlighter.kt` with `rulesOf(language)`; the spans are bucketed per line
|
||||
in one pass, and each line's `AnnotatedString` is built when that line is
|
||||
composed. A `LazyColumn` of lines, not one `Text`: text layout is linear
|
||||
in the text, and a 20,000-line file in one `Text` measures all of it to
|
||||
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
|
||||
wrong.** `horizontalScroll` is a node per row, and each one coerces the
|
||||
shared offset into *its own* range -- content width less viewport -- so
|
||||
with rows at their natural widths a short line's range is zero and it 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
|
||||
by whichever row measured last, and changed as the list scrolled. Both go
|
||||
away once **every row is given the same width**: the longest line in
|
||||
columns times one character's advance, which is arithmetic rather than
|
||||
twenty thousand measurements because the face is monospace. A tab counts as
|
||||
eight columns and deliberately upwards -- over-estimating leaves a little
|
||||
empty space past the longest line, under-estimating puts the end of that
|
||||
line out of reach -- and the width is capped well under what `Constraints`
|
||||
can carry, so a minified file is a scroll that stops early rather than a
|
||||
crash. Reported by Iris on 2026-09-04 as "it seems to affect different rows
|
||||
differently", which is precisely what a per-row range looks like.
|
||||
|
||||
**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
|
||||
own per node otherwise, so only the line under the finger bent and the
|
||||
rest of the file sat still beside it. That is the same complaint one layer
|
||||
further out, and it is only fixable now that every row agrees where the
|
||||
end is. It cannot be seen from this VM: the emulator's screenshots come
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
column translated by the scroll position could not, since the translation
|
||||
would be current while the set of numbers was a composition behind, and
|
||||
during a fling the numbers would slide against their lines. Checked at
|
||||
about 1kHz through a fling: 23,520 row observations over 552 frames, every
|
||||
one of them with its number at exactly its own top.
|
||||
|
||||
A consequence worth having: the numbers are no longer inside 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.
|
||||
|
||||
Line numbers are a gutter in each row, right-aligned, with the gutter
|
||||
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
|
||||
app already sits on.
|
||||
|
||||
The language comes from the file's extension through the same table
|
||||
`fenceLanguage` reads (`FENCE_LANGUAGES` already keys on `kt`, `rs`,
|
||||
`py`, …). One function, `fileLanguage(name)`, takes the part after the
|
||||
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
|
||||
|
||||
Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the
|
||||
same monospace style, inside the same horizontal scroll so it does not
|
||||
wrap, with a `VisualTransformation` that returns the text unchanged and
|
||||
the scanner's spans as styles (`OffsetMapping.Identity`, since no
|
||||
character moves). This is the one Compose API that colours a field's text
|
||||
without replacing the field; the newer `TextFieldState` API has no hook
|
||||
for styles. The gutter is one `Text` of `1\n2\n…` in the same style beside
|
||||
the field, aligned for the same reason as the viewer: no wrap, one line
|
||||
each.
|
||||
|
||||
Save is a glyph in the header, **disabled** until the text differs from
|
||||
what was loaded (never hidden -- a control that comes and goes makes its
|
||||
own absence the signal), and a `GlyphSpinner` while the write is out.
|
||||
Back with unsaved changes asks; the question says the edits will be lost.
|
||||
The keyboard: the explorer draws over the session, which deliberately has
|
||||
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
|
||||
|
||||
`Screen.Session` in `AppRoot` gains a `files: FilesTarget?`. When set, the
|
||||
`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
|
||||
scroll position and draft stay where they were, and returning from a file
|
||||
costs nothing. Back -- the button and the platform gesture --
|
||||
clears `files` when it is set and goes to the list otherwise. Inside the
|
||||
explorer the same back steps one level: editor → viewer (with the unsaved
|
||||
question), viewer → listing, listing → parent directory it came from, and
|
||||
only from the starting directory does it close. "Back returns; it does not
|
||||
exit."
|
||||
|
||||
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from
|
||||
a leaf screen goes to Main today, and a session disposed and re-created on
|
||||
each return refetches its transcript over the tunnel -- exactly the flip
|
||||
between "what did it change" and "what is it saying" this feature is for.
|
||||
The image viewer already made the same choice for the same reason.
|
||||
|
||||
### 11. The listing is drawn as it came, sorted at display time
|
||||
|
||||
Entries carry name, kind (`directory`, `file`, `other`), size, mtime, and
|
||||
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
|
||||
phone, stably: directories first, then case-insensitive name. Dotfiles are
|
||||
shown -- in a repository they are half of what matters. A row is the
|
||||
glyph, the name, and the size for a file; tapping a directory descends,
|
||||
tapping a file opens it. Each directory's entries are kept for as long as
|
||||
the explorer is open, keyed by path, so returning to one does not refetch
|
||||
it; the header's refresh glyph refetches the current one on purpose, and a
|
||||
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,
|
||||
in 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
|
||||
line (`find -printf '%y\t%Y\t%s\t%T@\t%f\0'`), so a filename with a
|
||||
newline or a tab in it survives; `parse_entries` is a unit test with
|
||||
exactly those names in it.
|
||||
|
||||
### 12. Icons
|
||||
|
||||
Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script
|
||||
rerun and its output committed (it needs network):
|
||||
|
||||
- `md-folder` U+F024B -- the header button, and directory rows. The same
|
||||
codepoint dev-updater uses, and it must not drift from it, as the cog
|
||||
and the refresh arrow already must not.
|
||||
- `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
|
||||
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 header reads widest scope to narrowest and the cog stays at the end
|
||||
where every other screen in this app keeps it. Asked for in that order by
|
||||
Iris on 2026-09-03.
|
||||
|
||||
### 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
|
||||
|
||||
Added to the table in `routes.rs`'s module doc:
|
||||
|
||||
```text
|
||||
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
|
||||
GET dir -> {"path":"/home/bob/repos/ai-app",
|
||||
"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":"…"}
|
||||
| {"path":"/…/a.png","kind":"binary","size":45678,"modified":…}
|
||||
| {"path":"/…/big.log","kind":"tooBig","size":12345678,"modified":…}
|
||||
PUT file -> {"size":1240,"modified":…,"sha256":"…"}
|
||||
```
|
||||
|
||||
Errors: `BadRequest` with the machine's message for a path that is not
|
||||
there, not allowed or not absolute; the existing 409 variant for the
|
||||
precondition; `Internal` only for the server's own faults. The message is
|
||||
what the phone shows, in place, so it 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)
|
||||
|
||||
Taken on the emulator in a **debug** build, which runs Compose at a
|
||||
fraction of release speed and renders in software -- so these rank
|
||||
correctly against each other and are pessimistic in absolute terms.
|
||||
Generated Rust, through the app's own render report.
|
||||
|
||||
| file | lines | scan + cut | scan per keystroke | worst frame record |
|
||||
|--------|--------|------------|--------------------|--------------------|
|
||||
| 32 kB | 917 | 11ms | 10ms | 183ms |
|
||||
| 128 kB | 3,633 | -- | 40ms | 2,027ms |
|
||||
| 1 MB | 28,660 | 460ms | -- | -- |
|
||||
|
||||
Three things followed.
|
||||
|
||||
**The viewer's scan had to leave the main thread.** Decision 8 said "off
|
||||
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
|
||||
server is willing to send, long enough that the accessibility tree cannot
|
||||
be read -- which is exactly what "the app has stopped" looks like from
|
||||
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
|
||||
1 MiB file, tap to text on screen, was **2.4s** against the sandbox --
|
||||
1.2s of which is that server's deliberate `--delay`, and 460ms the scan.
|
||||
The transfer is not what dominates, so the route gains nothing from
|
||||
streaming.
|
||||
|
||||
**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
|
||||
not the cost that matters: highlighting 128 kB costs 40ms a keystroke,
|
||||
which is survivable, while laying the same text out in one
|
||||
`BasicTextField` costs two seconds -- characters typed into it were
|
||||
dropped, and a 1 MiB file stopped the app responding altogether. Since
|
||||
every arrangement of a single text field pays that, switching highlighting
|
||||
off would have saved nothing. So `EDIT_LIMIT` is **32 kB**, the largest
|
||||
size measured as usable, and above it the pencil is disabled with the
|
||||
reason said in words beside it -- a disabled control teaches what the thing
|
||||
can do but cannot say why it is off, and a reader who cannot edit a file
|
||||
they can plainly read would otherwise conclude the app is broken.
|
||||
|
||||
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
|
||||
difference is the whole of decision 8.
|
||||
|
||||
## Later, deliberately not now
|
||||
|
||||
- Delete, rename and move. Destructive controls belong here eventually,
|
||||
shown and confirmed rather than hidden, but none of them is needed to
|
||||
read or change a file.
|
||||
- Images in the viewer, through the existing `SessionImageViewer`.
|
||||
- Following an agent's edits live: a file open in the viewer refreshing
|
||||
when a `Write`/`Edit` tool call on the same path lands in the
|
||||
transcript. The transcript already knows the path.
|
||||
- Remembering the last directory per session.
|
||||
- Uploading from the phone into a directory. Attachments already do the
|
||||
upload half; this would be the same route with a chosen destination.
|
||||
- Search within a file, and find-in-files.
|
||||
- **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The
|
||||
viewer already draws a file as rows and stays fast on a megabyte; an
|
||||
editor built the same way -- a field per line, or a field over the lines
|
||||
on screen -- would not pay Compose's cost of laying out one enormous
|
||||
text. It is a good deal more than this feature needed, and 32 kB covers
|
||||
the config files, notes and ordinary source files anybody edits from a
|
||||
phone.
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
# Subagents
|
||||
|
||||
A session's subagents -- the helpers a Claude Code session starts through its
|
||||
Task tool -- each get a transcript of their own, listed under the session's
|
||||
card and readable in the same transcript view the session has. Designed
|
||||
2026-09-05; the decisions Bryan has not yet reviewed are in `DECISIONS.md`.
|
||||
|
||||
## What a subagent is here
|
||||
|
||||
**A subagent is a second transcript owned by a session, in the same event
|
||||
model, with no process and no controls.** It is not a session: it cannot be
|
||||
messaged, stopped or started, and it has no setup, model or usage of its
|
||||
own. Everything it shares with a session -- the transcript file format, the
|
||||
paging routes, the SSE stream, the phone's cache and rendering -- is reused
|
||||
by addressing, not by copying.
|
||||
|
||||
The CLI reports a subagent's messages on the parent's own stream-json
|
||||
output, each carrying `parent_tool_use_id` = the id of the Task `tool_use`
|
||||
that started it. Before this the translator dropped those lines
|
||||
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
|
||||
them to that subagent's own translator and transcript. The parent's
|
||||
transcript still shows only the Task call itself.
|
||||
|
||||
## Storage
|
||||
|
||||
Under the session directory:
|
||||
|
||||
```
|
||||
<session>/subagents/<tool_use_id>/meta.json {title, created}
|
||||
<session>/subagents/<tool_use_id>/transcript.jsonl same SeqEvent lines as the session's
|
||||
```
|
||||
|
||||
The id is the Task tool_use id (`toolu_…`), which is unique, stable across a
|
||||
backend restart, and already the key everything on the parent side uses.
|
||||
Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
|
||||
id becomes a path.
|
||||
|
||||
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
|
||||
`read_window`, `catch_up` and `read_after` work on it unchanged.
|
||||
|
||||
Its path out: deleting the session deletes its directory, subagents included.
|
||||
There is no separate delete.
|
||||
|
||||
## Lifecycle, as events in the subagent's transcript
|
||||
|
||||
1. Created on the first child line for an unseen parent id (or, when the
|
||||
parent Task call was seen, at that call). First lines written:
|
||||
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when
|
||||
the prompt is known -- it genuinely is the subagent's first user turn.
|
||||
2. Every child line is translated by that subagent's own `Translator`
|
||||
(one per subagent: tool ids are unique but streaming deltas are by
|
||||
content-block index, and parallel subagents interleave).
|
||||
3. **The parent's `tool_result` never finishes a subagent.** The Task tool
|
||||
runs in the background by default: the `tool_result` -- "Async agent
|
||||
launched..." -- arrives the moment it *starts*, while the subagent goes
|
||||
on working for however long its own turn takes, sometimes minutes. What
|
||||
ends it is its own turn ending: the raw API's `message_delta` on its
|
||||
stream carrying `stop_reason: "end_turn"` (a `stop_reason` of `tool_use`
|
||||
is the model about to call one, not an end), or a `result` line for its
|
||||
own turn if a future CLI version ever sends one. Either maps to
|
||||
`Status Exited`; the subagent's vocabulary has no `Idle`, so the
|
||||
equivalent event `dispatch` produces for an ordinary session is dropped
|
||||
rather than written. A shipped version of this finished on the
|
||||
`tool_result` instead, which read a running background agent as
|
||||
"finished" with its transcript truncated at the moment it launched.
|
||||
4. **A child line for a subagent that already finished reopens it**
|
||||
(`Status Running`) rather than being dropped: a background Task can be
|
||||
sent another message long after its first turn ended, and that is
|
||||
exactly what a further line for it means. Same transcript, same child
|
||||
`Translator`, just picking back up.
|
||||
5. When the parent session's process exits (`Status Exited` on the
|
||||
session), every subagent still `Running` gets `Status Exited` too: its
|
||||
process was the parent's.
|
||||
|
||||
A subagent that was mid-flight when the backend restarted keeps working:
|
||||
the registry reopens the existing transcript on the next child line, and
|
||||
the file continues its sequence -- the same reopening #4 describes, whether
|
||||
what closed it was a restart or its own `end_turn`. If its turn ended while
|
||||
the backend was down nothing recorded that until the next line arrives, so
|
||||
its last status stays `Running`, which the list reports as **unknown**
|
||||
rather than as running (see the wire shape) until then.
|
||||
|
||||
Title: the Task call's `description` input, then ` (<subagent_type>)` when
|
||||
one is given; falling back to the tool's name when the child arrives before
|
||||
(or without) the parent call being seen.
|
||||
|
||||
## Server layout
|
||||
|
||||
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
|
||||
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
|
||||
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
|
||||
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk. Drivers get an
|
||||
`Arc<Subagents>` beside their `EventSink`; llama ignores it.
|
||||
- `session/claude/translate.rs` -- routes child lines by parent id, holds
|
||||
one child `Translator` per subagent, remembers pending Task calls'
|
||||
description/prompt/subagent_type.
|
||||
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
|
||||
subagents at once, each named "helper k". Each writes the prompt as its
|
||||
user message, streams a few words of text, runs one `Bash` tool call, then
|
||||
finishes about three seconds after starting, and the parent's Task calls
|
||||
end when their subagent does. Three seconds so the running state can be
|
||||
seen on the phone.
|
||||
- `routes.rs` -- three routes, in the doc table.
|
||||
|
||||
## Wire shape
|
||||
|
||||
```
|
||||
GET /sessions/{id} SessionInfo gains `subagents: N` (count, 0 when none)
|
||||
GET /sessions same field on each row
|
||||
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
|
||||
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
|
||||
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
|
||||
```
|
||||
|
||||
`status` is the transcript's last `Status` event, serialised like a session's
|
||||
(`running`, `exited`), except that a subagent whose session is not itself
|
||||
running cannot be running: the list answers `unknown` for that one. The
|
||||
phone words these as *running*, *finished* and *unknown* on the subcard.
|
||||
|
||||
The count on `SessionInfo` is a directory listing, so the list stays cheap.
|
||||
The per-subagent status is only read when the list route is asked for.
|
||||
|
||||
## Phone
|
||||
|
||||
- `SessionSummary.subagents: Int`. A card with a non-zero count ends in an
|
||||
expander row -- a full-width `Chevron(Pointing.Down)` row that flips to
|
||||
`Pointing.Up` -- collapsed by default. Expanding fetches
|
||||
`/sessions/{id}/subagents` and draws one `OutlinedCard` per subagent,
|
||||
indented inside the session card, the way dev-updater draws a project's
|
||||
components: title, then the status word and a relative time. The
|
||||
expansion state is per session id and survives a refresh of the list.
|
||||
- Tapping a subcard opens `Screen.Subagent`, which is `SessionScreen` in
|
||||
**read-only** form: the same transcript, paging, cache, selection,
|
||||
images and status row, with the composer, the process button, the model
|
||||
picker, the files button, the settings cog and the usage bar left out.
|
||||
The header shows the subagent's title with the session's title beneath
|
||||
it. Back returns to the list.
|
||||
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
|
||||
cache take a transcript address rather than a session id --
|
||||
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
|
||||
subagent's copy under its session's and the same code serves both.
|
||||
@@ -1,512 +0,0 @@
|
||||
# The transcript cache
|
||||
|
||||
Asked for by Iris on 2026-09-04: 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, it must not disturb a reply that is streaming
|
||||
when the screen is reopened, it must never skip an event, and session
|
||||
settings needs a manual reload for when the file on the machine has
|
||||
changed under it.
|
||||
|
||||
Built 2026-09-04. 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 -- three of them changed during the building, and
|
||||
"What building it changed" at the foot says which and why. What it is *not*
|
||||
is the operational half: how to exercise it, and what has bitten, are in
|
||||
AGENTS.md with the rest of the working notes.
|
||||
|
||||
## 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 today --
|
||||
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 today 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
|
||||
|
||||
Everything below is in service of four rules. When a decision 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, as it does today when the
|
||||
phone merely lost the tunnel for a second.
|
||||
3. **The cache is never load-bearing.** A missing, evicted, corrupt or
|
||||
unwritable cache degrades to today's behaviour -- a cold open -- and
|
||||
never to a blank or wrong screen. Every path that reads it has a
|
||||
network path beside it that produces the same result.
|
||||
4. **Data crosses the tunnel once.** A line already on the phone is not
|
||||
fetched again unless the reader asks for that (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 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
|
||||
(`SessionEvent.Unknown`) survives on disk for the build that will.
|
||||
|
||||
It lives under `context.cacheDir` -- `<cacheDir>/transcripts/v1/<host>_<port>/<sessionId>/`
|
||||
-- because it 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. `ServerSettings` has both fields; the key is
|
||||
`"${settings.host}_${settings.port}"` with `:` never appearing in it.
|
||||
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 already
|
||||
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
|
||||
(`coalesce=true`, which the scroll-back pager asks for) 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 `first` is the seq of its oldest
|
||||
event and `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; end = last line's seq + 1
|
||||
|
||||
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 the gap.
|
||||
|
||||
**The newest chunk is always raw.** That is what makes the stream cursor
|
||||
and the check in decision 3 well defined: a raw chunk's last line is a real
|
||||
event at a real seq, and the server never coalesces the newest window
|
||||
("the live cursor depends on real seqs", `read_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:
|
||||
if the newest chunk on disk is a `.rows` chunk (which can only happen if
|
||||
the app died between closing one live run and appending to the next), the
|
||||
session's cache is purged and the open is cold.
|
||||
|
||||
There is at most one open chunk. When a stream event arrives whose seq is
|
||||
not the open chunk's `end` -- which is what a `reset` looks like from
|
||||
here, see decision 6 -- the open chunk is closed by renaming it with its
|
||||
real end, and a new open chunk starts at the arriving seq. 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 this is a guard, not 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 in the first place, and if one arrives anyway (a server
|
||||
without decision 4's change) it is used for display and not stored.
|
||||
|
||||
### 3. The cached tail is checked against the server before the stream opens from it
|
||||
|
||||
The screen must not resume a stream from a cached seq unless the server's
|
||||
event at that seq is the one in the cache. The transcript file on the
|
||||
machine 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
|
||||
directory deleted and the session re-imported under the same name -- 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:** `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. The probe passes when that
|
||||
response, parsed with `parseSeqEvent`, is `==` to the cached line parsed
|
||||
the same way -- data-class equality 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.** `serde_json`'s default float parser is not correctly rounded, so a
|
||||
`ts` of `1788546972.6030757` written to the transcript came back from
|
||||
`/transcript` as `...0755`, while the SSE stream -- serializing the same
|
||||
struct -- sent the original. Measured on the emulator 2026-09-04: 23 of 330
|
||||
cached lines differed from the server's answer in the last bit, so the probe
|
||||
would have failed on any session whose cached tail happened to be one of
|
||||
them, silently and only sometimes. That is a defect in the server
|
||||
independent of this feature -- two answers to "what is line 30" -- and it is
|
||||
fixed there, with `float_roundtrip` and a test
|
||||
(`a_line_read_back_is_the_line_that_was_written`) that fails the moment the
|
||||
feature is dropped. 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 (no route to
|
||||
the server) leaves the cached transcript on screen, shows the request's
|
||||
error on the stream banner where a connection failure shows today, and is
|
||||
retried on the stream loop's schedule (`RECONNECT_DELAY_MS`); 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, one round trip, in the slot
|
||||
where the opening page's request is today -- 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 of the feature; a failed probe replaces them, the same
|
||||
appearance as a `reset`.
|
||||
|
||||
Rejected: a server-side check on the stream (`events?after=N&ts=T`,
|
||||
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, not
|
||||
only on open -- 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" (decision 6), so a second frame would be
|
||||
needed. Worth revisiting if the probe's round trip is ever measured as the
|
||||
thing making reopen slow; note it as the alternative here and in PLAN.md.
|
||||
|
||||
Rejected: trusting the cache without a check and relying on the reload
|
||||
button. Invariant 1 is not something a button restores after the fact.
|
||||
|
||||
Rejected: fetching the newest page as today 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 the server 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
|
||||
overlapped and 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 gains a lower bound. `TranscriptQuery` in
|
||||
`server/src/routes.rs` gets
|
||||
|
||||
/// Return nothing at or below this seq; the page stops here instead of at `limit`.
|
||||
/// The phone passes the end of what it already holds, so a page never overlaps it.
|
||||
#[serde(default)]
|
||||
after: Option<u64>,
|
||||
|
||||
named to match the SSE route's `after` (exclusive, `seq > after`).
|
||||
`read_window(path, before, after, limit, coalesce)` in
|
||||
`server/src/session/transcript.rs` computes
|
||||
`start = first_at_or_after(after + 1)` and stops the walk there: the raw
|
||||
branch parses `max(start, end - limit)..end`; `parse_coalesced` takes a
|
||||
`start` and its `while index > 0` becomes `while index > start`. A delta
|
||||
run cut at `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 route's table comment in `routes.rs` gains the parameter,
|
||||
and `transcript.rs` gets a test beside
|
||||
`a_window_is_the_events_before_a_cursor_and_nothing_else`: with `after`
|
||||
set, the page's oldest seq is greater than `after`, and with `after` set
|
||||
inside a delta run the partial run's seq is the first delta above `after`.
|
||||
|
||||
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 (`before=W&limit=W-b`,
|
||||
which is what the anchor restore already does). Exact, but a gap of ten
|
||||
thousand lines is several megabytes downloaded to save re-downloading
|
||||
history the reader may never scroll to; the feature exists to save data.
|
||||
Paging as today with a bound saves the same bytes and fetches only what
|
||||
is read.
|
||||
|
||||
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
|
||||
`coalesce = true`, 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 from the line before `before`
|
||||
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; it stops only between rows, once `limit` rows are complete, and
|
||||
returns the raw lines oldest-first. It does not join the deltas -- the
|
||||
fold does that (`foldEvent` appends a delta to a preceding
|
||||
`AssistantMsg`), and the joined row keeps the seq of its first delta either
|
||||
way, so anchors and the next `before` land where they do today.
|
||||
|
||||
A cached page is allowed to be **short**: the suffix's oldest chunk starts
|
||||
at some `first`, and a walk that reaches it returns what it found. The
|
||||
caller already treats a short page as a page; only an *empty* page means
|
||||
"start of the conversation" (`moreHistory = false`), and the cache never
|
||||
returns an empty page -- it returns `null` (a miss) and the network is
|
||||
asked. The walk may cross a chunk boundary inside the suffix, since adjacent
|
||||
chunks are one run; a delta run straddling a boundary counts as one row, as
|
||||
it should.
|
||||
|
||||
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, not at a boundary. Under the narrower rule every warm open sent
|
||||
its first backwards page to the server, and that page -- reaching back past
|
||||
the run the phone already held -- overlapped it 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
|
||||
(one comparison), it is pure, and it goes under a JVM unit test with the
|
||||
same fixture as the server's `coalescing_counts_rows_and_joins_delta_runs`
|
||||
-- the three cases are 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. The screen already drops
|
||||
everything and rebuilds from that window. For the cache, a reset 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 (decision 2:
|
||||
a seq that is not the open chunk's `end` closes it and opens a new chunk)
|
||||
and needs no signal from the screen; the gap is filled by paging
|
||||
(decision 4).
|
||||
|
||||
Two things the reset handler in `SessionScreen` does not clear today and
|
||||
must: `queued` and `waitingCommands`. 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. This is a latent bug today, made likely by the cache because a
|
||||
cached tail is older than a fetched one. `contextTokens` needs no change:
|
||||
`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
|
||||
`summary.status`, `summary.model` and `summary.permissionMode` -- the row
|
||||
the reader just tapped -- were fetched moments ago. So the cache replay
|
||||
runs through `apply` for the transcript's sake (queued bubbles, context,
|
||||
rows) 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
|
||||
|
||||
`SessionSettingsDialog` gains a row under the working directory:
|
||||
|
||||
[ Transcript ] 2.3 MB cached [ Reload ]
|
||||
|
||||
The size is what the button discards, and it 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. A caption 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: purge the session's cache directory, close the dialog, and
|
||||
rebuild the screen as a cold open -- the same sequence as `reset` plus a
|
||||
fresh opening fetch, with the reader put back where they were. The
|
||||
mechanism is an `epoch` counter (`mutableIntStateOf(0)`) added to the key
|
||||
of the opening effect and the stream effect; incrementing it cancels both
|
||||
(the stream's `finally` closes the socket) and relaunches them. State the
|
||||
relaunch must see cleared: `items`, `replies.clear()`, `held`, `oldestSeq
|
||||
= 0`, `moreHistory = true`, `queued`, `waitingCommands`, `lastSeq.set(0)`,
|
||||
`ready = false`. `savedAnchor` becomes `remember(summary.id, epoch)` so
|
||||
the restore path reads the anchor saved at the reader's *current*
|
||||
position (the anchor saver writes on every settle, so it is there), and
|
||||
`restoring` is re-derived from it. 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 where
|
||||
that failure lands today.
|
||||
|
||||
Rejected: a global "clear transcript cache" in the app's settings screen.
|
||||
Not asked for; eviction (decision 9) 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 `TranscriptCache.purgeAll` if wanted.
|
||||
|
||||
### 9. Budget, eviction, pruning
|
||||
|
||||
The cache is bounded three ways, each with its path out written beside
|
||||
the path in:
|
||||
|
||||
- **Budget.** `CACHE_BUDGET_BYTES = 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 session directories
|
||||
(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.** `SessionListScreen`'s delete calls
|
||||
`cache.session(id).purge()` after `deleteSession` succeeds, and every
|
||||
successful list fetch calls `cache.retainOnly(ids)` for that server, so
|
||||
a session deleted from another device or from the backend is pruned on
|
||||
the next visit to the list. `Drafts.kt` chose not to prune because its
|
||||
residue is bytes; here it is megabytes, so the pass is worth having.
|
||||
- **Android.** `cacheDir` may be emptied under pressure at any moment,
|
||||
including while a screen is open. Every read tolerates a missing
|
||||
directory (cold open) and every write failure is swallowed once and
|
||||
disables writing for that screen instance (decision 10).
|
||||
|
||||
### 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, with the failure logged once at `Log.w("ai-app", …)`. After a
|
||||
write failure the `SessionCache` instance sets `disabled = true` and
|
||||
writes nothing more, 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 (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; end = last line's seq + 1
|
||||
|
||||
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 something written above, and each was found by
|
||||
running it rather than by reading it. The decisions themselves are amended
|
||||
in place; this is the list of what moved, so that 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 below had `storePage` grow a special case for "this page is the new
|
||||
open chunk", decided by an implicit condition that 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 then 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 the same
|
||||
gestures, reported 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?** Decision 3 probes
|
||||
once per screen instance. A file replaced *while* the screen is open is
|
||||
today's behaviour and not made worse, but the server-side check it
|
||||
rejects would close it. Decide after measuring how often the probe's
|
||||
round trip is what the reader waits on.
|
||||
- **A reset arriving during an anchor restore** was an open worry when this
|
||||
was written, and was measured and closed on 2026-09-04 (see "The reconnect
|
||||
loop does not reproduce") before this landed. The cache makes the restore
|
||||
cheaper again -- a warm one is now the probe and nothing else -- so it can
|
||||
only have narrowed the window further. Worth re-measuring here only if a
|
||||
reader reports the screen reconnecting on reopen.
|
||||
- **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.
|
||||
Generated
+1081
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "android-shell"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# The JNI bridge behind E3's two Java stub classes (`MainActivity`,
|
||||
# `NotificationService` -- see RUST.md's "How much Java is unavoidable" for
|
||||
# why those two classes cannot be anything but Java/Kotlin, registered from
|
||||
# the manifest by name). Everything they would otherwise have done in
|
||||
# Kotlin -- the SSE follow loop, deciding where a notification is shown,
|
||||
# picking a session for a share -- is here instead, built on `client-core`
|
||||
# so the networking and parsing are not duplicated a third time next to the
|
||||
# server and the Kotlin app.
|
||||
#
|
||||
# `cdylib` for `System.loadLibrary`; `lib` too so `cargo test`/`clippy` run
|
||||
# on a normal host target without an Android NDK toolchain, the same
|
||||
# posture `client-core` and `server` already have.
|
||||
|
||||
[lib]
|
||||
name = "android_shell"
|
||||
crate-type = ["cdylib", "lib"]
|
||||
|
||||
[dependencies]
|
||||
client-core = { path = "../client-core" }
|
||||
jni = "0.22"
|
||||
log = "0.4"
|
||||
|
||||
# `LogErrorAndDefault` (the `native_method!` error policy this crate uses
|
||||
# throughout, see lib.rs) logs through the `log` facade, which is a no-op
|
||||
# without a backend installed -- so without this, every recoverable error
|
||||
# at a native entry point would be silently dropped rather than reaching
|
||||
# logcat. Android-only: nothing else here needs it, and it does not build
|
||||
# off-device (see `notify::ensure_logger`'s call site, the only place this
|
||||
# is used).
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android_logger = "0.15"
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Thin wrappers around the five `Env` calls this crate makes constantly
|
||||
//! (a class name, a method name and a signature, all as plain `&str`).
|
||||
//!
|
||||
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
|
||||
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
|
||||
//! here uniformly rather than switching to the compile-time `jni_str!`
|
||||
//! literal macro call by call -- these are a handful of short, one-off
|
||||
//! lookups, not a hot loop, so the difference is not worth two code paths
|
||||
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
|
||||
//! `FieldSignature`, which is why those go through
|
||||
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
|
||||
//! parsed form is what lets these calls skip re-validating the signature
|
||||
//! against the arguments on every call, which is the whole reason `jni`
|
||||
//! moved to it.
|
||||
//!
|
||||
//! **The classloader gotcha, found by testing (2026-09-05).** A class
|
||||
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
|
||||
//! `get_static_field` -- anything that resolves a *class*, as opposed to
|
||||
//! `call_method` on an object it already has, which needs no such lookup)
|
||||
//! defaults to `FindClass`'s ordinary search when it cannot find the
|
||||
//! calling thread a classloader through `Thread.getContextClassLoader()`.
|
||||
//! That default is fine on a thread the JVM itself started -- an
|
||||
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
|
||||
//! from `android-shell`'s own background thread (the notification
|
||||
//! follow-loop, the share upload) is running on a thread *Rust* spawned
|
||||
//! and attached with `JavaVM::attach_current_thread`, which the platform
|
||||
//! never gave an app classloader. Framework classes
|
||||
//! (`android.app.Notification$Builder`, ...) still resolve, because they
|
||||
//! are reachable from the bootstrap loader `FindClass` falls back to --
|
||||
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
|
||||
//! packaged inside this app's own APK. The failure was
|
||||
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
|
||||
//! as "failed to resolve Java class ... (class not found or linkage
|
||||
//! error)" -- on a real device this reads as "the notification silently
|
||||
//! never arrives," since the whole call is inside the follow loop and the
|
||||
//! ongoing foreground notification (built on the main thread, in
|
||||
//! `try_start`, before the background thread exists) posts fine either
|
||||
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
|
||||
//! first time any entry point has a `Context` to ask, and every class
|
||||
//! lookup below goes through it explicitly via `LoaderContext::Loader`
|
||||
//! rather than the thread-dependent default -- so it is correct on the
|
||||
//! main thread and on this crate's own background threads alike.
|
||||
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
|
||||
use jni::refs::{Global, LoaderContext};
|
||||
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
|
||||
use jni::strings::JNIString;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
|
||||
|
||||
/// Caches `context`'s own `ClassLoader`, the first time this is called.
|
||||
/// Cheap to call from every entry point that has a `Context` on hand
|
||||
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
|
||||
/// a `OnceLock::get` and nothing else.
|
||||
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
if CLASS_LOADER.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// context.getClass().getClassLoader() -- resolved via `call_method` on
|
||||
// real objects throughout, so this needs no class-name lookup of its
|
||||
// own and has nothing to bootstrap.
|
||||
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
|
||||
let loader_obj = call_method(
|
||||
env,
|
||||
&class_obj,
|
||||
"getClassLoader",
|
||||
"()Ljava/lang/ClassLoader;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
|
||||
let global = env.new_global_ref(&loader)?;
|
||||
// Lost the race with another entry point calling this concurrently --
|
||||
// both loaders name the same app, so either one is fine and there is
|
||||
// nothing to reconcile.
|
||||
let _ = CLASS_LOADER.set(global);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
|
||||
/// through the cached app classloader when one has been remembered, and
|
||||
/// through the ordinary default otherwise -- which is every call made
|
||||
/// before any entry point has run, and is also correct for a main-thread
|
||||
/// caller, so there is no case this makes worse.
|
||||
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
||||
match CLASS_LOADER.get() {
|
||||
Some(loader) => {
|
||||
let binary_name = name.replace('/', ".");
|
||||
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
|
||||
}
|
||||
None => env.find_class(JNIString::new(name)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
||||
resolve_class(env, name)
|
||||
}
|
||||
|
||||
/// A new Java string as a plain `JObject` -- what every call site here
|
||||
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
|
||||
/// `JString: Into<JObject>` is the documented way across).
|
||||
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
|
||||
Ok(env.new_string(text)?.into())
|
||||
}
|
||||
|
||||
pub fn new_object<'local>(
|
||||
env: &mut Env<'local>,
|
||||
class: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<JObject<'local>> {
|
||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||
let class = resolve_class(env, class)?;
|
||||
env.new_object(class, sig.method_signature(), args)
|
||||
}
|
||||
|
||||
pub fn call_method<'local>(
|
||||
env: &mut Env<'local>,
|
||||
obj: &JObject,
|
||||
method: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<JValueOwned<'local>> {
|
||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
|
||||
}
|
||||
|
||||
pub fn call_static_method<'local>(
|
||||
env: &mut Env<'local>,
|
||||
class: &str,
|
||||
method: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<JValueOwned<'local>> {
|
||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||
let class = resolve_class(env, class)?;
|
||||
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
|
||||
}
|
||||
|
||||
pub fn get_static_field<'local>(
|
||||
env: &mut Env<'local>,
|
||||
class: &str,
|
||||
field: &str,
|
||||
sig: &str,
|
||||
) -> Result<JValueOwned<'local>> {
|
||||
let sig = RuntimeFieldSignature::from_str(sig)?;
|
||||
let class = resolve_class(env, class)?;
|
||||
env.get_static_field(class, JNIString::new(field), sig.field_signature())
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
|
||||
//! package comment for what this crate is and RUST.md's E3 entry for the
|
||||
//! design decisions.
|
||||
//!
|
||||
//! Each native method is declared with `jni`'s [`native_method!`] macro
|
||||
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
|
||||
//! the macro derives the mangled export name and the JNI signature from the
|
||||
//! Rust function itself, so the two cannot drift apart the way a
|
||||
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
|
||||
//! routinely do. `error_policy = LogErrorAndDefault` matches
|
||||
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
|
||||
//! JNI call that threw) is reported to logcat, not thrown back into Java
|
||||
//! as an exception that would crash the app over something recoverable.
|
||||
//!
|
||||
//! Each `const _: NativeMethod = native_method! { ... };` binding is
|
||||
//! otherwise unused by name -- `_` is the idiomatic way to keep a
|
||||
//! side-effecting const (here, generating the `#[export_name]`d function
|
||||
//! the JVM resolves by the JNI naming convention) without a `dead_code`
|
||||
//! warning for a binding nothing reads.
|
||||
|
||||
mod jcall;
|
||||
mod notify;
|
||||
mod settings;
|
||||
mod share;
|
||||
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JClass, JObject};
|
||||
use jni::sys::jint;
|
||||
use jni::{Env, NativeMethod, native_method};
|
||||
|
||||
/// Installs the `log` backend that routes to logcat, once per process.
|
||||
/// Without it, `LogErrorAndDefault` (every native method below) and any
|
||||
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
|
||||
/// call into the `log` facade's default no-op logger, and a real failure
|
||||
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
|
||||
/// crashing, since nothing on screen or in the log says a notification was
|
||||
/// dropped. Called from every entry point below rather than a Java-side
|
||||
/// `Application.onCreate`, since this crate deliberately has no such class
|
||||
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
|
||||
fn ensure_logger() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
#[cfg(target_os = "android")]
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Debug)
|
||||
.with_tag("android-shell"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// The parameters are spelled as their Java types, not as `JObject`: the
|
||||
// macro encodes each argument into the exported symbol's JNI signature
|
||||
// (and JNI resolves `Java_...` names *by* that signature), so a generic
|
||||
// `JObject` here would export `(Ljava/lang/Object;...)` against a Java
|
||||
// method actually declared `(Landroid/app/Activity;...)` -- two different
|
||||
// symbols that never resolve to each other, silently, with no compiler
|
||||
// error on either side. `android.app.Activity` etc. have no dedicated
|
||||
// Rust wrapper in this crate, so they fall back to plain `JObject` in the
|
||||
// implementation functions below (the "Built-in Types" note in
|
||||
// `native_method!`'s docs).
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.MainActivity",
|
||||
static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (),
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
|
||||
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
|
||||
fn native_handle_intent<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
activity: JObject<'local>,
|
||||
intent: JObject<'local>,
|
||||
) -> Result<(), jni::errors::Error> {
|
||||
ensure_logger();
|
||||
jcall::remember_class_loader(env, &activity)?;
|
||||
share::handle_intent(env, &activity, &intent)
|
||||
}
|
||||
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.NotificationService",
|
||||
static extern fn native_sync(context: android.content.Context) -> (),
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
|
||||
/// enrollment may have just landed) and from `NotificationService.sync`
|
||||
/// itself. See `notify::sync`.
|
||||
fn native_sync<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
context: JObject<'local>,
|
||||
) -> Result<(), jni::errors::Error> {
|
||||
ensure_logger();
|
||||
jcall::remember_class_loader(env, &context)?;
|
||||
notify::sync(env, &context)
|
||||
}
|
||||
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.NotificationService",
|
||||
static extern fn native_on_start_command(service: android.app.Service) -> jint,
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
|
||||
fn native_on_start_command<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
service: JObject<'local>,
|
||||
) -> Result<jint, jni::errors::Error> {
|
||||
ensure_logger();
|
||||
jcall::remember_class_loader(env, &service)?;
|
||||
Ok(notify::on_start_command(env, service))
|
||||
}
|
||||
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.NotificationService",
|
||||
static extern fn native_on_destroy() -> (),
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
|
||||
fn native_on_destroy<'local>(
|
||||
_env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
) -> Result<(), jni::errors::Error> {
|
||||
ensure_logger();
|
||||
notify::on_destroy();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
//! Where a notification is said, and the foreground service that keeps
|
||||
//! the connection open while the app is closed. Ported from
|
||||
//! `Notifications.kt`'s `NotificationService`, minus the "session on
|
||||
//! screen" / "hand to the app as a banner" branches: those read
|
||||
//! process-wide state that only exists because a screen is drawn to
|
||||
//! register against, and this experiment draws no screen yet (that is
|
||||
//! E4's job, on iris). So every notification here takes the third branch
|
||||
//! Kotlin's `show` already had -- the platform's own drawer -- which is
|
||||
//! also exactly the case E3's pass condition asks for: **a notification
|
||||
//! arrives with the app closed.**
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use client_core::api::UreqTransport;
|
||||
use client_core::notifications::{SessionNotification, follow_notifications};
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JValue};
|
||||
use jni::sys::{JNI_TRUE, jint};
|
||||
|
||||
use crate::settings::{self, ServerSettings};
|
||||
|
||||
const ALERT_CHANNEL: &str = "sessions";
|
||||
const ONGOING_CHANNEL: &str = "connection";
|
||||
const ONGOING_ID: i32 = 1;
|
||||
const ALERT_ID: i32 = 2;
|
||||
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
|
||||
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
||||
|
||||
/// Whether the follow-loop thread is already running. **A deviation from
|
||||
/// `Notifications.kt`, found by testing rather than planned**: the Kotlin
|
||||
/// `onStartCommand` spawns a fresh `thread(isDaemon = true) { follow(...) }`
|
||||
/// on *every* call, with nothing to notice a previous one is still going --
|
||||
/// and `sync()` calling `startForegroundService` when the service is
|
||||
/// already running is an ordinary Android start, not a restart, so
|
||||
/// `onStartCommand` runs again. Enrolling from `MainActivity` (which calls
|
||||
/// `sync` once itself, then again inside `handle_enrollment` after saving
|
||||
/// the token) hits exactly this path and was observed opening **two**
|
||||
/// concurrent connections to `/notifications` from one process -- caught
|
||||
/// on this build via `adb logcat` showing two `jni::vm::java_vm: Attached
|
||||
/// thread ai-app-notifications` lines for one enrollment. Guarded here
|
||||
/// rather than left to match Kotlin's behaviour exactly, since duplicating
|
||||
/// a live connection is a resource leak with no upside; worth carrying the
|
||||
/// same guard back to `Notifications.kt` separately.
|
||||
static RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Set by `nativeOnDestroy`, checked by the follow loop between
|
||||
/// reconnects. **Known gap, recorded rather than hidden**: unlike
|
||||
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
|
||||
/// can interrupt a `ureq` read already blocked inside one connection --
|
||||
/// `Transport::stream` hands back a plain `Read` with no cancellation
|
||||
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
|
||||
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
|
||||
/// practice this is a bounded wait rather than a hang; closing that gap
|
||||
/// for real means adding a cancellation point to `client_core::Transport`,
|
||||
/// which is a decision affecting every caller of that trait, not just this
|
||||
/// one -- left for whoever next depends on prompt shutdown.
|
||||
static STOPPING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
|
||||
crate::jcall::get_static_field(env, class, field, "I")?.i()
|
||||
}
|
||||
|
||||
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/app/NotificationManagerCompat",
|
||||
"from",
|
||||
"(Landroid/content/Context;)Landroidx/core/app/NotificationManagerCompat;",
|
||||
&[JValue::Object(context)],
|
||||
)?
|
||||
.l()
|
||||
}
|
||||
|
||||
fn create_channel(
|
||||
env: &mut Env,
|
||||
manager: &JObject,
|
||||
id: &str,
|
||||
name: &str,
|
||||
importance: i32,
|
||||
) -> Result<()> {
|
||||
let id_j = crate::jcall::jstr_obj(env, id)?;
|
||||
let builder = crate::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationChannelCompat$Builder",
|
||||
"(Ljava/lang/String;I)V",
|
||||
&[JValue::Object(&id_j), JValue::Int(importance)],
|
||||
)?;
|
||||
let name_j = crate::jcall::jstr_obj(env, name)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"setName",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
||||
&[JValue::Object(&name_j)],
|
||||
)?;
|
||||
let channel = crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"build",
|
||||
"()Landroidx/core/app/NotificationChannelCompat;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
manager,
|
||||
"createNotificationChannel",
|
||||
"(Landroidx/core/app/NotificationChannelCompat;)V",
|
||||
&[JValue::Object(&channel)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two channels, because they are two different things to be told -- see
|
||||
/// `Notifications.kt`'s `createChannels` for the reasoning; the names and
|
||||
/// importances here are copied from it exactly, since a phone that has
|
||||
/// seen both apps should not learn two different vocabularies for the
|
||||
/// same fact.
|
||||
fn create_channels(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
let manager = notification_manager(env, context)?;
|
||||
let default = static_int(
|
||||
env,
|
||||
"androidx/core/app/NotificationManagerCompat",
|
||||
"IMPORTANCE_DEFAULT",
|
||||
)?;
|
||||
let min = static_int(
|
||||
env,
|
||||
"androidx/core/app/NotificationManagerCompat",
|
||||
"IMPORTANCE_MIN",
|
||||
)?;
|
||||
create_channel(
|
||||
env,
|
||||
&manager,
|
||||
ALERT_CHANNEL,
|
||||
"Sessions needing attention",
|
||||
default,
|
||||
)?;
|
||||
create_channel(env, &manager, ONGOING_CHANNEL, "Staying connected", min)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn new_intent_for<'l>(
|
||||
env: &mut Env<'l>,
|
||||
context: &JObject,
|
||||
class_name: &str,
|
||||
) -> Result<JObject<'l>> {
|
||||
let target_class = crate::jcall::find_class(env, class_name)?;
|
||||
crate::jcall::new_object(
|
||||
env,
|
||||
"android/content/Intent",
|
||||
"(Landroid/content/Context;Ljava/lang/Class;)V",
|
||||
&[JValue::Object(context), JValue::Object(&target_class)],
|
||||
)
|
||||
}
|
||||
|
||||
/// The intent a tap on an alert opens -- mirrors `Notifications.kt`'s
|
||||
/// `sessionIntent`, including building the URI through `Uri.Builder`
|
||||
/// rather than string concatenation, for the same reason: an id needing
|
||||
/// escaping must survive the round trip.
|
||||
fn session_intent<'l>(
|
||||
env: &mut Env<'l>,
|
||||
context: &JObject,
|
||||
session_id: &str,
|
||||
) -> Result<JObject<'l>> {
|
||||
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
|
||||
let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&intent,
|
||||
"setAction",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||
&[JValue::Object(&action_view)],
|
||||
)?;
|
||||
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
||||
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"scheme",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&scheme)],
|
||||
)?;
|
||||
let authority = crate::jcall::jstr_obj(env, "session")?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"authority",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&authority)],
|
||||
)?;
|
||||
let path = crate::jcall::jstr_obj(env, session_id)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"appendPath",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&path)],
|
||||
)?;
|
||||
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&intent,
|
||||
"setData",
|
||||
"(Landroid/net/Uri;)Landroid/content/Intent;",
|
||||
&[JValue::Object(&uri)],
|
||||
)?;
|
||||
Ok(intent)
|
||||
}
|
||||
|
||||
fn pending_activity<'l>(
|
||||
env: &mut Env<'l>,
|
||||
context: &JObject,
|
||||
intent: &JObject,
|
||||
) -> Result<JObject<'l>> {
|
||||
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
|
||||
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"android/app/PendingIntent",
|
||||
"getActivity",
|
||||
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;",
|
||||
&[
|
||||
JValue::Object(context),
|
||||
JValue::Int(0),
|
||||
JValue::Object(intent),
|
||||
JValue::Int(update_current | immutable),
|
||||
],
|
||||
)?
|
||||
.l()
|
||||
}
|
||||
|
||||
fn builder_call<'l>(
|
||||
env: &mut Env<'l>,
|
||||
builder: &JObject<'l>,
|
||||
method: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<()> {
|
||||
crate::jcall::call_method(env, builder, method, sig, args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The type Android 14+ requires a foreground service to declare, and
|
||||
/// nothing before it -- mirrors `Notifications.kt`'s `foregroundType`.
|
||||
fn foreground_type(env: &mut Env) -> Result<i32> {
|
||||
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
||||
let upside_down_cake = static_int(env, "android/os/Build$VERSION_CODES", "UPSIDE_DOWN_CAKE")?;
|
||||
if sdk >= upside_down_cake {
|
||||
static_int(
|
||||
env,
|
||||
"android/content/pm/ServiceInfo",
|
||||
"FOREGROUND_SERVICE_TYPE_SPECIAL_USE",
|
||||
)
|
||||
} else {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||
let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
||||
let builder = crate::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationCompat$Builder",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[JValue::Object(context), JValue::Object(&channel)],
|
||||
)?;
|
||||
let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentTitle",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&title)],
|
||||
)?;
|
||||
let icon = static_int(env, "android/R$drawable", "stat_notify_sync")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setSmallIcon",
|
||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Int(icon)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setOngoing",
|
||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Bool(JNI_TRUE)],
|
||||
)?;
|
||||
let priority_min = static_int(env, "androidx/core/app/NotificationCompat", "PRIORITY_MIN")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setPriority",
|
||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Int(priority_min)],
|
||||
)?;
|
||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?.l()
|
||||
}
|
||||
|
||||
/// Starts the service if there is a server to connect to, and stops it
|
||||
/// otherwise -- mirrors `Notifications.kt`'s `NotificationService.sync`.
|
||||
pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
let service_intent =
|
||||
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
|
||||
if settings::load(env, context)?.is_none() {
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
context,
|
||||
"stopService",
|
||||
"(Landroid/content/Intent;)Z",
|
||||
&[JValue::Object(&service_intent)],
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
create_channels(env, context)?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/content/ContextCompat",
|
||||
"startForegroundService",
|
||||
"(Landroid/content/Context;Landroid/content/Intent;)V",
|
||||
&[JValue::Object(context), JValue::Object(&service_intent)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `Service.onStartCommand` body -- loads settings, starts the
|
||||
/// foreground notification, and spawns the follow-loop thread. Answers the
|
||||
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
|
||||
/// framework rather than hardcoded so a wrong guess at their values cannot
|
||||
/// silently pick the other behaviour.
|
||||
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
|
||||
match try_start(env, &service) {
|
||||
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
|
||||
Ok(false) => {
|
||||
let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
|
||||
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
||||
}
|
||||
Err(e) => {
|
||||
log_error(env, "onStartCommand", &e);
|
||||
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
||||
let Some(settings) = settings::load(env, service)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let ca = settings::load_pinned_ca(env)?;
|
||||
let notification = ongoing_notification(env, service)?;
|
||||
let fg_type = foreground_type(env)?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/app/ServiceCompat",
|
||||
"startForeground",
|
||||
"(Landroid/app/Service;ILandroid/app/Notification;I)V",
|
||||
&[
|
||||
JValue::Object(service),
|
||||
JValue::Int(ONGOING_ID),
|
||||
JValue::Object(¬ification),
|
||||
JValue::Int(fg_type),
|
||||
],
|
||||
)?;
|
||||
|
||||
// See `RUNNING`'s doc: a second `onStartCommand` while the loop from
|
||||
// the first is still going -- the ordinary case for this service,
|
||||
// since `sync()` is called from more than one place -- must not open
|
||||
// a second connection.
|
||||
if RUNNING.swap(true, Ordering::SeqCst) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let vm = env.get_java_vm()?;
|
||||
let context = env.new_global_ref(service)?;
|
||||
STOPPING.store(false, Ordering::SeqCst);
|
||||
std::thread::Builder::new()
|
||||
.name("ai-app-notifications".to_string())
|
||||
.spawn(move || {
|
||||
// Requests a *permanent* attachment (detached only when this thread
|
||||
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
|
||||
// this is the long-lived follow loop, not a one-shot callback.
|
||||
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||
follow_loop(env, &context, settings, &ca);
|
||||
Ok(())
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Follows the backend's notification stream, reconnecting until stopped
|
||||
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
|
||||
/// ordinary case, so it retries quietly and forever; nothing is shown when
|
||||
/// it cannot connect, for the same reason as the Kotlin original: a
|
||||
/// notification saying "I could not tell you whether anything happened" is
|
||||
/// noise about a condition nobody can act on.
|
||||
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
|
||||
while !STOPPING.load(Ordering::SeqCst) {
|
||||
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
|
||||
let _ = follow_notifications(&transport, |notification| {
|
||||
if let Err(e) = show(env, context, ¬ification) {
|
||||
log_error(env, "show", &e);
|
||||
}
|
||||
!STOPPING.load(Ordering::SeqCst)
|
||||
});
|
||||
}
|
||||
if STOPPING.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(RECONNECT_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
/// One notification per session, replacing that session's previous one --
|
||||
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
|
||||
/// branches this module's doc comment explains.
|
||||
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
|
||||
let manager = notification_manager(env, context)?;
|
||||
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
||||
let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?;
|
||||
let allowed = if sdk < tiramisu {
|
||||
true
|
||||
} else {
|
||||
let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
|
||||
let granted = static_int(
|
||||
env,
|
||||
"android/content/pm/PackageManager",
|
||||
"PERMISSION_GRANTED",
|
||||
)?;
|
||||
let result = crate::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/content/ContextCompat",
|
||||
"checkSelfPermission",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)I",
|
||||
&[JValue::Object(context), JValue::Object(&permission)],
|
||||
)?
|
||||
.i()?;
|
||||
result == granted
|
||||
};
|
||||
let enabled =
|
||||
crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
|
||||
if !allowed || !enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let intent = session_intent(env, context, ¬ification.session_id)?;
|
||||
let pending = pending_activity(env, context, &intent)?;
|
||||
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
||||
let builder = crate::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationCompat$Builder",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[JValue::Object(context), JValue::Object(&channel)],
|
||||
)?;
|
||||
let title = crate::jcall::jstr_obj(env, ¬ification.title)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentTitle",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&title)],
|
||||
)?;
|
||||
let text = crate::jcall::jstr_obj(env, notification.kind.attention_line())?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentText",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&text)],
|
||||
)?;
|
||||
let icon = static_int(env, "android/R$drawable", "stat_notify_chat")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setSmallIcon",
|
||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Int(icon)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentIntent",
|
||||
"(Landroid/app/PendingIntent;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&pending)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setAutoCancel",
|
||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Bool(JNI_TRUE)],
|
||||
)?;
|
||||
let when = (notification.at * 1000.0) as i64;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setWhen",
|
||||
"(J)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Long(when)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setShowWhen",
|
||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Bool(JNI_TRUE)],
|
||||
)?;
|
||||
let built =
|
||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
|
||||
.l()?;
|
||||
let tag = crate::jcall::jstr_obj(env, ¬ification.session_id)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&manager,
|
||||
"notify",
|
||||
"(Ljava/lang/String;ILandroid/app/Notification;)V",
|
||||
&[
|
||||
JValue::Object(&tag),
|
||||
JValue::Int(ALERT_ID),
|
||||
JValue::Object(&built),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
|
||||
/// the gap this module's `STOPPING` doc explains.
|
||||
pub fn on_destroy() {
|
||||
STOPPING.store(true, Ordering::SeqCst);
|
||||
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
|
||||
// comment): the old thread may still be inside a blocked read when a
|
||||
// new `onStartCommand` follows immediately, which would spawn a
|
||||
// second one before the first has actually stopped. Narrower than not
|
||||
// resetting at all -- a service destroyed and never restarted would
|
||||
// otherwise wedge `RUNNING` true forever -- and no worse than the
|
||||
// known gap already accepted above.
|
||||
RUNNING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
|
||||
let message = format!("android-shell: {where_}: {error}");
|
||||
let _ = (|| -> Result<()> {
|
||||
let tag = crate::jcall::jstr_obj(env, "android-shell")?;
|
||||
let msg = crate::jcall::jstr_obj(env, &message)?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"android/util/Log",
|
||||
"e",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)I",
|
||||
&[JValue::Object(&tag), JValue::Object(&msg)],
|
||||
)?;
|
||||
Ok(())
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Enrollment: where the backend is, and the Keystore-sealed token to
|
||||
//! reach it. This crate does not reimplement the Android Keystore AES-GCM
|
||||
//! sealing in Rust -- it calls the same `wg-app-link` `ServerStore` Kotlin
|
||||
//! class the production app already uses (see `ServerConfig.kt`), through
|
||||
//! JNI, for two reasons: that code is shared with Dev Updater and already
|
||||
//! tested, and the sealed value on a real phone is keyed to the exact
|
||||
//! Keystore alias that class already uses -- reimplementing the crypto
|
||||
//! here would either duplicate it or invalidate an existing enrollment.
|
||||
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
|
||||
/// Where the backend is and how to authenticate to it -- the Rust twin of
|
||||
/// `wg-app-link`'s `ServerSettings` data class, read back field by field
|
||||
/// rather than kept as a live JNI reference, so it can cross a thread
|
||||
/// boundary (a `JObject` is tied to one `Env`/thread).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerSettings {
|
||||
pub host: String,
|
||||
pub port: i32,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl ServerSettings {
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("https://{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
/// This experiment's own scheme and Keystore alias -- distinct from the
|
||||
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
|
||||
/// installed side by side on the same development device without
|
||||
/// colliding over which one a scanned QR or a deep link resolves to. See
|
||||
/// RUST.md's E3 entry for why they are not the same value.
|
||||
pub(crate) const SCHEME: &str = "aiappshell";
|
||||
const KEY_ALIAS: &str = "aiapp-shell-token-key";
|
||||
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
|
||||
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
|
||||
|
||||
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
||||
let scheme = crate::jcall::jstr_obj(env, SCHEME)?;
|
||||
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
|
||||
crate::jcall::new_object(
|
||||
env,
|
||||
STORE_CLASS,
|
||||
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||
&[JValue::Object(&scheme), JValue::Object(&alias)],
|
||||
)
|
||||
}
|
||||
|
||||
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
|
||||
let host = get_string(env, settings_obj, "getHost")?;
|
||||
let port = crate::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
|
||||
let token = get_string(env, settings_obj, "getToken")?;
|
||||
Ok(ServerSettings { host, port, token })
|
||||
}
|
||||
|
||||
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
||||
let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
|
||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||
jstr.try_to_string(env)
|
||||
}
|
||||
|
||||
/// The stored enrollment, or `None` when there is not one -- mirrors
|
||||
/// `ServerConfig.kt`'s `loadServerSettings`.
|
||||
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
|
||||
let store = new_store(env)?;
|
||||
let settings_obj = crate::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"load",
|
||||
"(Landroid/content/Context;)Lcom/example/wgapplink/ServerSettings;",
|
||||
&[JValue::Object(context)],
|
||||
)?
|
||||
.l()?;
|
||||
if settings_obj.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(read_settings(env, &settings_obj)?))
|
||||
}
|
||||
|
||||
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
|
||||
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
|
||||
let store = new_store(env)?;
|
||||
let host = crate::jcall::jstr_obj(env, &settings.host)?;
|
||||
let token = crate::jcall::jstr_obj(env, &settings.token)?;
|
||||
let settings_obj = crate::jcall::new_object(
|
||||
env,
|
||||
SETTINGS_CLASS,
|
||||
"(Ljava/lang/String;ILjava/lang/String;)V",
|
||||
&[
|
||||
JValue::Object(&host),
|
||||
JValue::Int(settings.port),
|
||||
JValue::Object(&token),
|
||||
],
|
||||
)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"save",
|
||||
"(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V",
|
||||
&[JValue::Object(context), JValue::Object(&settings_obj)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
|
||||
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
|
||||
/// query-parameter rules rather than re-deriving them here.
|
||||
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
|
||||
let store = new_store(env)?;
|
||||
let settings_obj = crate::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"parseEnrollmentUri",
|
||||
"(Landroid/net/Uri;)Lcom/example/wgapplink/ServerSettings;",
|
||||
&[JValue::Object(uri)],
|
||||
)?
|
||||
.l()?;
|
||||
if settings_obj.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(read_settings(env, &settings_obj)?))
|
||||
}
|
||||
|
||||
/// The CA this build pins, generated at build time the same way
|
||||
/// `androidApp`'s `generatePinnedCert` task does (see `build.gradle.kts`)
|
||||
/// but into a plain Java constant, since this module has no Kotlin of its
|
||||
/// own to generate into.
|
||||
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
|
||||
let value = crate::jcall::get_static_field(
|
||||
env,
|
||||
"com/example/aiapp/shell/PinnedCa",
|
||||
"PINNED_CA_PEM",
|
||||
"Ljava/lang/String;",
|
||||
)?
|
||||
.l()?;
|
||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||
Ok(jstr.try_to_string(env)?.into_bytes())
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
|
||||
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
|
||||
//!
|
||||
//! **Scope cut, recorded rather than silent**: only shared *text*
|
||||
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
|
||||
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
|
||||
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
|
||||
//! ported here, because `client-core`'s `ApiClient` does not have the
|
||||
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
|
||||
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
|
||||
//! `content://` stream and no text falls through to a toast saying so,
|
||||
//! rather than silently doing nothing. Closing this gap is the same
|
||||
//! `client-core` work whichever caller needs it next.
|
||||
//!
|
||||
//! **Which session a share lands in** is also a placeholder: with no
|
||||
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
|
||||
//! to whichever session has the latest `last_activity` -- the one most
|
||||
//! likely to be what somebody meant. Worth revisiting once a real screen
|
||||
//! exists to ask instead of guessing.
|
||||
|
||||
use client_core::api::{ApiClient, UreqTransport};
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
|
||||
use crate::notify;
|
||||
use crate::settings;
|
||||
|
||||
const ACTION_SEND: &str = "android.intent.action.SEND";
|
||||
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
|
||||
const ACTION_VIEW: &str = "android.intent.action.VIEW";
|
||||
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
|
||||
|
||||
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
|
||||
let value = crate::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||
Ok(Some(jstr.try_to_string(env)?))
|
||||
}
|
||||
|
||||
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
||||
let message = crate::jcall::jstr_obj(env, message)?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"com/example/aiapp/shell/MainActivity",
|
||||
"toast",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[JValue::Object(context), JValue::Object(&message)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The one place an incoming intent is sorted into what it means -- mirrors
|
||||
/// `MainActivity.kt`'s `handleIntent`.
|
||||
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||
let action = get_string_method(env, intent, "getAction")?;
|
||||
if matches!(
|
||||
action.as_deref(),
|
||||
Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE)
|
||||
) {
|
||||
return handle_share(env, activity, intent);
|
||||
}
|
||||
if action.as_deref() != Some(ACTION_VIEW) {
|
||||
return Ok(());
|
||||
}
|
||||
let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?;
|
||||
if uri.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
let scheme = get_string_method(env, &uri, "getScheme")?;
|
||||
if scheme.as_deref() != Some(settings::SCHEME) {
|
||||
return Ok(());
|
||||
}
|
||||
match get_string_method(env, &uri, "getHost")?.as_deref() {
|
||||
Some("session") => handle_session_open(env, activity, &uri),
|
||||
Some("enroll") => handle_enrollment(env, activity, &uri),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
||||
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
|
||||
return Ok(());
|
||||
};
|
||||
// There is no session screen yet (E4's job); the toast is this
|
||||
// experiment's stand-in proof that the tap was routed to the right
|
||||
// session id.
|
||||
toast(env, activity, &format!("Opened session {session_id}"))
|
||||
}
|
||||
|
||||
fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
||||
match settings::parse_enrollment_uri(env, uri)? {
|
||||
Some(parsed) => {
|
||||
settings::save(env, activity, &parsed)?;
|
||||
notify::sync(env, activity)?;
|
||||
toast(
|
||||
env,
|
||||
activity,
|
||||
&format!("Enrolled with {}", parsed.base_url()),
|
||||
)
|
||||
}
|
||||
None => toast(env, activity, "Not a valid enrollment code"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
|
||||
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
|
||||
/// what happens to it, minus attachments per this module's doc comment.
|
||||
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
||||
let text = crate::jcall::call_method(
|
||||
env,
|
||||
intent,
|
||||
"getStringExtra",
|
||||
"(Ljava/lang/String;)Ljava/lang/String;",
|
||||
&[JValue::Object(&extra_text)],
|
||||
)?
|
||||
.l()?;
|
||||
let text = if text.is_null() {
|
||||
None
|
||||
} else {
|
||||
let jstr: JString = env.cast_local::<JString>(text)?;
|
||||
Some(jstr.try_to_string(env)?)
|
||||
};
|
||||
let Some(text) = text.filter(|t| !t.trim().is_empty()) else {
|
||||
return toast(
|
||||
env,
|
||||
activity,
|
||||
"Nothing to share -- only shared text is supported so far",
|
||||
);
|
||||
};
|
||||
|
||||
// Network I/O must not run on the calling thread: `handle_intent` is
|
||||
// called from `onCreate`/`onNewIntent`, both on the main thread, and a
|
||||
// blocking socket read there is a `NetworkOnMainThreadException`. So
|
||||
// the actual send happens on a JNI-attached background thread, the
|
||||
// same shape `notify::try_start`'s follow loop uses; `toast` from that
|
||||
// thread is safe because `MainActivity.toast` itself hops back to the
|
||||
// main looper (see that method).
|
||||
let vm = env.get_java_vm()?;
|
||||
let activity_ref = env.new_global_ref(activity)?;
|
||||
std::thread::spawn(move || {
|
||||
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||
share_in_background(env, &activity_ref, text);
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn share_in_background(env: &mut Env, activity: &JObject, text: String) {
|
||||
let outcome = attach_to_a_session(env, activity, &text);
|
||||
let message = match outcome {
|
||||
Ok(title) => format!("Shared into \"{title}\""),
|
||||
Err(message) => message,
|
||||
};
|
||||
let _ = toast(env, activity, &message);
|
||||
}
|
||||
|
||||
fn attach_to_a_session(
|
||||
env: &mut Env,
|
||||
activity: &JObject,
|
||||
text: &str,
|
||||
) -> std::result::Result<String, String> {
|
||||
let settings = settings::load(env, activity)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "Not enrolled yet".to_string())?;
|
||||
let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?;
|
||||
let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let client = ApiClient::new(transport);
|
||||
let sessions = client.fetch_sessions().map_err(|e| e.to_string())?;
|
||||
let target = sessions
|
||||
.into_iter()
|
||||
.max_by(|a, b| a.last_activity.total_cmp(&b.last_activity))
|
||||
.ok_or_else(|| "No session to share into".to_string())?;
|
||||
client
|
||||
.send_message(&target.id, text, &[])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(target.title)
|
||||
}
|
||||
@@ -102,6 +102,16 @@ android {
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
// Read by MainActivity to decide, at startup, whether this is the P0 benchmark build
|
||||
// (docs/RUST.md's P0 box) rather than the app somebody enrolled. False everywhere except
|
||||
// the `bench` build type below, which overrides it.
|
||||
buildConfigField("boolean", "FIXTURE_MODE", "false")
|
||||
}
|
||||
buildFeatures {
|
||||
// Only for FIXTURE_MODE above; nothing else here reaches for generated BuildConfig fields.
|
||||
buildConfig = true
|
||||
// Only for the bench build type's resValue("string", "app_name", ...) below.
|
||||
resValues = true
|
||||
}
|
||||
packaging {
|
||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||
@@ -131,6 +141,35 @@ android {
|
||||
isMinifyEnabled = false
|
||||
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
// P0's benchmark build (docs/RUST.md, docs/DECISIONS.md's 2026-09-05 entry): release
|
||||
// optimisations so a frame time measured here means what release means everywhere else in
|
||||
// this project, its own application id so it installs beside a real enrollment rather than
|
||||
// replacing it, and FIXTURE_MODE so MainActivity opens straight onto the fixture session
|
||||
// instead of asking to be enrolled. Signed with the same key as release -- it never talks
|
||||
// to a real backend, so there is no CA of its own to mismatch, and a second keystore would
|
||||
// be one more secret to keep off this machine's shared mount for no benefit.
|
||||
create("bench") {
|
||||
initWith(getByName("release"))
|
||||
// :link (wg-app-link) has no "bench" build type of its own -- it is a library shared
|
||||
// with dev-updater and has no reason to know this project invented one -- so this says
|
||||
// which of its build types to link against instead.
|
||||
matchingFallbacks += listOf("release")
|
||||
applicationIdSuffix = ".bench"
|
||||
// "AI Sessions bench" everywhere the OS shows the app's name (launcher, recents,
|
||||
// Settings): this resValue overrides res/values/strings.xml's app_name for this
|
||||
// build type alone, and AndroidManifest.xml's android:label reads @string/app_name
|
||||
// rather than a literal so a build type can override it without touching the
|
||||
// manifest.
|
||||
resValue("string", "app_name", "AI Sessions bench")
|
||||
buildConfigField("boolean", "FIXTURE_MODE", "true")
|
||||
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
// The fixture both bench builds (this one and iris's) open with; see
|
||||
// app/bench-fixture/README.md. Read directly from its own directory rather than copied
|
||||
// into androidApp/src -- one file to keep in sync with the generator, not two.
|
||||
getByName("bench").assets.directories.add("../bench-fixture/assets")
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
the fix is a judgement about how this app should look. Drop this
|
||||
suppression when a real icon lands. -->
|
||||
<application
|
||||
android:label="AI Sessions"
|
||||
android:label="@string/app_name"
|
||||
android:allowBackup="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
tools:ignore="MissingApplicationIcon">
|
||||
|
||||
@@ -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
|
||||
* 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
|
||||
* somewhere else, but the two are not one table and must not become one -- adding a syntax role to
|
||||
* this list would silently move `ls`'s directory blue.
|
||||
* Catppuccin values so nothing on screen is a colour from somewhere else, but the two are not one
|
||||
* table -- adding a syntax role to this list would silently move `ls`'s directory blue.
|
||||
*/
|
||||
data class AnsiPalette(
|
||||
/** 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.
|
||||
*
|
||||
* 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
|
||||
* every green word. Stripping them all would be the other half-answer -- colour is often the whole
|
||||
* of what a diff, a test run or a linter is saying.
|
||||
* verbatim those are line noise in the middle of the thing being read. Stripping them all would be
|
||||
* the other half-answer -- colour is often the whole of what a diff or a test run is saying.
|
||||
*
|
||||
* So the sequences that decide how text *looks* become spans, and every other one is dropped.
|
||||
* Dropped rather than shown, because the rest move a cursor around a grid this is not: a transcript
|
||||
* is a scrolling document, and "go to column 40" has no meaning here that is better than nothing.
|
||||
* So the sequences that decide how text *looks* become spans, and every other one is dropped rather
|
||||
* than shown: the rest move a cursor around a grid this is not, and "go to column 40" has no
|
||||
* meaning in a scrolling document.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
// A bare carriage return rewrites the line. One before a newline is the other half
|
||||
// of a Windows line ending: it rewrites nothing, and it is dropped rather than kept,
|
||||
// since that pair is one line break and the return itself would draw as a stray
|
||||
// control character.
|
||||
// A bare carriage return rewrites the line. One before a newline is the other half of a
|
||||
// Windows line ending: it rewrites nothing, and it is dropped rather than kept, since
|
||||
// that pair is one line break.
|
||||
c == '\r' && text.getOrNull(at + 1) != '\n' -> {
|
||||
flush()
|
||||
dropLine(runs)
|
||||
@@ -82,8 +79,8 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
|
||||
}
|
||||
c == '\r' -> at++
|
||||
// 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
|
||||
// move does.
|
||||
// terminal commands. A stray bell or backspace goes for the same reason a cursor move
|
||||
// does.
|
||||
c >= ' ' || c == '\n' || c == '\t' -> {
|
||||
plain.append(c)
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* escape it was meant to remove. Three shapes -- the CSI (`ESC [ … letter`), the string escapes
|
||||
* (OSC, DCS, APC, PM) which run to a terminator, and the two-character ones.
|
||||
* not recognise would otherwise have its body printed as ordinary text. Three shapes -- the CSI
|
||||
* (`ESC [ … letter`), the string escapes which run to a terminator, and the two-character ones.
|
||||
*/
|
||||
private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int {
|
||||
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
|
||||
while (end < text.length && text[end] !in CSI_FINAL) end++
|
||||
if (end >= text.length) {
|
||||
// Cut off mid-sequence, which is what a stream that has not finished arriving
|
||||
// looks like: drop the fragment rather than printing it, and the whole sequence
|
||||
// arrives with the next delta.
|
||||
// Cut off mid-sequence, which is what a stream that has not finished arriving looks
|
||||
// like: drop the fragment rather than printing it, and the whole sequence arrives
|
||||
// with the next delta.
|
||||
text.length
|
||||
} else {
|
||||
onCsi(text.substring(at + 2, end), text[end])
|
||||
|
||||
@@ -6,26 +6,23 @@ import java.net.URL
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
// The REST half of the backend's surface (see server/src/routes.rs for the
|
||||
// table); the SSE half is EventStream.kt. All blocking network calls --
|
||||
// invoke from a background dispatcher. Each throws ApiException on failure,
|
||||
// carrying the server's own explanation where it sent one, since those
|
||||
// messages are written to be read on this screen.
|
||||
// The REST half of the backend's surface (see server/src/routes.rs for the table); the SSE half is
|
||||
// EventStream.kt. All blocking network calls -- invoke from a background dispatcher. Each throws
|
||||
// ApiException carrying the server's own explanation where it sent one, since those messages are
|
||||
// written to be read on this screen.
|
||||
|
||||
// Shared with EventStream.kt, which connects the same way but then reads
|
||||
// without a deadline.
|
||||
// Shared with EventStream.kt, which connects the same way but then reads without a deadline.
|
||||
const val CONNECT_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
|
||||
* 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
|
||||
* 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
|
||||
* other refusal is a message to show. Nothing should branch on it to decide what to *say* -- the
|
||||
* message is what says that.
|
||||
* do: a 409 from a write is "somebody else changed this, here are three ways out". Nothing should
|
||||
* branch on it to decide what to *say* -- the message is what says that.
|
||||
*/
|
||||
class ApiException(message: String, val status: Int? = null, cause: Throwable? = null) :
|
||||
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
|
||||
* 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
|
||||
* here -- an event stream has no bounded read time (see EventStream.kt).
|
||||
* here -- an event stream has no bounded read time.
|
||||
*/
|
||||
fun <T> requestFromServer(
|
||||
settings: ServerSettings,
|
||||
@@ -44,9 +41,9 @@ fun <T> requestFromServer(
|
||||
method: String = "GET",
|
||||
jsonBody: String? = null,
|
||||
/**
|
||||
* A request body written as it is produced -- the upload path. Content type, and a writer
|
||||
* handed the connection's stream. Sent chunked, since what a writer will produce is not known
|
||||
* up front and the point is that a file never sits whole in memory on this side.
|
||||
* A request body written as it is produced -- the upload path. Sent chunked, since what a
|
||||
* writer will produce is not known up front and the point is that a file never sits whole in
|
||||
* memory.
|
||||
*/
|
||||
streamBody: Pair<String, (java.io.OutputStream) -> Unit>? = null,
|
||||
readTimeoutMs: Int = READ_TIMEOUT_MS,
|
||||
@@ -87,9 +84,8 @@ fun <T> requestFromServer(
|
||||
} catch (e: ApiException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
// Surfacing the real exception (rather than one canned message for
|
||||
// every failure mode) is what lets this be diagnosed on a device
|
||||
// with no logcat access.
|
||||
// Surfacing the real exception rather than one canned message for every failure mode is
|
||||
// what lets this be diagnosed on a device with no logcat access.
|
||||
throw ApiException(
|
||||
"Couldn't reach the server at ${settings.baseUrl} " +
|
||||
"(${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 =
|
||||
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> =
|
||||
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) }
|
||||
|
||||
/** Percent-encodes a value going into a query string. */
|
||||
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
|
||||
// which of that machine's providers it runs.
|
||||
// One row of GET /sessions. A session names the machine it runs on and which of that machine's
|
||||
// providers it runs.
|
||||
data class SessionSummary(
|
||||
val id: String,
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
val setup: String,
|
||||
/** The machine's current label. This is the one to display; [setup] is never shown. */
|
||||
@@ -147,13 +136,26 @@ data class SessionSummary(
|
||||
* 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
|
||||
* own record of the conversation somewhere this app's delete does not reach. It is not a
|
||||
* promise that the file is still there, and re-importing is not a restore -- this app's
|
||||
* transcript holds things that record does not.
|
||||
* own record somewhere this app's delete does not reach. It is not a promise that the file is
|
||||
* still there, and re-importing is not a restore.
|
||||
*/
|
||||
val keepsOwnTranscript: Boolean,
|
||||
/** How much the session asks before acting; null when it was never set. */
|
||||
val permissionMode: String?,
|
||||
/**
|
||||
* How hard the model thinks, or null for the CLI's own default.
|
||||
*
|
||||
* Null is a level somebody can choose, not only one to start in -- see [EFFORT_LEVELS]. It is
|
||||
* reported rather than assumed for the same reason [permissionMode] is.
|
||||
*/
|
||||
val effort: String?,
|
||||
/**
|
||||
* Whether a thinking level does anything here -- a Claude CLI session, not a llama or echo one.
|
||||
*
|
||||
* Asked of the server rather than worked out from the provider's name, because this is a
|
||||
* property of the driver's *kind* and the phone only has the name.
|
||||
*/
|
||||
val takesEffort: Boolean,
|
||||
/**
|
||||
* Whether this continues a session the machine already had, which changes what deleting means.
|
||||
*/
|
||||
@@ -162,39 +164,73 @@ data class SessionSummary(
|
||||
* Whether this session announces itself when it wants attention.
|
||||
*
|
||||
* 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
|
||||
* on when a backend is too old to say, which matches what that backend actually does.
|
||||
* itself from a default is one you can turn off while believing you are reading it.
|
||||
*/
|
||||
val notify: Boolean,
|
||||
/**
|
||||
* Whether this session sends itself a message once its account's usage limit lifts, and what
|
||||
* that message says.
|
||||
*
|
||||
* The message is what the server would actually send, with its own default already filled in,
|
||||
* so the field shows the words rather than an empty box standing for them.
|
||||
*/
|
||||
val autoResume: Boolean,
|
||||
val autoResumeMessage: String,
|
||||
/**
|
||||
* When the server next intends to check whether the limit has lifted, in epoch seconds, or null
|
||||
* when nothing is waiting.
|
||||
*
|
||||
* A time to *ask*, not a time to resume: the server checks the meter at that moment and waits
|
||||
* again if the limit is still on. Worded that way wherever it is shown, because a promise this
|
||||
* app cannot keep is worse than no time at all.
|
||||
*/
|
||||
val resumeAt: Double?,
|
||||
/**
|
||||
* 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
|
||||
* process then starts in belongs to whatever launches it. Shown as unset rather than filled in
|
||||
* with a guess, so a reader changing it is choosing rather than confirming.
|
||||
* Null is not "the home directory": it is the session never having been told. Shown as unset
|
||||
* rather than filled in with a guess, so a reader changing it is choosing rather than
|
||||
* confirming.
|
||||
*/
|
||||
val cwd: String?,
|
||||
/**
|
||||
* How much context this session is holding, as the server last measured it -- see
|
||||
* `SessionEvent.UsageDelta`.
|
||||
* How much context this session is holding, as the server last measured it.
|
||||
*
|
||||
* 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
|
||||
* row says so in words rather than drawing an empty context for a conversation that may be
|
||||
* nearly full.
|
||||
* full.
|
||||
*/
|
||||
val contextTokens: Long?,
|
||||
/**
|
||||
* The longest edge an image should have when it reaches this session, or null where the
|
||||
* provider has no limit.
|
||||
*
|
||||
* Null and "a big number" are different answers, and only the first stays true: a provider that
|
||||
* 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 -- see `uploadPickedImage`.
|
||||
* Null and "a big number" are different answers, and only the first stays true. Decided by the
|
||||
* server because that is where a provider's kind is known.
|
||||
*/
|
||||
val maxImageEdge: Int?,
|
||||
/**
|
||||
* Which of `GET /usage`'s snapshots is about this session, and null where nothing meters it.
|
||||
*
|
||||
* The rate-limit bar answers a question about an *account*, and what decides which account --
|
||||
* if any -- is the provider this session runs, not the machine it runs on. Pairing by machine
|
||||
* alone drew the Claude CLI's five-hour window under every echo session on a machine that also
|
||||
* has the CLI: a quota that session cannot spend and could never run down. Decided by the
|
||||
* server for the same reason [maxImageEdge] is -- it is a fact about the provider's kind, and
|
||||
* this app has only its name.
|
||||
*/
|
||||
val usageProvider: String?,
|
||||
val status: String,
|
||||
val lastActivity: Double,
|
||||
/**
|
||||
* How many subagents this session has, however their own status now reads.
|
||||
*
|
||||
* A directory listing on the server rather than a status read per subagent, so the list stays
|
||||
* cheap; the per-subagent state is only fetched when the card is expanded. Zero on a server
|
||||
* that predates subagents, so this app still opens against one.
|
||||
*/
|
||||
val subagents: Int,
|
||||
)
|
||||
|
||||
private fun parseSession(session: JSONObject) =
|
||||
@@ -207,14 +243,24 @@ private fun parseSession(session: JSONObject) =
|
||||
title = session.getString("title"),
|
||||
model = session.optString("model").ifEmpty { null },
|
||||
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
||||
effort = session.optString("effort").ifEmpty { null },
|
||||
takesEffort = session.optBoolean("takesEffort", false),
|
||||
imported = session.optBoolean("imported", false),
|
||||
notify = session.optBoolean("notify", true),
|
||||
autoResume = session.optBoolean("autoResume", false),
|
||||
// The server sends its own default rather than nothing, so an empty answer means an older
|
||||
// server -- and this app's word for it is the same word.
|
||||
autoResumeMessage =
|
||||
session.optString("autoResumeMessage").ifEmpty { DEFAULT_RESUME_MESSAGE },
|
||||
resumeAt = if (session.has("resumeAt")) session.getDouble("resumeAt") else null,
|
||||
cwd = session.optString("cwd").ifEmpty { null },
|
||||
contextTokens =
|
||||
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
||||
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
||||
usageProvider = session.optString("usageProvider").ifEmpty { null },
|
||||
status = session.getString("status"),
|
||||
lastActivity = session.getDouble("lastActivity"),
|
||||
subagents = session.optInt("subagents", 0),
|
||||
)
|
||||
|
||||
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||
@@ -225,18 +271,45 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||
*
|
||||
* 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,
|
||||
* wrong for a switch, since a switch drawn from a stale row shows a position that may have been
|
||||
* changed since -- here or on another device -- and nothing on screen says which.
|
||||
* wrong for a switch, since a stale row shows a position that may have been changed since.
|
||||
*/
|
||||
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
|
||||
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
|
||||
|
||||
// What the server offers, so the spawn screen has no hardcoded lists: a
|
||||
// setup added to the server's config.ron appears here with no app rebuild.
|
||||
/**
|
||||
* One row of `GET /sessions/{id}/subagents`, oldest first.
|
||||
*
|
||||
* A subagent is a second transcript owned by a session -- no process, no controls of its own -- so
|
||||
* this carries only what a card needs to draw and to open it; see SUBAGENTS.md. [status] is
|
||||
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
|
||||
* running, and the list says so rather than reporting a state that cannot hold.
|
||||
*/
|
||||
data class SubagentSummary(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val status: String,
|
||||
val created: Double,
|
||||
val lastActivity: Double,
|
||||
)
|
||||
|
||||
fun fetchSubagents(settings: ServerSettings, sessionId: String): List<SubagentSummary> =
|
||||
requestFromServer(settings, "/sessions/$sessionId/subagents") {
|
||||
it.jsonObjects { row ->
|
||||
SubagentSummary(
|
||||
id = row.getString("id"),
|
||||
title = row.getString("title"),
|
||||
status = row.getString("status"),
|
||||
created = row.getDouble("created"),
|
||||
lastActivity = row.getDouble("lastActivity"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
|
||||
// config.ron appears here with no app rebuild.
|
||||
//
|
||||
// One list rather than two. A provider only exists on a machine that has
|
||||
// it installed, so offering machines and providers as independent choices
|
||||
// would offer pairs that cannot work.
|
||||
// One list rather than two. A provider only exists on a machine that has it installed, so offering
|
||||
// machines and providers as independent choices would offer pairs that cannot work.
|
||||
data class Provider(val name: String, val kind: String, val models: List<String>)
|
||||
|
||||
/**
|
||||
@@ -275,7 +348,7 @@ fun fetchSetups(settings: ServerSettings): List<Setup> =
|
||||
* 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
|
||||
* 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(
|
||||
val id: String,
|
||||
@@ -284,19 +357,15 @@ data class Importable(
|
||||
val modified: Double,
|
||||
val lines: Int,
|
||||
/**
|
||||
* 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 transcripts embed screenshots as base64, so
|
||||
* a single line can be a megabyte.
|
||||
* 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
|
||||
* transcripts embed screenshots as base64, so a single line can be a megabyte.
|
||||
*/
|
||||
val bytes: Long,
|
||||
/**
|
||||
* Tokens the model was holding at the last turn, or null if no turn has recorded any.
|
||||
*
|
||||
* The number that predicts what continuing this session costs. It disagrees with [bytes] in the
|
||||
* direction that matters: most of a large transcript is usually history from before a
|
||||
* compaction, which the model is no longer given.
|
||||
* 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
|
||||
* history from before a compaction, which the model is no longer given.
|
||||
*/
|
||||
val contextTokens: Long?,
|
||||
/** Whether [title] is a name somebody chose rather than the last thing said in the session. */
|
||||
@@ -305,17 +374,15 @@ data class Importable(
|
||||
* 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
|
||||
* what is running, and a session that cannot be checked is not a session that is free. The
|
||||
* server refuses an import of a "yes"; the row says so before you press it.
|
||||
* what is running. The server refuses an import of a "yes"; the row says so before you press
|
||||
* it.
|
||||
*/
|
||||
val inUse: String,
|
||||
/**
|
||||
* What this server is doing to the session right now -- "importing" or "deleting" -- or null
|
||||
* when nothing is.
|
||||
* What this server is doing to the session right now -- "importing" or "deleting" -- or null.
|
||||
*
|
||||
* 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
|
||||
* phone that was asleep or out of range never saw the events that said so.
|
||||
* for it: a phone that was asleep never saw the events that said so.
|
||||
*/
|
||||
val pending: String?,
|
||||
/**
|
||||
@@ -328,7 +395,7 @@ data class Importable(
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
data class ImportableChange(
|
||||
@@ -348,20 +415,18 @@ fun parseImportableChange(payload: String): ImportableChange? =
|
||||
message = frame.optString("message").takeIf { it.isNotEmpty() },
|
||||
)
|
||||
} catch (_: org.json.JSONException) {
|
||||
// A frame this build does not understand is not a reason to drop the stream: the listing
|
||||
// is the truth and will say what happened whatever this missed.
|
||||
// A frame this build does not understand is not a reason to drop the stream: the listing is
|
||||
// the truth and will say what happened whatever this missed.
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 —
|
||||
* which is how it came to time out against a server that was answering perfectly well. Listing
|
||||
* means reading every transcript Claude Code has ever written: about four seconds against a
|
||||
* gigabyte of them before the tunnel adds anything, and that figure grows with every session
|
||||
* 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.
|
||||
* 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 answering perfectly well. Listing means
|
||||
* reading every transcript Claude Code has ever written: about four seconds against a gigabyte of
|
||||
* them before the tunnel adds anything. A timeout is for a server that has stopped answering.
|
||||
*/
|
||||
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
|
||||
requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) {
|
||||
@@ -373,13 +438,11 @@ fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
|
||||
modified = session.optDouble("modified", 0.0),
|
||||
lines = session.optInt("lines", 0),
|
||||
bytes = session.optLong("bytes", 0L),
|
||||
// Absent means nothing has been measured -- which is not a context of zero, so
|
||||
// it stays null and the row simply does not claim a figure.
|
||||
// Absent means nothing has been measured, which is not a context of zero.
|
||||
contextTokens =
|
||||
if (session.isNull("contextTokens")) null
|
||||
else session.optLong("contextTokens").takeIf { it > 0L },
|
||||
// Absent means an older backend that cannot answer, which is exactly what
|
||||
// "unknown" says -- so the default is the honest one rather than "no".
|
||||
// Absent means an older backend that cannot answer, which is what "unknown" says.
|
||||
inUse = session.optString("inUse", "unknown"),
|
||||
named = session.optBoolean("named", false),
|
||||
pending = session.optString("pending").takeIf { it.isNotEmpty() },
|
||||
@@ -402,6 +465,12 @@ data class SshDetails(
|
||||
* Where files attached from here land on that machine; null for the session's own directory.
|
||||
*/
|
||||
val attachmentsDir: String? = null,
|
||||
/**
|
||||
* Where that machine keeps its GGUF models; null for the same place the backend keeps its own
|
||||
* (`~/.local/share/ai-app/models`, read on that machine). A llama.cpp session serves the file
|
||||
* from the machine it runs on, so this is where its models are looked for and listed.
|
||||
*/
|
||||
val modelsDir: String? = null,
|
||||
)
|
||||
|
||||
private fun SshDetails.toJson() =
|
||||
@@ -409,6 +478,7 @@ private fun SshDetails.toJson() =
|
||||
if (port != null) put("port", port)
|
||||
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
|
||||
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
|
||||
if (!modelsDir.isNullOrBlank()) put("modelsDir", modelsDir)
|
||||
}
|
||||
|
||||
/** What a machine turns out to have, without saving anything. */
|
||||
@@ -477,6 +547,8 @@ fun spawnSession(
|
||||
model: String? = null,
|
||||
cwd: String? = null,
|
||||
permissionMode: String? = null,
|
||||
/** Null for whatever the server's default is; see [fetchDefaultEffort]. */
|
||||
effort: String? = null,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
/** Continue this Claude Code session instead of starting an empty one. */
|
||||
import: String? = null,
|
||||
@@ -494,6 +566,7 @@ fun spawnSession(
|
||||
if (!model.isNullOrBlank()) put("model", model)
|
||||
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
||||
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
||||
if (!effort.isNullOrBlank()) put("effort", effort)
|
||||
if (!import.isNullOrBlank()) put("import", import)
|
||||
if (params.isNotEmpty()) {
|
||||
put("params", JSONObject(params.toMap<String, Any>()))
|
||||
@@ -528,8 +601,7 @@ fun sendMessage(
|
||||
*
|
||||
* 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
|
||||
* that id. The bubble disappearing is the success case and it arrives on the event stream, not from
|
||||
* here -- every device drops it, not only the one that tapped.
|
||||
* that id. The bubble disappearing arrives on the event stream, so every device drops it.
|
||||
*/
|
||||
fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) {
|
||||
requestFromServer(
|
||||
@@ -543,13 +615,11 @@ fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: Strin
|
||||
/**
|
||||
* 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
|
||||
* path accepted here would surface much later, as a session that would not start, with nothing
|
||||
* pointing at the typo.
|
||||
* The server checks the directory is there and refuses if it is not -- a mistyped path accepted
|
||||
* here would surface much later, as a session that would not start.
|
||||
*
|
||||
* 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
|
||||
* session with no process everywhere else.
|
||||
* The next thing said to the session starts it again in the new one.
|
||||
*/
|
||||
fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) {
|
||||
requestFromServer(
|
||||
@@ -593,8 +663,8 @@ fun uploadAttachment(
|
||||
write(out)
|
||||
out.write(tail)
|
||||
},
|
||||
// Long: a trace is hundreds of megabytes, and the server copies it on to a remote
|
||||
// machine before answering.
|
||||
// Long: a trace is hundreds of megabytes, and the server copies it on to a remote machine
|
||||
// before answering.
|
||||
readTimeoutMs = 600000,
|
||||
) { connection ->
|
||||
connection.jsonObject().getString("id")
|
||||
@@ -605,8 +675,7 @@ fun uploadAttachment(
|
||||
* 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]
|
||||
* still says it is one. Neither is worked out here -- the machine answers both, because it is the
|
||||
* only thing that can.
|
||||
* still says it is one. Neither is worked out here -- the machine answers both.
|
||||
*/
|
||||
data class DirEntry(
|
||||
val name: String,
|
||||
@@ -625,10 +694,10 @@ data class Listing(val path: String, val entries: List<DirEntry>)
|
||||
/**
|
||||
* What reading a file produced.
|
||||
*
|
||||
* Four cases, because they are four different things to draw and none of them is an error the
|
||||
* screen can shrug off: content, something that is not text, something too big to have sent, and
|
||||
* (as [ApiException], not a case here) the machine's own refusal. A file with nothing in it is
|
||||
* [FileContent.Text] with an empty string -- which is what it is, and not the same as any of these.
|
||||
* Four cases, because they are four different things to draw and none is an error the screen can
|
||||
* shrug off: content, something that is not text, something too big to have sent, and (as
|
||||
* [ApiException]) the machine's own refusal. A file with nothing in it is [FileContent.Text] with
|
||||
* an empty string, which is what it is.
|
||||
*/
|
||||
sealed class FileContent {
|
||||
abstract val path: String
|
||||
@@ -688,9 +757,8 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}",
|
||||
// A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before
|
||||
// any of it moves. Well clear of that rather than just above it -- a timeout is for a
|
||||
// server that has stopped answering.
|
||||
// A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before any
|
||||
// of it moves. Well clear of that rather than just above it.
|
||||
readTimeoutMs = 60000,
|
||||
) { connection ->
|
||||
val body = connection.jsonObject()
|
||||
@@ -709,8 +777,7 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten
|
||||
"binary" -> FileContent.Binary(at, size, modified)
|
||||
"tooBig" -> FileContent.TooBig(at, size, modified)
|
||||
// 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
|
||||
// never seen.
|
||||
// the nearest of the four would draw something confident about a state never seen.
|
||||
else ->
|
||||
throw ApiException(
|
||||
"The server described this file as \"$kind\", which this app does not know how to show."
|
||||
@@ -721,9 +788,8 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten
|
||||
/**
|
||||
* 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
|
||||
* what the conflict dialog shows -- an agent editing the same file while somebody reads it is the
|
||||
* ordinary case here, not the exotic one.
|
||||
* The refusal is a 409 carrying the server's wording, which is what the conflict dialog shows -- an
|
||||
* agent editing the same file while somebody reads it is the ordinary case here.
|
||||
*/
|
||||
fun writeFile(
|
||||
settings: ServerSettings,
|
||||
@@ -770,7 +836,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 =
|
||||
requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) {
|
||||
it.inputStream.readBytes()
|
||||
@@ -779,10 +844,9 @@ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String):
|
||||
// One rate-limit window, rendered as a labeled bar on the usage screen.
|
||||
data class UsageWindow(
|
||||
/**
|
||||
* The API's own word for which window this is -- "session" for the five-hour one.
|
||||
*
|
||||
* How to find a particular window. The label beside it is written for a person to read, so
|
||||
* matching on it would select nothing the day its wording changes.
|
||||
* 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
|
||||
* wording changes.
|
||||
*/
|
||||
val kind: String,
|
||||
val label: String,
|
||||
@@ -801,8 +865,8 @@ data class UsageSnapshot(
|
||||
* What came back: "ok", "notLoggedIn", "unreachable" or "failed".
|
||||
*
|
||||
* 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
|
||||
* are faults worth chasing. Collapsing them made a healthy setup read as broken.
|
||||
* machine somebody chose not to put an account on -- a fact, not a fault. Collapsing them made
|
||||
* a healthy setup read as broken.
|
||||
*/
|
||||
val state: String,
|
||||
/** Why, for the two states that are faults. Absent otherwise. */
|
||||
@@ -818,8 +882,8 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
|
||||
provider = snapshot.getString("provider"),
|
||||
setup = snapshot.optString("setup"),
|
||||
setupName = snapshot.optString("setupName"),
|
||||
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok"
|
||||
// would draw an empty card as a healthy one.
|
||||
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok" would
|
||||
// draw an empty card as a healthy one.
|
||||
state = snapshot.optString("state").ifEmpty { "failed" },
|
||||
detail = snapshot.optString("detail").ifEmpty { null },
|
||||
windows =
|
||||
@@ -840,8 +904,7 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
|
||||
* 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
|
||||
* special case of it. What a provider makes of several answers is its own business and is decided
|
||||
* on the server; nothing here joins, splits or reformats them for one.
|
||||
* special case of it. What a provider makes of several answers is decided on the server.
|
||||
*/
|
||||
fun answerQuestion(
|
||||
settings: ServerSettings,
|
||||
@@ -868,9 +931,8 @@ fun interruptSession(settings: ServerSettings, sessionId: String) {
|
||||
/**
|
||||
* 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]
|
||||
* picks it back up. The server reports what it could not do -- there was nothing running, or the
|
||||
* machine would not say whether there was -- rather than answering the same way either way.
|
||||
* Not a delete and not an interrupt: the conversation stays where it is and [startSession] picks it
|
||||
* back up. The server reports what it could not do rather than answering the same way either way.
|
||||
*/
|
||||
fun stopSession(settings: ServerSettings, sessionId: String) {
|
||||
requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {}
|
||||
@@ -888,12 +950,10 @@ fun startSession(settings: ServerSettings, sessionId: String) {
|
||||
* 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
|
||||
* is each row's own state, through [fetchImportable] and the change stream. That is the point:
|
||||
* leaving the screen used to cancel the delete it had started.
|
||||
* is each row's own state. That is the point: leaving the screen used to cancel the delete.
|
||||
*
|
||||
* One request for the whole batch, which is what makes a handover all-or-nothing. Sending one per
|
||||
* row meant a batch could half-arrive -- four deleted, two never asked for -- and the two that were
|
||||
* missed looked exactly like two that had not been picked.
|
||||
* One request for the whole batch, which is what makes a handover all-or-nothing. One per row meant
|
||||
* a batch could half-arrive, and the rows that were missed looked exactly like rows not picked.
|
||||
*/
|
||||
fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) {
|
||||
requestFromServer(
|
||||
@@ -910,8 +970,7 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<S
|
||||
* 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
|
||||
* 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
|
||||
* them, for the reason [deleteImportable] gives.
|
||||
* changing -- which is what lets the screen be left.
|
||||
*/
|
||||
fun startImport(
|
||||
settings: ServerSettings,
|
||||
@@ -941,25 +1000,23 @@ fun startImport(
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
fun fetchTranscript(
|
||||
settings: ServerSettings,
|
||||
sessionId: String,
|
||||
address: TranscriptAddress,
|
||||
before: Long? = null,
|
||||
limit: Int = 80,
|
||||
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page
|
||||
// of a delta-heavy conversation is a page of the screen rather than a fraction of one message.
|
||||
// The scroll-back pager wants this; the anchor restore does not (it counts events to a known
|
||||
// seq). Ignored by the server for the newest window, where the live cursor needs real seqs.
|
||||
// See the server's `read_window`.
|
||||
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
|
||||
// a delta-heavy conversation is a page of the screen rather than a fraction of one message. The
|
||||
// scroll-back pager wants this; the anchor restore does not. Ignored by the server for the
|
||||
// newest window, where the live cursor needs real seqs.
|
||||
coalesce: Boolean = false,
|
||||
// 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 at a seq inside its own
|
||||
// delta run. Exclusive, like the SSE route's cursor. See TranscriptCache and `read_window`.
|
||||
// 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 {
|
||||
@@ -968,11 +1025,10 @@ fun fetchTranscript(
|
||||
if (coalesce) append("&coalesce=true")
|
||||
if (after != null) append("&after=").append(after)
|
||||
}
|
||||
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
|
||||
return requestFromServer(settings, "/${address.urlPath}/transcript$query") { connection ->
|
||||
val body = JSONArray(connection.inputStream.bufferedReader().readText())
|
||||
// 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 -- a second entry point differing only in
|
||||
// return type would be two answers to one question.
|
||||
// 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)
|
||||
@@ -986,7 +1042,7 @@ fun fetchTranscript(
|
||||
* 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
|
||||
* 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) {
|
||||
requestFromServer(
|
||||
@@ -1017,6 +1073,56 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String)
|
||||
*/
|
||||
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
||||
|
||||
/**
|
||||
* What a new session's thinking level is when nothing chose one, or null for the CLI's own.
|
||||
*
|
||||
* Held by the server rather than by this phone, because a second device would otherwise spawn
|
||||
* sessions at a level the first one's owner never picked.
|
||||
*/
|
||||
fun fetchDefaultEffort(settings: ServerSettings): String? =
|
||||
requestFromServer(settings, "/defaults") {
|
||||
it.jsonObject().optString("effort").ifEmpty { null }
|
||||
}
|
||||
|
||||
/** Sets what new sessions start at. Nothing already running changes. */
|
||||
fun setDefaultEffort(settings: ServerSettings, level: String?) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/defaults",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* How hard the model thinks, as `claude --effort` takes them, cheapest first.
|
||||
*
|
||||
* Not offered alongside the model and the permission mode on the session's own bar, because it does
|
||||
* not behave like them: the CLI has a control request for those two and none for this (checked
|
||||
* against 2.1.258), so a level is settled when the process is launched. Changing it therefore stops
|
||||
* the process, which is what the working directory beside it in this dialog does, and why it is
|
||||
* here rather than on a bar whose other controls take effect mid-turn.
|
||||
*/
|
||||
val EFFORT_LEVELS = listOf("low", "medium", "high", "xhigh", "max")
|
||||
|
||||
/** What the picker shows, and sends as null, for a session that has chosen no level. */
|
||||
const val DEFAULT_EFFORT = "default"
|
||||
|
||||
/**
|
||||
* Records how hard a session thinks and **stops its process**, since the level is read when the
|
||||
* process is launched. The next message, or Start, runs one that has it.
|
||||
*
|
||||
* [level] is null for the CLI's own default.
|
||||
*/
|
||||
fun setSessionEffort(settings: ServerSettings, sessionId: String, level: String?) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/sessions/$sessionId/effort",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
|
||||
) {}
|
||||
}
|
||||
|
||||
/** Switches how much a running session asks before acting, also in place. */
|
||||
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
|
||||
requestFromServer(
|
||||
@@ -1028,6 +1134,36 @@ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode:
|
||||
}
|
||||
|
||||
/** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */
|
||||
/**
|
||||
* What an auto-resume says when nothing else was typed. Mirrors the server's own default, so a
|
||||
* cleared field shows the word that would actually be sent instead of going blank.
|
||||
*/
|
||||
const val DEFAULT_RESUME_MESSAGE = "continue"
|
||||
|
||||
/**
|
||||
* Turns auto-resume on or off and sets what it would say, in one request because they are one
|
||||
* decision -- see the server's `/sessions/{id}/auto-resume`.
|
||||
*/
|
||||
fun setSessionAutoResume(
|
||||
settings: ServerSettings,
|
||||
sessionId: String,
|
||||
autoResume: Boolean,
|
||||
message: String?,
|
||||
) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/sessions/$sessionId/auto-resume",
|
||||
method = "POST",
|
||||
jsonBody =
|
||||
JSONObject()
|
||||
.put("autoResume", autoResume)
|
||||
// Empty means the server's default rather than a session poked with nothing to
|
||||
// read, which is the same rule the server applies to the field.
|
||||
.put("message", message?.trim()?.ifEmpty { null } ?: JSONObject.NULL)
|
||||
.toString(),
|
||||
) {}
|
||||
}
|
||||
|
||||
fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
@@ -1078,10 +1214,9 @@ fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Bo
|
||||
requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {}
|
||||
}
|
||||
|
||||
// Models: what this backend has downloaded, what it is downloading, and
|
||||
// what HuggingFace offers. Browsing is proxied by the server rather than
|
||||
// done here, because this app trusts exactly one certificate and has no
|
||||
// general internet trust to spend on huggingface.co.
|
||||
// Models: what this backend has downloaded, what it is downloading, and what HuggingFace offers.
|
||||
// Browsing is proxied by the server rather than done here, because this app trusts exactly one
|
||||
// certificate and has no general internet trust to spend on huggingface.co.
|
||||
|
||||
data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long)
|
||||
|
||||
@@ -1119,6 +1254,26 @@ private fun parseDownload(o: JSONObject) =
|
||||
error = if (o.has("error")) o.getString("error") else null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The models on one machine, which is the list a llama.cpp session there can choose from.
|
||||
*
|
||||
* Not [fetchModels], which is what the *backend* has downloaded. A session serves its model from
|
||||
* the machine it runs on, so for a machine reached over ssh those are two different lists -- and
|
||||
* offering the backend's would name files that are not there, turning a choice that cannot work
|
||||
* into a session that fails when it tries to load one.
|
||||
*/
|
||||
fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel> =
|
||||
requestFromServer(settings, "/setups/${setupId.urlEncoded()}/models") { connection ->
|
||||
JSONArray(connection.inputStream.bufferedReader().readText()).mapObjects { m ->
|
||||
LocalModel(
|
||||
key = m.getString("key"),
|
||||
repo = m.getString("repo"),
|
||||
file = m.getString("file"),
|
||||
bytes = m.getLong("bytes"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchModels(settings: ServerSettings): Models =
|
||||
requestFromServer(settings, "/models") { connection ->
|
||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -29,24 +31,36 @@ import kotlinx.coroutines.withContext
|
||||
* 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.
|
||||
*
|
||||
* Import, models and setups are not here any more. They are tabs inside [MainScreen] -- four views
|
||||
* of the same backend, none of them a step down from another -- and what is left in this `when` is
|
||||
* only what genuinely is a step down: one session, spawning one, and settings. A session's own
|
||||
* settings are not among them: they are a dialog over the session, which is where the thing they
|
||||
* change is.
|
||||
* Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
|
||||
* them a step down from another -- and what is left here is only what genuinely is a step down: one
|
||||
* session, spawning one, and settings.
|
||||
*/
|
||||
private sealed class Screen {
|
||||
data object Main : Screen()
|
||||
/**
|
||||
* The session list, with a subagent's own transcript over it when [subagent] is set.
|
||||
*
|
||||
* A layer on this screen rather than a screen of its own, for the same reason [Session.files]
|
||||
* is: [SessionListScreen] owns which cards are expanded and what each expansion fetched, kept
|
||||
* in `remember`, and a subagent is opened from a card's expander. As a sibling `Screen` it was
|
||||
* disposed and recreated on every return, which lost that state -- an expanded card collapsed
|
||||
* itself the moment its own subagent's view was closed.
|
||||
*/
|
||||
data class Main(val subagent: SubagentTarget? = null) : Screen()
|
||||
|
||||
/**
|
||||
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
|
||||
* SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
|
||||
* is opened from the session list's card rather than from inside the session it belongs to.
|
||||
*/
|
||||
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
|
||||
|
||||
/**
|
||||
* One session, with the file explorer over it when [files] is set.
|
||||
*
|
||||
* 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,
|
||||
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and
|
||||
* re-created on every return, refetching the transcript over the tunnel -- which is exactly the
|
||||
* 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.
|
||||
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re-
|
||||
* created on every return, refetching the transcript over the tunnel.
|
||||
*/
|
||||
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
|
||||
|
||||
@@ -60,7 +74,7 @@ private sealed class Screen {
|
||||
*
|
||||
* 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
|
||||
* would otherwise compare equal -- see MainActivity, which counts them.
|
||||
* would otherwise compare equal.
|
||||
*/
|
||||
data class SessionOpenRequest(val sessionId: String, val serial: Int)
|
||||
|
||||
@@ -68,8 +82,8 @@ data class SessionOpenRequest(val sessionId: String, val serial: Int)
|
||||
private data class FailedOpen(val request: SessionOpenRequest, val message: String)
|
||||
|
||||
/**
|
||||
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity),
|
||||
* re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
|
||||
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), re-
|
||||
* 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.
|
||||
*
|
||||
@@ -85,12 +99,12 @@ fun AppRoot(
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
||||
var screen by remember { mutableStateOf<Screen>(Screen.Main) }
|
||||
var screen by remember { mutableStateOf<Screen>(Screen.Main()) }
|
||||
// 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.
|
||||
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
||||
// Bumped whenever another screen changes something the list shows, so
|
||||
// returning to it refetches instead of showing a stale list.
|
||||
// Bumped whenever another screen changes something the list shows, so returning to it
|
||||
// refetches.
|
||||
var reloadToken by remember { mutableIntStateOf(0) }
|
||||
// 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.
|
||||
@@ -100,13 +114,12 @@ fun AppRoot(
|
||||
share = shareRequest
|
||||
// A session already open takes it. Otherwise the list is where the choice is made,
|
||||
// whatever screen was showing: Spawn and Settings have nowhere to put a file.
|
||||
if (screen !is Screen.Session) screen = Screen.Main
|
||||
if (screen !is Screen.Session) screen = Screen.Main()
|
||||
}
|
||||
}
|
||||
|
||||
// A standing condition rather than a per-request failure, so it is
|
||||
// stated once here instead of appended to every error that might be
|
||||
// caused by it. Without this the app is simply unreachable and every
|
||||
// A standing condition rather than a per-request failure, so it is stated once here instead of
|
||||
// appended to every error it might cause. Without this the app is simply unreachable and every
|
||||
// screen blames the server or the tunnel for it.
|
||||
if (!localNetworkAllowed(context)) {
|
||||
Text(
|
||||
@@ -121,14 +134,14 @@ fun AppRoot(
|
||||
|
||||
val current = settings
|
||||
if (current == null) {
|
||||
// Not enrolled yet: settings is the only usable screen. The QR
|
||||
// path lands in MainActivity and recomposes from the top.
|
||||
// Not enrolled yet: settings is the only usable screen. The QR path lands in MainActivity
|
||||
// and recomposes from the top.
|
||||
Box(Modifier.imePadding()) {
|
||||
SettingsScreen(
|
||||
existing = null,
|
||||
onSaved = { saved ->
|
||||
settings = saved
|
||||
screen = Screen.Main
|
||||
screen = Screen.Main()
|
||||
},
|
||||
onBack = null,
|
||||
)
|
||||
@@ -136,21 +149,20 @@ fun AppRoot(
|
||||
return
|
||||
}
|
||||
|
||||
// The one way back, whichever screen is showing and whether it was
|
||||
// reached by the system back gesture or a screen's own Back button.
|
||||
// Every leaf screen can have changed something the list shows, so it
|
||||
// always refetches.
|
||||
// The one way back, whichever screen is showing and whether it was reached by the system back
|
||||
// gesture or a screen's own Back button. Every leaf screen can have changed something the list
|
||||
// shows, so it always refetches.
|
||||
val goToMain = {
|
||||
reloadToken++
|
||||
screen = Screen.Main
|
||||
screen = Screen.Main()
|
||||
}
|
||||
if (screen !is Screen.Main) {
|
||||
BackHandler(onBack = goToMain)
|
||||
}
|
||||
|
||||
// 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
|
||||
// snapshot the list already fetched, there is nothing here to seed it from.
|
||||
// first, because that is what SessionScreen is given -- and unlike a list row, there is nothing
|
||||
// here to seed it from.
|
||||
//
|
||||
// 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.
|
||||
@@ -180,10 +192,9 @@ fun AppRoot(
|
||||
)
|
||||
}
|
||||
|
||||
// Every screen but the session takes the keyboard as bottom padding here. The session
|
||||
// screen deliberately does not: resizing a whole screen on every frame of the keyboard
|
||||
// animation is the cost that made it lag, so it moves only its composer and transcript --
|
||||
// see the layout note in SessionScreen.
|
||||
// Every screen but the session takes the keyboard as bottom padding here. The session screen
|
||||
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is
|
||||
// the cost that made it lag, so it moves only its composer and transcript.
|
||||
when (val here = screen) {
|
||||
is Screen.Main ->
|
||||
Box(Modifier.imePadding()) {
|
||||
@@ -192,6 +203,9 @@ fun AppRoot(
|
||||
reloadToken = reloadToken,
|
||||
share = share,
|
||||
onOpen = { screen = Screen.Session(it) },
|
||||
onOpenSubagent = { summary, subagent ->
|
||||
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
|
||||
},
|
||||
onSpawn = { screen = Screen.Spawn },
|
||||
onImported = { imported ->
|
||||
reloadToken++
|
||||
@@ -199,18 +213,38 @@ fun AppRoot(
|
||||
},
|
||||
onSettings = { screen = Screen.Settings },
|
||||
)
|
||||
// Its own back handler is registered after MainScreen's, so it is the one the
|
||||
// platform asks first while a subagent is open -- the same rule the files
|
||||
// explorer's handler follows over its session, below.
|
||||
here.subagent?.let { target ->
|
||||
BackHandler { screen = here.copy(subagent = null) }
|
||||
// Its own opaque background: this screen was always the sole content under
|
||||
// the theme's own Surface before, so it never had to paint one -- stacked over
|
||||
// the list here, the space between its own cards let the list underneath show
|
||||
// through without this. The same fix FilesScreen needed over its session.
|
||||
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
key(target.summary.id, target.subagent.id) {
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
summary = target.summary,
|
||||
onBack = { screen = here.copy(subagent = null) },
|
||||
onFiles = {},
|
||||
subagent = target.subagent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is Screen.Session ->
|
||||
// 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
|
||||
// draft and a scroll position, and without the key Compose keeps all of it across the
|
||||
// change and merges two conversations -- which crashes the list on the first duplicate
|
||||
// row 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.
|
||||
// one showing other rows. SessionScreen remembers a transcript, an open stream, a draft
|
||||
// and a scroll position, and without the key Compose keeps all of it across the change
|
||||
// and merges two conversations -- which crashes the list on the first duplicate row
|
||||
// key. Only reachable since a notification can move straight from one session to
|
||||
// another.
|
||||
key(here.summary.id) {
|
||||
// A Box so the explorer can be drawn *over* the session rather than instead of
|
||||
// it; the session stays composed underneath. No imePadding here, for the reason
|
||||
// above -- the explorer adds its own, since it has a text field.
|
||||
// A Box so the explorer can be drawn *over* the session rather than instead of it.
|
||||
// No imePadding here, for the reason above -- the explorer adds its own.
|
||||
Box {
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
@@ -257,8 +291,7 @@ fun AppRoot(
|
||||
|
||||
// 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
|
||||
// wanting attention is not a fact about the page somebody happens to be on, so it is not the
|
||||
// page's job to leave room for it. Tapping one is the same act as tapping a notification, so
|
||||
// it goes through the same `open`, failure dialog included.
|
||||
// wanting attention is not a fact about the page somebody happens to be on. Tapping one is the
|
||||
// same act as tapping a notification, so it goes through the same `open`.
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* come back as an event, so on a phone the card sat unchanged for most of a second after a tap and
|
||||
* the natural response was to tap again.
|
||||
* broken: the mark used to appear only when the answer had crossed the tunnel and come back as an
|
||||
* event, so the card sat unchanged for most of a second after a tap.
|
||||
*
|
||||
* 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
|
||||
* away and picking puts the words away, and there is never a draft that means two things.
|
||||
* parts: typing puts the picks away and picking puts the words away, so there is never a draft that
|
||||
* means two things.
|
||||
*/
|
||||
data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
|
||||
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.
|
||||
*
|
||||
* All of it comes from the question events themselves -- what each option means, what picking it
|
||||
* would produce, whether several may be picked at once. None of it is read out of the call's own
|
||||
* All of it comes from the question events themselves. 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
|
||||
* 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
|
||||
* 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
|
||||
* the whole of any one of them. Paged, each question is a screen and the count says how many are
|
||||
* 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.
|
||||
* scrolls past the question they are answering to reach the button that sends it. Paged, each
|
||||
* question is a screen and the count says how many are left.
|
||||
*
|
||||
* 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
|
||||
* reader could not change their mind about the first after reading the third.
|
||||
* asked them together, and sending each as it was tapped meant the reader could not change their
|
||||
* mind about the first after reading the third.
|
||||
*/
|
||||
@Composable
|
||||
fun AskUserQuestionBody(
|
||||
asks: List<TranscriptItem.QuestionCard>,
|
||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||
) {
|
||||
// Seeded from what was already answered, so a card the reader comes back to shows their
|
||||
// answers rather than an empty draft over them.
|
||||
// Seeded from what was already answered, so a card the reader comes back to shows their answers
|
||||
// rather than an empty draft over them.
|
||||
var drafts by
|
||||
remember(asks.map { it.id }) {
|
||||
mutableStateOf(
|
||||
@@ -125,7 +121,7 @@ fun AskUserQuestionBody(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// 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) {
|
||||
Chevron(Pointing.Left, colour = LocalContentColor.current)
|
||||
}
|
||||
@@ -143,7 +139,7 @@ fun AskUserQuestionBody(
|
||||
if (outstanding.isNotEmpty()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
// 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.
|
||||
val ready = outstanding.all { drafts[it.id]?.settled == true }
|
||||
Button(
|
||||
@@ -155,8 +151,8 @@ fun AskUserQuestionBody(
|
||||
}
|
||||
) {
|
||||
// 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
|
||||
// that never stops would be the only sign of a failure this card cannot
|
||||
// around this, and the draft is still here to send again -- a spinner that
|
||||
// never stops would be the only sign of a failure this card cannot
|
||||
// describe.
|
||||
sending = false
|
||||
}
|
||||
@@ -165,8 +161,8 @@ fun AskUserQuestionBody(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (sending) {
|
||||
// In the button rather than beside it, so the row does not change height at
|
||||
// the moment it is pressed.
|
||||
// In the button rather than beside it, so the row does not change height at the
|
||||
// moment it is pressed.
|
||||
CircularProgressIndicator(
|
||||
Modifier.height(18.dp).width(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
@@ -186,8 +182,7 @@ fun AskUserQuestionBody(
|
||||
* 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
|
||||
* when nothing did. A question is the same thing either way, and two renderings of it would be two
|
||||
* places for an answer to go missing.
|
||||
* when nothing did. Two renderings of it would be two 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
|
||||
* 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
|
||||
// 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
|
||||
// "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()
|
||||
// What is marked: what was answered once there is an answer, and what the finger has
|
||||
// chosen until then.
|
||||
// What is marked: what was answered once there is an answer, and what the finger has chosen
|
||||
// until then.
|
||||
val marked = if (answered) ask.answers.toSet() else draft.picked
|
||||
// Null once the question is answered: the options stay and stop being pressable.
|
||||
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
|
||||
// [OtherAnswer]. Only ever the answers that match nothing offered, so a question answered
|
||||
// by picking says it by the mark alone.
|
||||
// What was answered in the reader's own words, which no option can mark. Only ever the
|
||||
// answers that match nothing offered, so a question answered by picking says it by the
|
||||
// mark.
|
||||
val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } }
|
||||
if (inWords.isNotEmpty()) {
|
||||
Text(
|
||||
@@ -252,10 +247,8 @@ fun AskedQuestion(
|
||||
}
|
||||
|
||||
/**
|
||||
* [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].
|
||||
* [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].
|
||||
*/
|
||||
private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
|
||||
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
|
||||
* 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
|
||||
* is one cue and it is unambiguous.
|
||||
* should have been. A border is one cue and it is unambiguous.
|
||||
*/
|
||||
@Composable
|
||||
private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) {
|
||||
@@ -324,8 +316,8 @@ private fun Preview(preview: String) {
|
||||
preview,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines
|
||||
// of the thing being previewed.
|
||||
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines of
|
||||
// the thing being previewed.
|
||||
softWrap = false,
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* cannot tell that it was ever open.
|
||||
* rather than pick. Leaving it out narrows a question that was never that narrow.
|
||||
*/
|
||||
@Composable
|
||||
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
|
||||
* 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
|
||||
* way for a list of choices to be wrong.
|
||||
* That reads as those having been the only choices.
|
||||
*/
|
||||
@Composable
|
||||
fun AnswerOptions(
|
||||
@@ -379,8 +369,8 @@ fun AnswerOptions(
|
||||
OutlinedButton(
|
||||
onClick = { onPick?.invoke(option.label) },
|
||||
// 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
|
||||
// take the mark with it -- both are stated here instead.
|
||||
// offered. Material dims a disabled button's own border and label, which would take
|
||||
// the mark with it -- both are stated here instead.
|
||||
enabled = onPick != null,
|
||||
border =
|
||||
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
|
||||
* file is not fetched -- there is nothing on this phone to open a trace or a log with -- so the
|
||||
* name is the whole of it.
|
||||
* file is not fetched -- there is nothing on this phone to open a trace with -- so the name is all
|
||||
* of it.
|
||||
*/
|
||||
@Composable
|
||||
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
|
||||
* is identified by both ends -- what it is at the front, what kind at the back -- and either
|
||||
* ellipsis alone takes away one of them.
|
||||
* is identified by both ends -- what it is at the front, what kind at the back.
|
||||
*/
|
||||
@Composable
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* the server, per session -- see `DriverKind::max_image_edge` -- because that is where a provider's
|
||||
* requirements are known, and a phone that carried its own copy of them would be a second place to
|
||||
* update when one changes.
|
||||
* expensive part on a phone is the upload, not the decode. What the limit *is* comes from the
|
||||
* server, per session, because that is where a provider's requirements are known.
|
||||
*/
|
||||
suspend fun uploadPickedImage(
|
||||
context: Context,
|
||||
@@ -53,17 +51,16 @@ suspend fun uploadPicked(
|
||||
if (mime != null && mime.startsWith("image/")) {
|
||||
return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
|
||||
}
|
||||
// Opened before the request starts, so a provider that refuses says so here and not from
|
||||
// inside the connection; then streamed, since a trace or a log is bigger than this process
|
||||
// should hold at once.
|
||||
// Opened before the request starts, so a provider that refuses says so here and not from inside
|
||||
// the connection; then streamed, since a trace is bigger than this process should hold at once.
|
||||
val source = openSource(resolver, uri)
|
||||
val name = displayName(resolver, uri)
|
||||
return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out ->
|
||||
try {
|
||||
source.use { it.copyTo(out, COPY_BUFFER) }
|
||||
} catch (e: java.io.IOException) {
|
||||
// Either side of the copy can fail; the message names the file, which is the
|
||||
// part the reader can do something about.
|
||||
// Either side of the copy can fail; the message names the file, which is the part the
|
||||
// reader can do something about.
|
||||
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
|
||||
* 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 =
|
||||
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.
|
||||
*
|
||||
* An image already inside the limit is uploaded exactly as it came, rather than decoded and
|
||||
* re-encoded to the same size: a round trip through JPEG loses a little every time, and there is
|
||||
* nothing to gain from it. This is also the path a provider with no limit always takes.
|
||||
* An image already inside the limit is uploaded exactly as it came, rather than decoded and re-
|
||||
* encoded to the same size: a round trip through JPEG loses a little every time. This is also the
|
||||
* path a provider with no limit always takes.
|
||||
*/
|
||||
private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteArray, String> {
|
||||
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.
|
||||
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
|
||||
// the full twelve megapixels only to shrink it is how this runs out of memory on the images
|
||||
// it most needs to handle.
|
||||
// Powers of two first, which is all the decoder can do, and then the exact scale. Decoding the
|
||||
// full twelve megapixels only to shrink it is how this runs out of memory on the images it most
|
||||
// needs to handle.
|
||||
val decode =
|
||||
BitmapFactory.Options().apply {
|
||||
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()
|
||||
if (scale < 1f) matrix.postScale(scale, scale)
|
||||
// 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
|
||||
// nothing anywhere saying so. Applied to the same matrix as the scale, so it costs no second
|
||||
// copy of the bitmap.
|
||||
// re-encoding drops the tag -- so a portrait photo would arrive at the model on its side.
|
||||
// Applied to the same matrix as the scale, so it costs no second copy of the bitmap.
|
||||
matrix.postRotate(exifRotation(original))
|
||||
val scaled = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
|
||||
val out = ByteArrayOutputStream()
|
||||
@@ -166,8 +162,8 @@ private fun exifRotation(bytes: ByteArray): Float =
|
||||
else -> 0f
|
||||
}
|
||||
} catch (_: java.io.IOException) {
|
||||
// No EXIF, or none this can read. Upright is the assumption every
|
||||
// image without the tag is displayed under anyway.
|
||||
// No EXIF, or none this can read. Upright is the assumption every image without the tag is
|
||||
// displayed under anyway.
|
||||
0f
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.content.Context
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* P0's benchmark gate (see docs/RUST.md and docs/DECISIONS.md's 2026-09-05 entry): an in-process
|
||||
* fake of the backend, so the `bench` build type can drive a real session screen -- the real
|
||||
* [TranscriptSource], the real fold, the real paging -- with no server and no network permission.
|
||||
*
|
||||
* Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else
|
||||
* in this build compiles it in but never calls it, since Kotlin has no per-build-type source set
|
||||
* that both [MainActivity] (which every variant compiles) and this can share without one.
|
||||
*
|
||||
* The design: [requestFromServer] and [Sse] talk to `https://$FIXTURE_HOST:$FIXTURE_PORT` through
|
||||
* ordinary `java.net.URL`, exactly as they would talk to a real server. A
|
||||
* [java.net.URLStreamHandlerFactory] registered once for the whole process intercepts every
|
||||
* `https://` connection to that host and answers from this object's in-memory event log instead of
|
||||
* opening a socket -- see BenchNetwork.kt. Everything above that (TranscriptSource, SessionScreen,
|
||||
* the fold, uniqueItems) never learns the difference.
|
||||
*/
|
||||
object BenchFixture {
|
||||
const val FIXTURE_HOST = "bench.fixture.invalid"
|
||||
const val FIXTURE_PORT = 1
|
||||
|
||||
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
|
||||
private const val BACKLOG_COUNT = 3200
|
||||
|
||||
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
|
||||
|
||||
/** The session id every bench run opens; nothing else in this build ever mints one. */
|
||||
const val SESSION_ID = "bench-fixture-session"
|
||||
|
||||
/**
|
||||
* The whole transcript, seq order, growing as [pushLive] is called during the streaming phase.
|
||||
* Read by both the REST page handler and the SSE handler, so a page requested mid- stream and a
|
||||
* live frame agree on what has "already happened" -- the same thing a real server's own
|
||||
* transcript file guarantees.
|
||||
*/
|
||||
private val log = CopyOnWriteArrayList<Pair<String, SeqEvent>>()
|
||||
|
||||
/** The events not yet appended to [log] -- the streaming phase's own source. */
|
||||
private var streamTail: List<Pair<String, SeqEvent>> = emptyList()
|
||||
|
||||
private val images = mutableMapOf<String, ByteArray>()
|
||||
|
||||
@Volatile private var loaded = false
|
||||
|
||||
/**
|
||||
* Parses the bundled fixture once. Safe to call more than once; only the first does anything.
|
||||
*/
|
||||
@Synchronized
|
||||
fun ensureLoaded(context: Context) {
|
||||
if (loaded) return
|
||||
val lines =
|
||||
context.assets.open("transcript.jsonl").bufferedReader().readLines().filter {
|
||||
it.isNotBlank()
|
||||
}
|
||||
val parsed = lines.map { it to parseSeqEvent(it) }
|
||||
log.addAll(parsed.take(BACKLOG_COUNT))
|
||||
streamTail = parsed.drop(BACKLOG_COUNT)
|
||||
for (name in listOf("bench1.png", "bench2.png")) {
|
||||
images[name] = context.assets.open(name).readBytes()
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
|
||||
/** The events the streaming phase has left to send. */
|
||||
fun remainingStreamEvents(): Int = streamTail.size
|
||||
|
||||
/** Sends the next fixture event onto the live log, as a real SSE frame would arrive. */
|
||||
fun pushNextLiveEvent(): Boolean {
|
||||
val next = streamTail.firstOrNull() ?: return false
|
||||
streamTail = streamTail.drop(1)
|
||||
log.add(next)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Undoes [pushNextLiveEvent] and reloads the opening backlog, for running the bench twice. */
|
||||
@Synchronized
|
||||
fun resetToBacklog(context: Context) {
|
||||
loaded = false
|
||||
log.clear()
|
||||
ensureLoaded(context)
|
||||
}
|
||||
|
||||
fun fileBytes(name: String): ByteArray? = images[name]
|
||||
|
||||
/**
|
||||
* Raw JSON lines with seq > [after], in order -- what an `/events?after=` connection replays.
|
||||
*/
|
||||
fun linesAfter(after: Long): List<String> =
|
||||
log.filter { it.second.seq > after }.map { it.first }
|
||||
|
||||
/**
|
||||
* One REST page: [fetchTranscript]'s `before`/`limit`/`after`, against the growing log. Ignores
|
||||
* `coalesce` -- the fixture's own deltas are already split the way a real reply streams, and
|
||||
* what the benchmark exercises is the fold and the paging, not the server's row-joining, which
|
||||
* client-core's own port tracks separately (CLIENT_CORE.md).
|
||||
*/
|
||||
fun page(before: Long?, limit: Int, after: Long?): List<String> {
|
||||
val upper = before ?: (log.lastOrNull()?.second?.seq?.plus(1) ?: 1L)
|
||||
val candidates = log.filter {
|
||||
it.second.seq < upper && (after == null || it.second.seq > after)
|
||||
}
|
||||
return candidates.takeLast(limit).map { it.first }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.PipedInputStream
|
||||
import java.io.PipedOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLStreamHandler
|
||||
import java.net.URLStreamHandlerFactory
|
||||
import java.security.Principal
|
||||
import java.security.cert.Certificate
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLPeerUnverifiedException
|
||||
import org.json.JSONArray
|
||||
|
||||
/**
|
||||
* Installs the process-wide interception [BenchFixture] needs. Idempotent and safe to call more
|
||||
* than once; the JDK only allows [URL.setURLStreamHandlerFactory] to be called successfully once
|
||||
* per process, and a second real call throws -- so this guards it rather than relying on every
|
||||
* caller to remember.
|
||||
*
|
||||
* Scoped to [BenchFixture.FIXTURE_HOST]: any other `https://` URL falls through to the platform's
|
||||
* ordinary handler, so this only ever changes behaviour for the one host the bench build invents.
|
||||
*/
|
||||
@Synchronized
|
||||
fun installFixtureNetworkOnce() {
|
||||
if (installed) return
|
||||
installed = true
|
||||
URL.setURLStreamHandlerFactory(
|
||||
URLStreamHandlerFactory { protocol ->
|
||||
if (protocol != "https") null
|
||||
else
|
||||
object : URLStreamHandler() {
|
||||
override fun openConnection(url: URL): HttpURLConnection =
|
||||
if (url.host == BenchFixture.FIXTURE_HOST) FixtureConnection(url)
|
||||
else
|
||||
// The bench build makes no other https call -- this factory is
|
||||
// installed only in FIXTURE_MODE (MainActivity) -- so there is
|
||||
// deliberately no delegate to a platform handler here: once a
|
||||
// URLStreamHandlerFactory is installed there is no supported way to
|
||||
// ask the JDK for its own default handler back, and re-entering this
|
||||
// same factory for the fallback would recurse forever rather than
|
||||
// reach one.
|
||||
throw java.io.IOException(
|
||||
"bench build's fixture network has no route to https host " +
|
||||
"${url.host} -- only ${BenchFixture.FIXTURE_HOST} is served"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var installed = false
|
||||
|
||||
/**
|
||||
* Answers one request against [BenchFixture] instead of opening a socket. Implements just enough of
|
||||
* [HttpsURLConnection] for [requestFromServer] and [Sse] to work unmodified: both only call
|
||||
* `connect`/`disconnect`, set a handful of request properties they never need answered, and read
|
||||
* `responseCode` and `inputStream`.
|
||||
*/
|
||||
private class FixtureConnection(url: URL) : HttpsURLConnection(url) {
|
||||
private var input: InputStream? = null
|
||||
private var writer: Thread? = null
|
||||
|
||||
override fun connect() {
|
||||
if (input != null) return
|
||||
input = route(url.path, url.query)
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
writer?.interrupt()
|
||||
try {
|
||||
input?.close()
|
||||
} catch (_: IOException) {}
|
||||
}
|
||||
|
||||
override fun usingProxy() = false
|
||||
|
||||
override fun getResponseCode(): Int {
|
||||
connect()
|
||||
return 200
|
||||
}
|
||||
|
||||
override fun getInputStream(): InputStream {
|
||||
connect()
|
||||
return input!!
|
||||
}
|
||||
|
||||
override fun getErrorStream(): InputStream? = null
|
||||
|
||||
// Nothing here reads any of these; implemented only because HttpsURLConnection declares them
|
||||
// abstract. A fixture never negotiates real TLS, so each says exactly that rather than
|
||||
// fabricating a plausible-looking certificate.
|
||||
override fun getCipherSuite() = "none (bench fixture, no TLS)"
|
||||
|
||||
override fun getLocalCertificates(): Array<Certificate>? = null
|
||||
|
||||
override fun getServerCertificates(): Array<Certificate> =
|
||||
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
|
||||
|
||||
override fun getPeerPrincipal(): Principal =
|
||||
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
|
||||
|
||||
override fun getLocalPrincipal(): Principal? = null
|
||||
|
||||
/**
|
||||
* [path] is `/sessions/{id}/...`; everything else this build's fixture is asked for is a bug.
|
||||
*/
|
||||
private fun route(path: String, query: String?): InputStream {
|
||||
val params =
|
||||
(query ?: "")
|
||||
.split("&")
|
||||
.filter { it.contains('=') }
|
||||
.associate {
|
||||
val (k, v) = it.split("=", limit = 2)
|
||||
k to java.net.URLDecoder.decode(v, "UTF-8")
|
||||
}
|
||||
return when {
|
||||
path.endsWith("/transcript") -> {
|
||||
val lines =
|
||||
BenchFixture.page(
|
||||
before = params["before"]?.toLongOrNull(),
|
||||
limit = params["limit"]?.toIntOrNull() ?: 80,
|
||||
after = params["after"]?.toLongOrNull(),
|
||||
)
|
||||
val body = JSONArray(lines.map { org.json.JSONObject(it) })
|
||||
ByteArrayInputStream(body.toString().toByteArray())
|
||||
}
|
||||
path.endsWith("/events") -> openEventsStream(params["after"]?.toLongOrNull() ?: 0L)
|
||||
path.contains("/files/") -> {
|
||||
val name = path.substringAfterLast("/files/")
|
||||
val bytes =
|
||||
BenchFixture.fileBytes(name)
|
||||
?: throw IOException("bench fixture has no file named $name")
|
||||
ByteArrayInputStream(bytes)
|
||||
}
|
||||
else -> throw IOException("bench fixture has no route for $path")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A live SSE body: [BenchFixture.linesAfter] replayed immediately, then polled every 50ms for
|
||||
* anything [BenchFixture.pushNextLiveEvent] has added since -- the same shape a real backend's
|
||||
* backlog-then-follow gives [Sse], just polled instead of woken, which is a fixture's business
|
||||
* rather than something worth a condition variable for.
|
||||
*/
|
||||
private fun openEventsStream(after: Long): InputStream {
|
||||
val pipeIn = PipedInputStream(1 shl 16)
|
||||
val pipeOut = PipedOutputStream(pipeIn)
|
||||
var sent = after
|
||||
val thread = Thread {
|
||||
try {
|
||||
while (!Thread.currentThread().isInterrupted) {
|
||||
val fresh = BenchFixture.linesAfter(sent)
|
||||
for (line in fresh) {
|
||||
pipeOut.write("data: $line\n\n".toByteArray())
|
||||
pipeOut.flush()
|
||||
sent = org.json.JSONObject(line).getLong("seq")
|
||||
}
|
||||
Thread.sleep(50)
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
// disconnect() -- the ordinary way this ends.
|
||||
} catch (_: IOException) {
|
||||
// The reader side (Sse) closed its end.
|
||||
} finally {
|
||||
try {
|
||||
pipeOut.close()
|
||||
} catch (_: IOException) {}
|
||||
}
|
||||
}
|
||||
.also {
|
||||
it.isDaemon = true
|
||||
it.start()
|
||||
}
|
||||
writer = thread
|
||||
return pipeIn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.content.Context
|
||||
import android.os.BatteryManager
|
||||
import android.os.Process
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* P0's scripted benchmark, run in-process instead of by a shell script: the phone has no usable
|
||||
* system tracing (this-machine-android's skill) and no agent can drive it, so the same scroll loop
|
||||
* and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over `ui-trace` are reproduced
|
||||
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
|
||||
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
|
||||
* [BenchFixture]'s doc comment gives.
|
||||
*/
|
||||
object BenchRun {
|
||||
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, 900px over 200ms, 500ms apart. */
|
||||
private const val CYCLES = 6
|
||||
private const val SWIPE_PX = 900f
|
||||
private const val SWIPE_MS = 200
|
||||
private const val SWIPE_PAUSE_MS = 500L
|
||||
|
||||
/** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */
|
||||
private const val STREAM_EVENTS_PER_SEC = 20
|
||||
private const val STREAM_SECONDS = 20
|
||||
|
||||
/**
|
||||
* Scrolls, then streams, then returns the extra report lines P0 asked for (CPU time, peak RSS,
|
||||
* battery current) -- [FrameStats] and [DebugStats] are reset first, exactly as
|
||||
* `copyRenderReport` resets them, so the two accountings cover the same stretch of work.
|
||||
*/
|
||||
suspend fun run(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
listState: LazyListState,
|
||||
): List<String> {
|
||||
FrameStats.reset()
|
||||
DebugStats.reset()
|
||||
val cpuStartMs = Process.getElapsedCpuTime()
|
||||
|
||||
val battery = BatterySampler(context)
|
||||
// Launched in the caller's scope rather than a fresh coroutineScope{} here, which would
|
||||
// suspend this function until the sampler job ended -- and it only ends when told to.
|
||||
val samplerJob = scope.launch {
|
||||
while (isActive) {
|
||||
battery.sample()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// The swipe loop: transcript-bench.sh's four swipes per cycle are two drags toward newer
|
||||
// content and two back, so a cycle returns to where it started and the whole loop measures
|
||||
// steady-state scrolling rather than travelling somewhere new each time.
|
||||
repeat(CYCLES) {
|
||||
repeat(2) {
|
||||
listState.animateScrollBy(SWIPE_PX, tween(SWIPE_MS))
|
||||
delay(SWIPE_PAUSE_MS)
|
||||
}
|
||||
repeat(2) {
|
||||
listState.animateScrollBy(-SWIPE_PX, tween(SWIPE_MS))
|
||||
delay(SWIPE_PAUSE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned to the newest end before streaming starts, the way stream-bench.sh's "Jump to
|
||||
// latest" tap is -- a reply streamed into a list parked further back arrives off-screen and
|
||||
// the report would show nothing happened.
|
||||
listState.scrollToItem(0)
|
||||
|
||||
var sent = 0
|
||||
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
|
||||
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
|
||||
BenchFixture.pushNextLiveEvent()
|
||||
sent++
|
||||
delay(1000L / STREAM_EVENTS_PER_SEC)
|
||||
}
|
||||
// Lets the last few deltas land and draw before the report is read.
|
||||
delay(300)
|
||||
|
||||
samplerJob.cancel()
|
||||
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
|
||||
val rssLine = peakRssLine()
|
||||
val batteryLine = battery.finish()
|
||||
|
||||
return listOf(
|
||||
" scroll: $CYCLES cycles (${CYCLES * 4} swipes), streamed $sent/$total fixture events",
|
||||
" process CPU time over this run: ${cpuMs}ms",
|
||||
rssLine,
|
||||
batteryLine,
|
||||
)
|
||||
}
|
||||
|
||||
/** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */
|
||||
private fun peakRssLine(): String {
|
||||
val kb =
|
||||
try {
|
||||
File("/proc/self/status")
|
||||
.readLines()
|
||||
.firstOrNull { it.startsWith("VmHWM:") }
|
||||
?.trim()
|
||||
?.removePrefix("VmHWM:")
|
||||
?.trim()
|
||||
?.removeSuffix("kB")
|
||||
?.trim()
|
||||
?.toLongOrNull()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
return " peak RSS: " +
|
||||
(kb?.let { "${it}kB" } ?: "unavailable (/proc/self/status unreadable)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples [BatteryManager.BATTERY_PROPERTY_CURRENT_NOW] (microamps) once a second for the length of
|
||||
* a run. The property returns `Int.MIN_VALUE` on hardware that does not support it -- most
|
||||
* emulators -- and that is reported as "unavailable" rather than folded into an average with the
|
||||
* real samples, which would silently understate every number after it. See UI_RULES: never present
|
||||
* an inferred value as a measured one.
|
||||
*/
|
||||
private class BatterySampler(context: Context) {
|
||||
private val manager = context.getSystemService(BatteryManager::class.java)
|
||||
private val samples = mutableListOf<Int>()
|
||||
|
||||
fun sample() {
|
||||
val value = manager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
|
||||
if (value != null && value != Int.MIN_VALUE) samples.add(value)
|
||||
}
|
||||
|
||||
fun finish(): String {
|
||||
if (samples.isEmpty()) return " battery current: unavailable on this device"
|
||||
val meanUa = samples.sum() / samples.size
|
||||
return " battery current: mean ${meanUa}µA over ${samples.size} samples" +
|
||||
" (min ${samples.min()}, max ${samples.max()})"
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,16 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// The composer's row of settings and pickers, and the menus they open. One file because the
|
||||
// outline and the corner are one appearance: a control shaped like this opens a surface shaped
|
||||
// like this, and a reader learns the pair once.
|
||||
// The composer's row of settings and pickers, and the menus they open. One file because the outline
|
||||
// and the corner are one appearance: a control shaped like this opens a surface shaped like this.
|
||||
|
||||
/**
|
||||
* 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
|
||||
* all until they are touched. Three bare words sitting under the message field read as a caption
|
||||
* about the field rather than as three things to press, and the only way to find out otherwise was
|
||||
* to press one. The outline says "control" without the weight of a filled button, which is reserved
|
||||
* here for the two that act on the session (send, and start/stop).
|
||||
* all until they are touched. Three bare words under the message field read as a caption about the
|
||||
* field rather than as three things to press. The outline says "control" without the weight of a
|
||||
* filled button, which is reserved for the two that act on the session.
|
||||
*/
|
||||
@Composable
|
||||
fun BubbleButton(
|
||||
@@ -32,8 +30,8 @@ fun BubbleButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
shape = BubbleShape,
|
||||
// A text button's padding rather than a filled button's 24dp: these sit three across
|
||||
// under the message field, and the wider padding is what decides whether the row fits.
|
||||
// A text button's padding rather than a filled button's 24dp: these sit three across under
|
||||
// the message field, and the wider padding is what decides whether the row fits.
|
||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
||||
modifier = modifier,
|
||||
) {
|
||||
@@ -48,7 +46,6 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50)
|
||||
* 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
|
||||
* ends that tall would bow its sides. This is the roundest corner that still leaves a straight edge
|
||||
* beside a one-line option, which is the shortest menu here.
|
||||
* ends that tall would bow its sides.
|
||||
*/
|
||||
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
|
||||
@@ -26,20 +26,16 @@ import androidx.compose.ui.unit.dp
|
||||
* of the operation over it.
|
||||
*
|
||||
* 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
|
||||
* rather than something the reader learns once.
|
||||
* the same in the import list and the session list or the appearance becomes a per-screen dialect.
|
||||
*
|
||||
* [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
|
||||
* a *word* because a spinner alone cannot say which operation this is — deleting and importing are
|
||||
* different in kind, and losing a session to the wrong one is not recoverable by waiting.
|
||||
* beside a string, which can disagree. It is a *word* because a spinner alone cannot say which
|
||||
* operation this is -- deleting and importing are different in kind.
|
||||
*
|
||||
* 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
|
||||
* had to remember — and it swallowed the drag along with the tap, which meant a list could not be
|
||||
* scrolled while anything in it was busy. Consuming taps but not drags means re-deciding what a
|
||||
* 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.
|
||||
* label. That was the other way round at first -- an overlay consuming pointer events -- and it
|
||||
* swallowed the drag along with the tap, so a list could not be scrolled while anything in it was
|
||||
* busy.
|
||||
*/
|
||||
@Composable
|
||||
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.
|
||||
*
|
||||
* Both, rather than either alone. Dimming by itself is what this app already used for a row on its
|
||||
* way out, and it is the same cue as a disabled control, so a busy row read as one more thing that
|
||||
* could not be tapped. Draining the colour is what says the row is *suspended* — the status word,
|
||||
* the accent on a warning and everything else that means something by its colour stop meaning it
|
||||
* for as long as the operation runs, which is exactly true: none of them is being kept up to date.
|
||||
* Both, rather than either alone. Dimming by itself is the same cue as a disabled control, so a
|
||||
* busy row read as one more thing that could not be tapped. Draining the colour is what says the
|
||||
* row is *suspended* -- the status word and everything else that means something by its colour stop
|
||||
* meaning it for as long as the operation runs, which is exactly true.
|
||||
*
|
||||
* Not all the way to grey. A row with no colour left is hard to find again in a list, and the
|
||||
* reader is watching this one.
|
||||
* Not all the way to grey: a row with no colour left is hard to find again in a list.
|
||||
*/
|
||||
private fun Modifier.busy(busy: Boolean): Modifier =
|
||||
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
|
||||
* 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
|
||||
* y runs from the open side to the tip, and [Pointing] is only a table of how those two map onto
|
||||
* the box.
|
||||
* direction. The shape is written once in its own coordinates, and [Pointing] is only a table of
|
||||
* how those map onto the box.
|
||||
*
|
||||
* It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole
|
||||
* of what assistive technology has to go on, and it is also the answer to "what was that arrow for"
|
||||
* six months from now.
|
||||
* It draws no label of its own, so every caller owes it a `contentDescription`.
|
||||
*/
|
||||
@Composable
|
||||
fun Chevron(
|
||||
|
||||
@@ -30,14 +30,12 @@ import org.intellij.markdown.ast.getTextInNode
|
||||
* 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
|
||||
* call's command colours a reply's code the same way, through [highlighted] and one palette, so a
|
||||
* `kotlin` fence and the Kotlin a tool wrote are the same colours. A fence in a language [scan] has
|
||||
* no rules for is plain rather than wrongly coloured: [fenceLanguage] answers null for those, and
|
||||
* plain is what the reader would have seen before.
|
||||
* call's command colours a reply's code the same way, so a `kotlin` fence and the Kotlin a tool
|
||||
* wrote are the same colours. A fence in a language [scan] has no rules for is plain rather than
|
||||
* wrongly coloured.
|
||||
*
|
||||
* 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]
|
||||
* hands out the code and the language and leaves the drawing to the block it is given.
|
||||
* language word and the code between them is its knowledge of the parser.
|
||||
*/
|
||||
@Composable
|
||||
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.
|
||||
*
|
||||
* Which children of the node are the fence markers, the language word and the code between them is
|
||||
* the library's knowledge of the parser, copied from its `MarkdownCodeFence` rather than called:
|
||||
* that one is a composable, and the whole point of this function is that [warm] can run it on a
|
||||
* background thread and highlight the same string the drawing will ask for. Two extractions would
|
||||
* be two keys, and the warmed answer would be silently missed at every fence.
|
||||
* Copied from the library's `MarkdownCodeFence` rather than called: that one is a composable, and
|
||||
* the whole point here is that [warm] can run this on a background thread and highlight the same
|
||||
* string the drawing will ask for. Two extractions would 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
|
||||
* library skips as invalid.
|
||||
* Null for a fence too short to hold anything -- an unterminated one still arriving.
|
||||
*/
|
||||
fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
|
||||
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
|
||||
* 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
|
||||
private fun CodeBlockText(
|
||||
@@ -117,8 +112,7 @@ private fun CodeBlockText(
|
||||
.semantics { isTraversalGroup = true }
|
||||
) {
|
||||
BasicText(
|
||||
// No language while the block is still being written, which is what draws it plain;
|
||||
// see [MarkdownRoot]'s `streaming`.
|
||||
// No language while the block is still being written, which is what draws it plain.
|
||||
replies.highlighted(code, language.takeUnless { streaming }),
|
||||
style = style,
|
||||
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 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
|
||||
* written there as the language's name. One table rather than two, so a language added for fences
|
||||
* is a language added for files and neither can be the one somebody forgot.
|
||||
* write after the backticks. One table rather than two, so a language added for fences 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
|
||||
* `Cargo.toml` TOML. A leading dot is not one: `.bashrc` has no extension, it has a name that
|
||||
* 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`, `LICENSE` -- is likewise null, and null is drawn plain.
|
||||
* The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin. A
|
||||
* leading dot is not one: `.bashrc` has no extension, it has a name that starts with a dot. A name
|
||||
* with no dot at all -- `Makefile` -- is likewise null, and null is drawn plain.
|
||||
*/
|
||||
fun fileLanguage(name: String): Language? {
|
||||
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.
|
||||
*
|
||||
* Walks the whole tree rather than the top level: a fence inside a list item or a quote is drawn
|
||||
* the same way and costs the same to lex.
|
||||
* 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
|
||||
* costs the same to lex.
|
||||
*/
|
||||
fun fences(parse: State): List<Pair<String, Language?>> {
|
||||
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
|
||||
* 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
|
||||
* through to whatever runs the session, because a dialect's own vocabulary is its own and grows
|
||||
* without this list -- it just arrives unannounced and unexplained.
|
||||
* through, because a dialect's own vocabulary grows without this list.
|
||||
*/
|
||||
data class SessionCommand(
|
||||
/** With the slash, as it is typed and as it is sent. */
|
||||
@@ -90,8 +89,8 @@ fun CommandSuggestions(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
// The command in the colour commands are, so the suggestion and the
|
||||
// bubble it becomes are visibly the same thing.
|
||||
// The command in the colour commands are, so the suggestion and the bubble
|
||||
// it becomes are visibly the same thing.
|
||||
if (command.argument == null) command.name
|
||||
else "${command.name} <${command.argument}>",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
@@ -117,8 +116,7 @@ fun CommandSuggestions(
|
||||
* anything appearing here.
|
||||
*
|
||||
* [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
|
||||
* and reads as having been missed.
|
||||
* reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes.
|
||||
*/
|
||||
@Composable
|
||||
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),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
// Stated beside the fill rather than inherited: a semantic colour has to carry
|
||||
// its own contrast, because the surface under it will not change to rescue it.
|
||||
// Stated beside the fill rather than inherited: a semantic colour has to carry its
|
||||
// own contrast, because the surface under it will not change to rescue it.
|
||||
Text(text, color = MaterialTheme.colorScheme.inverseOnSurface)
|
||||
if (waiting) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
@@ -7,12 +7,10 @@ import androidx.compose.ui.Modifier
|
||||
* 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
|
||||
* now, and that is a fact about the conversation, not a turn in it. It has no collapsed form -- it
|
||||
* is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which
|
||||
* a clear also uses, so the two marks cannot drift apart.
|
||||
* now, and that is a fact about the conversation, not a turn in it. Drawn by [TranscriptDivider],
|
||||
* which 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,
|
||||
* which is the same thing the status line says while the compaction runs.
|
||||
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it.
|
||||
*/
|
||||
@Composable
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
* When they were not reported this says only that a compaction happened, rather than filling in a
|
||||
* plausible number or explaining at length what was missing.
|
||||
* to why the wait was worth it. When they were not reported this says only that a compaction
|
||||
* happened, rather than filling in a plausible number.
|
||||
*/
|
||||
fun compactionSummary(item: TranscriptItem.CompactedNote): String {
|
||||
val pre = item.preTokens
|
||||
@@ -41,8 +38,8 @@ fun compactionSummary(item: TranscriptItem.CompactedNote): String {
|
||||
* 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
|
||||
* quantity about the same moment, and one of them grouping its thousands while the other did not
|
||||
* read as two different measurements.
|
||||
* quantity about the same moment, and one grouping its thousands while the other did not read as
|
||||
* two different measurements.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* estimate here would be this screen's guess wearing a measurement's clothes. Knowing it has been
|
||||
* going forty seconds is what a reader actually wants -- it is the difference between waiting and
|
||||
* going to look at why.
|
||||
* compaction has begun and then says nothing until it has finished, so any bar or estimate here
|
||||
* would be this screen's guess wearing a measurement's clothes.
|
||||
*
|
||||
* [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
|
||||
* honest answer, and a number counted from the moment the screen opened would be wrong in the
|
||||
* direction that matters, since a compaction somebody is asking about is a long one.
|
||||
* session that is already compacting looks like. That case says only "compacting": a number counted
|
||||
* from the moment the screen opened would be wrong in the direction that matters.
|
||||
*/
|
||||
fun compactingLabel(seconds: Long?): String =
|
||||
when {
|
||||
|
||||
@@ -12,10 +12,9 @@ import java.util.Locale
|
||||
* 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
|
||||
* and `logcat` running, which is the one thing nobody has set up at the moment it happens -- and a
|
||||
* crash report that arrives a day later, without the stack, is a guess. This costs one file write
|
||||
* on a process that is already dying, and it turns "it crashes when I open that chat" into the
|
||||
* frame it crashed in.
|
||||
* and `logcat` running, which is the one thing nobody has set up at the moment it happens. This
|
||||
* costs one file write on a process that is already dying, and it turns "it crashes when I open
|
||||
* that chat" into the frame it crashed in.
|
||||
*
|
||||
* 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.
|
||||
@@ -26,8 +25,7 @@ private const val CRASH_FILE = "last-crash.txt"
|
||||
* 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
|
||||
* reader. The top of a stack is what identifies a crash and the bottom is framework plumbing, so
|
||||
* what gets cut is the part nobody reads.
|
||||
* reader. The top of a stack is what identifies a crash and the bottom is framework plumbing.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* adds a witness.
|
||||
* ends the process, and an app that swallows that instead sits there in an unknown state.
|
||||
*/
|
||||
fun installCrashLog(context: Context) {
|
||||
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
|
||||
* 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
|
||||
* 120Hz phone. Counts do not have that problem: how many times a row was composed, or a reply
|
||||
* parsed, is the same number on any machine, and it is the number that says whether the work is
|
||||
* proportional to what is on screen or to everything ever loaded.
|
||||
* is under the floor of what it can measure. Counts do not have that problem: how many times a row
|
||||
* was composed, or a reply parsed, is the same number on any machine, and it is the number that
|
||||
* says whether the work is 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
|
||||
* 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
|
||||
* 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
|
||||
* recording, and this is the subtraction that was otherwise done by hand in a conversation every
|
||||
* 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 with how many are on screen.
|
||||
* different things is high. The transcript times its own measure, placement and recording, and this
|
||||
* is the subtraction. 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.
|
||||
*
|
||||
* 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
|
||||
* shares of an average frame, not a claim about any particular one.
|
||||
* are not themselves per-frame, so these are shares of an average frame.
|
||||
*/
|
||||
fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* the whole point of it is to save one.
|
||||
* number -- a report whose reader has to ask what the columns mean costs another round trip.
|
||||
*/
|
||||
fun debugReport(
|
||||
device: String,
|
||||
@@ -131,6 +127,13 @@ fun debugReport(
|
||||
frames: List<String>,
|
||||
accounting: List<String>,
|
||||
crash: String?,
|
||||
/**
|
||||
* P0's benchmark-only measurements (process CPU time, peak RSS, battery current) -- empty on
|
||||
* every path but [BenchRun.runP0Benchmark], which is the only caller that has them. A section
|
||||
* heading only appears when there is something to put under it, so an ordinary copy from the
|
||||
* render-report button reads exactly as it did before this existed.
|
||||
*/
|
||||
extra: List<String> = emptyList(),
|
||||
): String = buildString {
|
||||
appendLine("ai-app render report")
|
||||
appendLine(device)
|
||||
@@ -156,6 +159,11 @@ fun debugReport(
|
||||
appendLine("work since this was last copied:")
|
||||
val work = DebugStats.lines()
|
||||
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
|
||||
if (extra.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine("bench:")
|
||||
extra.forEach { appendLine(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
|
||||
|
||||
@@ -12,6 +12,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
/**
|
||||
* A line across the transcript saying what left the session's context.
|
||||
@@ -21,11 +25,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
|
||||
* 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
|
||||
* 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.
|
||||
* The rules take [color] too, so the whole divider reads as one mark of one kind.
|
||||
*/
|
||||
@Composable
|
||||
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
|
||||
@@ -44,11 +44,47 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
|
||||
* The mark a clear leaves.
|
||||
*
|
||||
* 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
|
||||
* fact. Everything above stays on screen and stays scrollable -- the reader can see that, which is
|
||||
* why this does not say it.
|
||||
* compaction it summarises nothing and measures nothing. Everything above stays on screen and stays
|
||||
* scrollable -- the reader can see that, which is why this does not say it.
|
||||
*/
|
||||
@Composable
|
||||
fun ClearedRow(modifier: Modifier = Modifier) {
|
||||
TranscriptDivider("Context cleared", clearedColor, modifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* The mark running out of quota leaves.
|
||||
*
|
||||
* The same red the usage bar takes when a window is spent, because it is the same fact in a second
|
||||
* place: colour by consequence, so "there is nothing left to spend" is learned once.
|
||||
*
|
||||
* A time rather than a countdown. The row is folded once and never re-measured, so a span would go
|
||||
* stale on screen the moment it was drawn; and this is when the *account* said it would reset,
|
||||
* which is not a promise about when the session picks back up. A limit the session was told no
|
||||
* reset time for says nothing about one -- that state has its own words rather than a plausible
|
||||
* number.
|
||||
*/
|
||||
@Composable
|
||||
fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) {
|
||||
TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the row says. Split out so the wording is testable without a screen, since the two states it
|
||||
* has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair
|
||||
* that reads the same when it goes wrong.
|
||||
*
|
||||
* [zone] is a parameter rather than read here so a test says the same thing wherever it runs.
|
||||
*/
|
||||
fun limitSummary(resetsAt: Double?, zone: ZoneId): String {
|
||||
val at = resetsAt?.let {
|
||||
try {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
.withZone(zone)
|
||||
.format(Instant.ofEpochSecond(it.toLong()))
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at"
|
||||
}
|
||||
@@ -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
|
||||
* 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
|
||||
* on another device would be a surprise rather than a convenience. What has been *sent* is the
|
||||
* server's, and that is the part which has to outlive this phone.
|
||||
* box on the phone somebody is holding, and half a sentence surfacing on another device would be a
|
||||
* surprise. What has been *sent* is the server's.
|
||||
*
|
||||
* Kept per session id, because the thing being typed belongs to the conversation it is aimed at:
|
||||
* one shared box would hand a message meant for one session to whichever was opened next.
|
||||
* Kept per session id: one shared box would hand a message meant for one session to whichever was
|
||||
* opened next.
|
||||
*/
|
||||
fun loadDraft(context: Context, sessionId: String): String =
|
||||
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.
|
||||
*
|
||||
* The path out is emptying the box, which is what sending does -- so a sent message removes its own
|
||||
* 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, which
|
||||
* 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.
|
||||
* The path out is emptying the box, which is what sending does. 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
|
||||
* residue is a few bytes per session ever abandoned mid-sentence.
|
||||
*/
|
||||
fun saveDraft(context: Context, sessionId: String, text: String) {
|
||||
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
|
||||
* 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
|
||||
* the rest -- `2.5s`, `30ms`. At a minute or more the question is "how long exactly", so every unit
|
||||
* that has something in it is written out -- `5d 12h 4m`. Units that are empty are left out rather
|
||||
* than written as zero, since the labels say which is which and `5d 0h 4m` is only longer.
|
||||
* question is "roughly how long", so only the largest unit is shown and a fraction carries the rest
|
||||
* -- `2.5s`. At a minute or more the question is "how long exactly", so every unit with something
|
||||
* in it is written out -- `5d 12h 4m`. Empty units are left out rather than written as zero.
|
||||
*
|
||||
* 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 {
|
||||
if (ms < 0) return "-" + formatMillis(-ms)
|
||||
|
||||
@@ -12,9 +12,9 @@ private const val RESET_EVENT = "reset"
|
||||
*
|
||||
* 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
|
||||
* 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 address: TranscriptAddress) {
|
||||
private val stream = Sse(settings)
|
||||
|
||||
fun close() = stream.close()
|
||||
@@ -24,20 +24,19 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
|
||||
*
|
||||
* [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
|
||||
* caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It
|
||||
* arrives before those events, so a caller that clears on it stays in order.
|
||||
* caller drops what it holds and rebuilds. It arrives before those events, so a caller that
|
||||
* clears on it stays in order.
|
||||
*/
|
||||
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.
|
||||
// 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 ->
|
||||
// A named frame carries no payload and a data frame has no name, so this is one or
|
||||
// the other.
|
||||
stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
|
||||
// A named frame carries no payload and a data frame has no name.
|
||||
if (name == RESET_EVENT) onReset()
|
||||
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ package com.example.aiapp
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
// The common event model, mirrored from server/src/session/driver.rs --
|
||||
// the app renders purely from this stream (replayed from the transcript by
|
||||
// cursor, then live), so there is no separate "load history" shape to keep
|
||||
// in sync with it.
|
||||
// The common event model, mirrored from server/src/session/driver.rs -- the app renders purely from
|
||||
// this stream (replayed from the transcript by cursor, then live), so there is no separate "load
|
||||
// history" shape to keep in sync with it.
|
||||
|
||||
/** One transcript line: the event plus its resume cursor and time. */
|
||||
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.
|
||||
*
|
||||
* More than a label because the reader is deciding rather than confirming: what an option means,
|
||||
* and what picking it would produce, are the things that decide it. Both are absent on a
|
||||
* More than a label because the reader is deciding rather than confirming. Both are absent on a
|
||||
* permission, whose Allow and Deny mean exactly what they say.
|
||||
*/
|
||||
data class QuestionOption(val label: String, val description: String?, val preview: String?)
|
||||
@@ -30,13 +28,12 @@ sealed class SessionEvent {
|
||||
*/
|
||||
val id: String?,
|
||||
/**
|
||||
* What was attached to it, by the ref the files route serves: images, and since 2026-09-03
|
||||
* any file, told apart by [isImageRef].
|
||||
* What was attached to it, by the ref the files route serves: images, and any file, told
|
||||
* apart by [isImageRef].
|
||||
*
|
||||
* 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
|
||||
* it, and left this app deciding from adjacency alone which message an image went with --
|
||||
* something the sender knew and could simply have said.
|
||||
* it, and left this app deciding from adjacency which message an image went with.
|
||||
*/
|
||||
val attachments: List<String>,
|
||||
) : SessionEvent()
|
||||
@@ -45,11 +42,10 @@ sealed class SessionEvent {
|
||||
* 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
|
||||
* screen state, so leaving the session or restarting the app drew nothing waiting while the
|
||||
* message was still queued -- and nothing waiting is what "there is nothing" looks like.
|
||||
* screen state, so leaving the session drew nothing waiting while the message was still queued
|
||||
* -- and nothing waiting is what "there is nothing" looks like.
|
||||
*
|
||||
* Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by
|
||||
* [CommandSent].
|
||||
* Resolved by the [UserMessage] carrying the same id.
|
||||
*/
|
||||
data class MessageQueued(val id: String, val text: String, val attachments: List<String>) :
|
||||
SessionEvent()
|
||||
@@ -59,8 +55,7 @@ sealed class SessionEvent {
|
||||
*
|
||||
* 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
|
||||
* coming -- with nothing left to resolve it, since the [UserMessage] that normally does is
|
||||
* exactly what was cancelled.
|
||||
* coming.
|
||||
*/
|
||||
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 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
|
||||
* one that started no turn. See the server's `Event::PeerMessage`.
|
||||
* for a message read out of a session file, and for one that started no turn.
|
||||
*/
|
||||
val turnStart: Long? = null,
|
||||
) : SessionEvent()
|
||||
|
||||
/**
|
||||
* 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, so nothing here ever draws a bubble that resolves in the same frame.
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* Either field alone: the two are confirmed separately and by different things. Asking for a
|
||||
* change is not having one, so this -- not the request -- is what the pickers show.
|
||||
* Either field alone: the two are confirmed separately. Asking for a change is not having one,
|
||||
* so this -- not the request -- is what the pickers show.
|
||||
*/
|
||||
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
|
||||
|
||||
/**
|
||||
* 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
|
||||
* usage. Carried on the event rather than summed by the reader, because it is not a sum: a
|
||||
* conversation's context drops at a compaction and a clear, so adding turns up would report a
|
||||
* figure the session stopped being true of. Null where the dialect did not say, and on entries
|
||||
* recorded before the backend sent it -- which leaves the context unmeasured rather than
|
||||
* unchanged.
|
||||
* [context] is prompt plus both cache figures. Carried on the event rather than summed by the
|
||||
* reader, because it is not a sum: a conversation's context drops at a compaction and a clear,
|
||||
* so adding turns up would report a figure the session stopped being true of. Null where the
|
||||
* dialect did not say, which leaves the context unmeasured rather than unchanged.
|
||||
*/
|
||||
data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
|
||||
|
||||
/**
|
||||
* 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
|
||||
* compaction whose size nobody measured has to be able to say so, since a zero here would read
|
||||
* as "recovered nothing" and a made-up number would read as a measurement.
|
||||
* The counts are nullable because the server sends them only when it was told them: a zero here
|
||||
* would read as "recovered nothing" and a made-up number would read as a measurement.
|
||||
*/
|
||||
data class Compacted(
|
||||
val preTokens: Long?,
|
||||
@@ -163,27 +152,35 @@ sealed class SessionEvent {
|
||||
|
||||
/**
|
||||
* The conversation was cleared. Everything above this is still here to read and is no longer in
|
||||
* the session's context.
|
||||
*
|
||||
* An object rather than a class because it carries nothing: what it means is entirely its
|
||||
* the session's context. An object rather than a class because what it means is entirely its
|
||||
* position in the transcript.
|
||||
*/
|
||||
data object Cleared : SessionEvent()
|
||||
|
||||
/**
|
||||
* The session stopped because its account's usage limit was reached.
|
||||
*
|
||||
* Its own event rather than an [Error] carrying the CLI's sentence, because it is a state
|
||||
* rather than something that went wrong -- and because the raw sentence is `Claude AI usage
|
||||
* limit reached|1788546972`, which is not readable by the person it is shown to.
|
||||
*
|
||||
* [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts
|
||||
* on it; what this draws it as is a time, not a countdown, because nothing here re-measures it.
|
||||
*/
|
||||
data class LimitReached(val resetsAt: Double?) : 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
|
||||
* event kind degrades to a placeholder row instead of killing the stream.
|
||||
* An event type this app build doesn't know -- a newer server. Kept rather than thrown so one
|
||||
* new event kind degrades to a placeholder row instead of killing the stream.
|
||||
*/
|
||||
data class Unknown(val type: String) : SessionEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON array of strings under [name], empty when the field is absent.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
private fun JSONObject.stringList(name: String): List<String> {
|
||||
val array = optJSONArray(name) ?: return emptyList()
|
||||
@@ -212,8 +209,8 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
SessionEvent.ToolStart(
|
||||
id = body.getString("id"),
|
||||
tool = body.getString("tool"),
|
||||
// Kept as raw JSON text: the input shape is the tool's own
|
||||
// business, and the UI only ever shows it verbatim.
|
||||
// Kept as raw JSON text: the input shape is the tool's own business, and the UI
|
||||
// only ever shows it verbatim.
|
||||
input = body.get("input").toString(),
|
||||
)
|
||||
"toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output"))
|
||||
@@ -276,42 +273,43 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
trigger = body.optString("trigger").ifEmpty { null },
|
||||
)
|
||||
"cleared" -> SessionEvent.Cleared
|
||||
"limitReached" ->
|
||||
SessionEvent.LimitReached(
|
||||
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
|
||||
)
|
||||
"error" -> SessionEvent.Error(body.getString("message"))
|
||||
else -> SessionEvent.Unknown(type)
|
||||
}
|
||||
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 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
|
||||
* moves the figure arrives here.
|
||||
* summary it opened with is a measurement from before this stream started.
|
||||
*
|
||||
* 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
|
||||
* -- and carrying it forward is how a session that had just been cleared went on reporting the
|
||||
* context it no longer had.
|
||||
*
|
||||
* Null is "we don't know", which is a state each of them can reach: nothing measured yet, a
|
||||
* compaction that finished without saying how much it recovered, or a clear nobody has run a turn
|
||||
* since.
|
||||
* Null is "we don't know", which each of them can reach.
|
||||
*/
|
||||
/**
|
||||
* 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? =
|
||||
when (event) {
|
||||
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a
|
||||
// turn -- which every context figure is -- rather than unknown.
|
||||
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a turn
|
||||
// -- which every context figure is -- rather than unknown.
|
||||
is SessionEvent.UsageDelta -> event.context ?: current
|
||||
is SessionEvent.Compacted -> event.postTokens
|
||||
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.
|
||||
*
|
||||
* 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 |
|
||||
* |--------|--------|--------------------|--------------------|-------------------|
|
||||
@@ -35,15 +35,13 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
* | 1 MB | 28,660 | -- | -- | stops responding |
|
||||
*
|
||||
* 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
|
||||
* `BasicTextField` costs two seconds. So switching highlighting off above a size -- which is what
|
||||
* EXPLORER.md expected to have to decide -- would not have saved it; the cost is Compose laying out
|
||||
* one enormous text, and every arrangement of a single text field pays it. A line-by-line editor is
|
||||
* the way past this and is a good deal more than this feature needed.
|
||||
* 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 -- what
|
||||
* EXPLORER.md expected to have to decide -- would not have saved it; every arrangement of a single
|
||||
* text field pays that cost. A line-by-line editor is the way past this.
|
||||
*
|
||||
* 32 kB rather than something between it and 128 kB, because 32 kB is the largest size that was
|
||||
* actually measured as usable. The viewer's own limit stays the server's `FILE_LIMIT` of 1 MiB:
|
||||
* reading a big file is fine, and it is only editing one that is not.
|
||||
* 32 kB because it is the largest size actually measured as usable. The viewer's own limit stays
|
||||
* the server's `FILE_LIMIT` of 1 MiB: reading a big file is fine, and only editing one is not.
|
||||
*/
|
||||
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
|
||||
* 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
|
||||
* [OffsetMapping.Identity] is correct by construction -- no character moves, so no offset does. The
|
||||
* newer `TextFieldState` API has no hook for styles at all, which is why this is the older one.
|
||||
* [OffsetMapping.Identity] is correct by construction. The newer `TextFieldState` API has no hook
|
||||
* for styles at all.
|
||||
*
|
||||
* The cost is that the whole file is re-scanned on every keystroke. For a file under the server's
|
||||
* limit that is expected to be a few milliseconds; see EXPLORER.md's "Numbers to measure", which is
|
||||
* where a size below which highlighting is switched off would be decided if it turns out to be
|
||||
* needed.
|
||||
* The cost is that the whole file is re-scanned on every keystroke, which is what [EDIT_LIMIT] is
|
||||
* sized against.
|
||||
*
|
||||
* 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
|
||||
* sideways, and it lines up for the same reason the viewer's does: nothing wraps, so a logical line
|
||||
* is a visual line.
|
||||
* there are no rows here -- the field is one text object. It lines up for the same reason the
|
||||
* viewer's does: nothing wraps, so a logical line is a visual line.
|
||||
*/
|
||||
@Composable
|
||||
fun FileEditor(
|
||||
|
||||
@@ -12,10 +12,8 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||
* and again on every recomposition.
|
||||
*
|
||||
* 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
|
||||
* draw a screenful. That means each row needs *its* colours, and the scanner answers in offsets
|
||||
* 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.
|
||||
* layout is linear in the text. That means each row needs *its* colours, and the scanner answers in
|
||||
* offsets into the whole file -- so the spans are bucketed here, once, in one pass.
|
||||
*/
|
||||
class FileLines
|
||||
private constructor(
|
||||
@@ -37,11 +35,9 @@ private constructor(
|
||||
get() = lines.size
|
||||
|
||||
/**
|
||||
* 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 lazy list exists to
|
||||
* avoid.
|
||||
* 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
|
||||
* lazy list exists to avoid.
|
||||
*/
|
||||
fun line(index: Int): AnnotatedString {
|
||||
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
|
||||
* 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
|
||||
* 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: a file with nothing in it still
|
||||
* has somewhere for a cursor to go.
|
||||
* agrees. Without that, every well-formed file gained a phantom empty last line. An empty
|
||||
* file is one empty line numbered 1, which is what it is.
|
||||
*/
|
||||
fun of(text: String, language: Language?): FileLines =
|
||||
// Timed, and always, for the same reason everything else here is: the cost of opening
|
||||
// a large file is the number that decides whether the server's size limit is right,
|
||||
// and an instrument that is only in the build nobody is running answers nothing. It
|
||||
// lands in the render report beside the transcript's own figures.
|
||||
// Timed, and always, for the reason everything else here is: the cost of opening a
|
||||
// large file is the number that decides whether the server's size limit is right, and
|
||||
// an instrument that is only in the build nobody is running answers nothing.
|
||||
DebugStats.timed("file scanned and cut into lines") {
|
||||
val body = text.removeSuffix("\n")
|
||||
val lines = body.split('\n')
|
||||
@@ -80,10 +73,9 @@ private constructor(
|
||||
/**
|
||||
* How many columns a line occupies.
|
||||
*
|
||||
* A tab counts as eight rather than as one, and deliberately upwards: this decides how far
|
||||
* the viewer can scroll, and over-estimating leaves a little empty space past the longest
|
||||
* line where under-estimating makes the end of that line unreachable. Compose draws a tab
|
||||
* as a single advance, so eight is the generous reading rather than the accurate one.
|
||||
* A tab counts as eight rather than one, and deliberately upwards: this decides how far the
|
||||
* viewer can scroll, and over-estimating leaves a little empty space past the longest line
|
||||
* where under-estimating makes the end of that line unreachable.
|
||||
*/
|
||||
private fun columnsOf(line: String): Int {
|
||||
var count = 0
|
||||
@@ -95,10 +87,9 @@ private constructor(
|
||||
* 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
|
||||
* ordered, non-overlapping and inside the text, so a span can only belong to the line the
|
||||
* walk has reached or to ones after it. A span crossing a line break -- a block comment, a
|
||||
* multi-line string -- is cut at each break and appears in each line it covers, because a
|
||||
* row is drawn on its own and cannot inherit a colour from the row above.
|
||||
* ordered, non-overlapping and inside the text. A span crossing a line break is cut at each
|
||||
* break and appears in each line it covers, because a 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>> {
|
||||
val out = ArrayList<List<Span>>(lines.size)
|
||||
|
||||
@@ -50,15 +50,12 @@ fun codeStyle(): TextStyle =
|
||||
/**
|
||||
* [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
|
||||
* (28,660 lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was
|
||||
* first written, that is 460ms of frozen screen at the size the server is willing to send -- long
|
||||
* enough 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.
|
||||
* Measured on the emulator 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file (28,660
|
||||
* lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was first
|
||||
* written, that is 460ms of frozen screen at the size the server is willing to send -- long enough
|
||||
* that the accessibility tree cannot be read, which is what "the app has stopped" looks like.
|
||||
*
|
||||
* Keyed on the text and the language, so re-reading the same file does not rescan it and a file
|
||||
* that changed does.
|
||||
* Keyed on the text and the language, so re-reading the same file does not rescan it.
|
||||
*/
|
||||
@Composable
|
||||
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 `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
|
||||
* scroll never recovers. The cost of the choice is that each row needs its own colours, which is
|
||||
* what [FileLines] works out once and off this thread.
|
||||
* twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. The cost is
|
||||
* that each row needs its own colours, which is what [FileLines] works out once and off this
|
||||
* thread.
|
||||
*
|
||||
* 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
|
||||
* against the wrong text, the one thing a numbered listing must never do. Because nothing wraps, a
|
||||
* logical line is one visual line and the two cannot drift.
|
||||
* against the wrong text.
|
||||
*
|
||||
* **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
|
||||
* 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
|
||||
* also writes `maxValue` on the shared state as it measures, so how far the file could be dragged
|
||||
* was decided by whichever row happened to measure last and changed as the list scrolled. Both
|
||||
* disappear once every row is [FileLines.columns] wide: one range, one maximum, and the file moves
|
||||
* as the block this comment always claimed it was. Reported by Iris on 2026-09-04 as "it seems to
|
||||
* affect different rows differently", which is exactly what a per-row range looks like.
|
||||
* short line's range is zero and it never moves while a long one beside it does. Each row also
|
||||
* writes `maxValue` as it measures, so how far the file could be dragged was decided by whichever
|
||||
* row measured last. Both disappear once every row is [FileLines.columns] wide. Reported by Iris on
|
||||
* 2026-09-04 as "it seems to affect different rows differently", which is 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
|
||||
* around the list rather than by each row. `horizontalScroll` makes its own per node otherwise, so
|
||||
* only the line under the finger stretched and the rest of the file sat still beside it -- the same
|
||||
* complaint as the offsets above, one layer further out. Handing every row the same effect and
|
||||
* 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.
|
||||
* around the list rather than by each row -- `horizontalScroll` makes its own per node otherwise,
|
||||
* so only the line under the finger stretched. Only possible because every row now has the same
|
||||
* range.
|
||||
*
|
||||
* 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
|
||||
* rows leave a spacer where the numbers will go and [LineGutter] draws them there. Its width is
|
||||
* 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.
|
||||
* numbers out of both effects. The rows leave a spacer and [LineGutter] draws them there; its width
|
||||
* is measured from the digit count of the line count in the style it is drawn in.
|
||||
*
|
||||
* 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.
|
||||
@@ -139,8 +128,8 @@ fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
|
||||
softWrap = false,
|
||||
// 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
|
||||
// widest line. The shared effect is given to every row and
|
||||
// rendered by none of them -- see the box above.
|
||||
// widest line. The shared effect is given to every row and rendered
|
||||
// by none of them -- see the box above.
|
||||
modifier =
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* may never get wrong. Those two pull in opposite directions -- out of the list, but pinned to it.
|
||||
* have to stay exactly level with the lines they number. Those two pull in opposite directions.
|
||||
*
|
||||
* 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
|
||||
* measurement, so this is not composing from a value it read a frame ago, it is composing from the
|
||||
* answer the list has just produced. A `Column` translated by the scroll position could not do
|
||||
* that: the translation would be a layout read and current while the set of numbers would be a
|
||||
* composition behind it, so during a fling the numbers would slide against their lines.
|
||||
* measurement, so this composes from the answer the list has just produced rather than one it read
|
||||
* a frame ago. A `Column` translated by the scroll position could not: the translation would be
|
||||
* current while the set of numbers was a composition behind, so during a fling the numbers would
|
||||
* slide against their lines.
|
||||
*
|
||||
* 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.
|
||||
* The list is measured before this is -- they are siblings in a `Box` and it is declared first.
|
||||
*
|
||||
* `onSurfaceVariant`, because a number is not part of the file: it is this app numbering it, and
|
||||
* the text's own colour would put it in the same voice as the code. The background is painted
|
||||
* because the stretch can carry the text sideways under this column, and a digit with a smear of
|
||||
* code behind it reads as a rendering fault.
|
||||
* `onSurfaceVariant`, because a number is not part of the file. The background is painted because
|
||||
* the stretch can carry the text sideways under this column, and a digit with a smear of code
|
||||
* behind it reads as a rendering fault.
|
||||
*/
|
||||
@Composable
|
||||
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.
|
||||
*
|
||||
* `9` repeated, because digits in a monospace face are all one width and the count's own digits
|
||||
* would measure the same -- what matters is how many there are. Measuring in the style the numbers
|
||||
* are drawn in is what makes this survive a font size, a density or a display scale nobody here
|
||||
* chose.
|
||||
* `9` repeated, because digits in a monospace face are all one width -- what matters is how many
|
||||
* there are. Measuring in the style the numbers are drawn in is what makes this survive a font
|
||||
* size, a density or a display scale nobody here chose.
|
||||
*/
|
||||
@Composable
|
||||
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.
|
||||
*
|
||||
* One character measured rather than the line itself, because the face is monospace -- every
|
||||
* advance is the same -- and measuring the actual widest line of a twenty-thousand-line file is
|
||||
* work for an answer arithmetic already has. Sixty-four of them, divided, so the answer does not
|
||||
* carry a whole character's worth of rounding.
|
||||
* One character measured rather than the line itself, because the face is monospace and measuring
|
||||
* the actual widest line of a twenty-thousand-line file is work for an answer arithmetic already
|
||||
* has. Sixty-four of them, divided, so the answer does not carry a whole character's worth of
|
||||
* rounding.
|
||||
*
|
||||
* 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
|
||||
* out as one row is a crash rather than a slow scroll. Past the cap the far end of such a line
|
||||
* cannot be reached, which is the tolerable half of that trade.
|
||||
* one: a minified file is a single line of a hundred thousand characters, and laying that out as
|
||||
* one row is a crash rather than a slow scroll. Past the cap the far end of such a line cannot be
|
||||
* reached, which is the tolerable half of that trade.
|
||||
*/
|
||||
@Composable
|
||||
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
|
||||
|
||||
/**
|
||||
* 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 of the line are not touching.
|
||||
* 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.
|
||||
*/
|
||||
val GUTTER_GAP = 8.dp
|
||||
@@ -45,7 +45,7 @@ import kotlinx.coroutines.withContext
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* nothing. Back steps one level inside here -- editor to viewer, viewer to the directory it came
|
||||
* from, directory to the one above it -- and only closes from where it opened.
|
||||
* flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
|
||||
* viewer, viewer to the directory it came from, directory to the one above -- and only closes from
|
||||
* where it opened.
|
||||
*
|
||||
* Every directory that has been visited is kept for as long as this is open, so stepping back is
|
||||
* instant; the refresh glyph is how a directory gets asked again on purpose, and creating something
|
||||
* refetches the directory it was created in, since that is the one thing that changed.
|
||||
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is
|
||||
* how one gets asked again on purpose, and creating something refetches the directory it was
|
||||
* created in.
|
||||
*/
|
||||
@Composable
|
||||
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
||||
@@ -121,17 +121,16 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
// The session under this deliberately takes no keyboard inset (see SessionScreen's
|
||||
// layout note), so the explorer adds its own -- otherwise the editor types under the
|
||||
// keyboard.
|
||||
// The session under this deliberately takes no keyboard inset, so the explorer adds its
|
||||
// own -- otherwise the editor types under the keyboard.
|
||||
.imePadding()
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
when (val spot = here) {
|
||||
is Spot.Dir -> {
|
||||
val state = listings[spot.path] ?: LoadState.Loading
|
||||
// The resolved path once there is one: a directory opened as `~` is called
|
||||
// what it turned out to be, not what it was asked for.
|
||||
// The resolved path once there is one: a directory opened as `~` is called what
|
||||
// it turned out to be, not what it was asked for.
|
||||
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
|
||||
FilesHeader(
|
||||
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
|
||||
* 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
|
||||
* row still 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".
|
||||
* something else. `other` covers a fifo, a device, and a link whose target is gone: the row still
|
||||
* appears, because a directory that hid what it held would be lying about being empty.
|
||||
*/
|
||||
private fun trailingOf(entry: DirEntry): String? =
|
||||
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
|
||||
* 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
|
||||
* belongs to the screen.
|
||||
* moves to another. What is *not* here is edit mode itself: back has to know about it.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.DocPane(
|
||||
@@ -423,8 +420,8 @@ private fun ColumnScope.DocPane(
|
||||
onDirty(false)
|
||||
onEditing(false)
|
||||
} catch (e: ApiException) {
|
||||
// The one refusal that is a question rather than a message: somebody else's edit
|
||||
// is on the machine, and which of the two survives is not this app's to decide.
|
||||
// The one refusal that is a question rather than a message: somebody else's edit is
|
||||
// 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."
|
||||
else saveError = e.message
|
||||
} 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 it is disabled -- and a reader who cannot edit a file they can plainly read will
|
||||
// otherwise conclude the app is broken. Said once, here, rather than waiting for a tap that a
|
||||
// disabled button never receives.
|
||||
// Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it
|
||||
// is disabled -- and a reader who cannot edit a file they can plainly read will otherwise
|
||||
// conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button
|
||||
// never gets.
|
||||
if (loaded != null && !editable) {
|
||||
Text(
|
||||
"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('/')
|
||||
}
|
||||
|
||||
/** A resolved directory and a name in it, as one path. */
|
||||
internal fun join(directory: String, name: String): String =
|
||||
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
|
||||
* 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
|
||||
* move it. If they are large, the work per row is the problem and it is ours to fix. Guessing
|
||||
* between those two is how a day gets spent rewriting the half that was already fast.
|
||||
* move it. If they are large, the work per row is the problem and it is ours to fix.
|
||||
*
|
||||
* 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
|
||||
* animations, measuring and laying out, recording the draw -- and the parts after it.
|
||||
* broken into the parts the UI thread is responsible for and the parts after it.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* 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.
|
||||
* work by the newest one's frame count -- 36.8 seconds of placement inside a 13.5 second window.
|
||||
*/
|
||||
object FrameStats {
|
||||
private val total = ArrayList<Long>()
|
||||
@@ -54,7 +50,7 @@ object FrameStats {
|
||||
total += metrics.getMetric(FrameMetrics.TOTAL_DURATION)
|
||||
// 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
|
||||
// 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)
|
||||
input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_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.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the same
|
||||
* code is the same colours wherever it appears.
|
||||
* Shared by a tool call's input and a reply's fences, so the same code is the same colours wherever
|
||||
* it appears.
|
||||
*
|
||||
* 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
|
||||
* which needs no colour of its own -- the style the caller draws it with carries that.
|
||||
* off the drawing thread.
|
||||
*
|
||||
* 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]
|
||||
@@ -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.
|
||||
*
|
||||
* 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
|
||||
* ordinary code, never inside a string or a comment.
|
||||
* finally a single punctuation or mark character, which are coloured only in ordinary code.
|
||||
*/
|
||||
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
|
||||
var depth = 1
|
||||
while (at < code.length && depth > 0) {
|
||||
// The closer is tried first so that a language whose two delimiters are the same
|
||||
// string -- CoffeeScript's `###` -- closes rather than nesting forever.
|
||||
// The closer is tried first so that a language whose two delimiters are the same string
|
||||
// -- CoffeeScript's `###` -- closes rather than nesting forever.
|
||||
if (starts(comment.close)) {
|
||||
depth--
|
||||
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.
|
||||
*
|
||||
* Its own word rather than the operation's, because it is its own state and the difference is the
|
||||
* kind that matters: nothing has been done to this session yet, so a batch stopped here leaves it
|
||||
* exactly as it was. Marked from the moment the batch is handed over all the same -- a queued row
|
||||
* that still looked ordinary was still tappable, and tapping it would import it a second time
|
||||
* behind the batch already coming for it.
|
||||
* Its own word rather than the operation's, because nothing has been done to this session yet, so a
|
||||
* batch stopped here leaves it exactly as it was. Marked from the moment the batch is handed over
|
||||
* all the same -- a queued row that still looked ordinary was still tappable.
|
||||
*/
|
||||
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
|
||||
* 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
|
||||
* undo.
|
||||
* this screen that means importing a session nobody chose.
|
||||
*
|
||||
* 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
|
||||
* when the row moved, short enough that it is not in the way of a deliberate one.
|
||||
* a flicker running down the list.
|
||||
*/
|
||||
private const val SETTLE_MS = 500L
|
||||
|
||||
/**
|
||||
* 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
|
||||
* shows them. Choosing one sends its **id**, never a path, so an enrolled phone cannot turn this
|
||||
* screen into a file reader.
|
||||
* The list is the machine's answer, not this app's. Choosing one sends its **id**, never a path, so
|
||||
* an enrolled phone cannot turn this screen into a file reader.
|
||||
*
|
||||
* 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
|
||||
* accumulates dozens of abandoned sessions — and one confirmation dialog per row is the reason
|
||||
* 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
|
||||
* clearing them out was not worth doing.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -91,28 +86,25 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
var chosen by remember { mutableStateOf<Setup?>(null) }
|
||||
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
|
||||
// "deleting". A map keyed by id rather than a flag per row, because the rows are rebuilt from
|
||||
// whatever the server last said and this belongs to the request rather than to the session --
|
||||
// the same arrangement the session list uses for its deletes.
|
||||
// What is happening to each row right now, as the word the row shows. A map keyed by id rather
|
||||
// than a flag per row, because the rows are rebuilt from whatever the server last said and this
|
||||
// belongs to the request rather than to the session.
|
||||
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
|
||||
// separate flag, because a selection mode with nothing selected is a state with no controls
|
||||
// in it and no way to leave except Back.
|
||||
// Which rows the reader has picked out. Empty means selection mode is off: a selection mode
|
||||
// with nothing selected is a state with no controls in it and no way to leave except Back.
|
||||
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
|
||||
// exactly where a single banner fails: nine deletes succeeded and one did not, and the
|
||||
// banner cannot say which.
|
||||
// exactly where a single banner fails: nine deletes succeeded and one did not, and the banner
|
||||
// cannot say which.
|
||||
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
|
||||
// themselves, not a flag, so the dialog can say what it is about.
|
||||
var confirming by remember { mutableStateOf<List<Importable>?>(null) }
|
||||
// Same default as the spawn screen, and for the same reason: a phone
|
||||
// is the wrong place to answer "allow Bash?" forty times.
|
||||
// Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty
|
||||
// times.
|
||||
var permissionMode by remember { mutableStateOf("auto") }
|
||||
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from
|
||||
// it, so a tap reading it needs no recomposition and there is no timer to cancel when a
|
||||
// second removal lands on top of the first.
|
||||
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
|
||||
// so a tap reading it needs no recomposition.
|
||||
val movedAt = remember { mutableMapOf<String, Long>() }
|
||||
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.
|
||||
*
|
||||
* 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 --
|
||||
* started. Anything held locally would be a second version of that, and the stale one.
|
||||
* running, and this screen may be opening on work another phone started.
|
||||
*/
|
||||
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
* change stream, which is what lets this screen be left while a batch is still going.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* 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 }
|
||||
scope.launch {
|
||||
// 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
|
||||
// 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. Now
|
||||
// either the server has the batch or it has none of it, and this is the one place
|
||||
// that can be true.
|
||||
// only as atomic as the network, and what came back was some rows running and some
|
||||
// untouched -- indistinguishable, on the list, from rows nobody had picked.
|
||||
try {
|
||||
withContext(Dispatchers.IO) { send(ids) }
|
||||
} catch (err: Exception) {
|
||||
// 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
|
||||
// whole batch's failure, which is the point: no row was singled out.
|
||||
// so. This is the one failure the screen must report itself -- and it is the whole
|
||||
// batch's failure, which is the point: no row was singled out.
|
||||
running = running - ids.toSet()
|
||||
rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" }
|
||||
return@launch
|
||||
@@ -205,19 +191,17 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
// 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
|
||||
// finished while it was still connecting is one nothing will ever be said about --
|
||||
// and the row sits marked for ever. That is not hypothetical: with responses held
|
||||
// back far enough for the stream to open late, one row of a pair of deletes cleared
|
||||
// and the other stayed on "waiting".
|
||||
// finished while it was still connecting is one nothing will ever be said about -- and
|
||||
// the row sits marked for ever. That is not hypothetical: with responses held back far
|
||||
// enough, one row of a pair of deletes cleared and the other stayed on "waiting".
|
||||
//
|
||||
// The listing is the repair, because it carries the same state the events do. Only
|
||||
// when something still looks outstanding, so the ordinary case -- where the events
|
||||
// arrived and the rows are already gone -- does not pay for a second listing, which
|
||||
// is the most expensive call this screen makes.
|
||||
// The listing is the repair, because it carries the same state the events do. Only when
|
||||
// something still looks outstanding, so the ordinary case does not pay for a second
|
||||
// listing, which is the most expensive call this screen makes.
|
||||
if (setup != null && targets.any { running.containsKey(it.id) }) {
|
||||
// Quietly: no Loading, because blanking the list to report on rows that are
|
||||
// already saying what is happening to them is the flicker this screen avoids
|
||||
// everywhere else.
|
||||
// Quietly: no Loading, because blanking the list to report on rows that are already
|
||||
// saying what is happening to them is the flicker this screen avoids everywhere
|
||||
// else.
|
||||
sessions = fetchInto(setup)
|
||||
}
|
||||
}
|
||||
@@ -225,13 +209,6 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
|
||||
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. */
|
||||
fun importAll(targets: List<Importable>) {
|
||||
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
|
||||
// up. The listing already carried the same state when the screen opened -- this is what keeps
|
||||
// it current afterwards, including for work another screen or another phone started.
|
||||
// Live changes to what the server is doing to these sessions, for as long as this screen is up.
|
||||
// The listing already carried the same state when the screen opened -- this is what keeps it
|
||||
// current afterwards, including for work another phone started.
|
||||
//
|
||||
// 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
|
||||
// listing, which is what Refresh and re-entering the tab already fetch.
|
||||
// event stream, and every state it would have carried is in the next listing.
|
||||
val liveChanges = remember {
|
||||
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
|
||||
}
|
||||
@@ -307,8 +283,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
running =
|
||||
running + (change.session to (change.operation ?: WAITING))
|
||||
// Gone from the machine either way: a delete removed the
|
||||
// transcript, an import made it a session, and neither is
|
||||
// something this list still has to offer.
|
||||
// transcript, an import made it a session.
|
||||
"finished" -> {
|
||||
running = running - change.session
|
||||
forget(change.session)
|
||||
@@ -323,17 +298,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
}
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
// The screen leaving, not a failure -- and swallowing it would leave this
|
||||
// loop reconnecting to a stream nobody is watching.
|
||||
// The screen leaving, not a failure -- and swallowing it would leave this loop
|
||||
// reconnecting to a stream nobody is watching.
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// 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, so nothing it can do is worth taking the app down for --
|
||||
// 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.
|
||||
// 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,
|
||||
// and catching only the expected failure means an unexpected one closes the app
|
||||
// from a screen that is merely loading a list.
|
||||
} finally {
|
||||
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.
|
||||
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
|
||||
|
||||
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last
|
||||
// row can still be scrolled to while it is up, and nothing is nudged by a number that was
|
||||
// right for one font size.
|
||||
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
|
||||
// can still be scrolled to while it is up.
|
||||
var barHeight by remember { mutableStateOf(0.dp) }
|
||||
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
|
||||
// on it belong to the screen, and the bottom is where a thumb already is.
|
||||
// Beside nothing in particular, because a selection is not one row: the options that act on
|
||||
// it belong to the screen, and the bottom is where a thumb already is.
|
||||
if (selected.isNotEmpty()) {
|
||||
val picked =
|
||||
(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.
|
||||
*
|
||||
* 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
|
||||
* somebody was thinking of.
|
||||
* and an option that appears here has to work on every row in a selection.
|
||||
*/
|
||||
@Composable
|
||||
private fun SelectionBar(
|
||||
@@ -567,26 +537,23 @@ private fun ImportableList(
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.combinedClickable(
|
||||
// Off while something is happening to this row --
|
||||
// see [BusyItem], which draws that but deliberately
|
||||
// leaves the gestures alone so the list still
|
||||
// scrolls.
|
||||
// Off while something is happening to this row -- see
|
||||
// [BusyItem], which draws that but leaves the gestures
|
||||
// alone so the list still scrolls.
|
||||
enabled = running[session.id] == null,
|
||||
onClick = {
|
||||
if (settling(session.id)) return@combinedClickable
|
||||
// In selection mode a tap is a selection, so the
|
||||
// reader is never one mis-tap away from starting
|
||||
// a CLI they were only picking rows for.
|
||||
// reader is never one mis-tap away from starting a
|
||||
// CLI they were only picking rows for.
|
||||
//
|
||||
// Outside it, a tap continues the session --
|
||||
// except on a row that cannot be continued,
|
||||
// where it selects instead. That row's only
|
||||
// remaining action is Delete, and a tap that
|
||||
// did nothing at all would be a worse answer
|
||||
// than one that offers the thing it can do.
|
||||
// Two `--resume` processes on one transcript
|
||||
// each replay the other's writes, which is why
|
||||
// this must not simply try.
|
||||
// Outside it, a tap continues the session -- except
|
||||
// on a row that cannot be continued, where it
|
||||
// selects instead. That row's only remaining action
|
||||
// is Delete, and a tap that did nothing at all
|
||||
// would be a worse answer. Two `--resume` processes
|
||||
// on one transcript each replay the other's writes,
|
||||
// which is why this must not simply try.
|
||||
if (selecting || session.inUse == "yes")
|
||||
onToggle(session)
|
||||
else onOpen(session)
|
||||
@@ -606,8 +573,7 @@ private fun ImportableList(
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// Beside the title, because "which one was I just in" is
|
||||
// the question this list answers and the order already
|
||||
// reflects it -- the reader should be able to see the
|
||||
// ordering they are being given rather than infer it.
|
||||
// reflects it.
|
||||
Text(
|
||||
relativeTime(session.modified),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -616,12 +582,11 @@ private fun ImportableList(
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// The path first, and the only thing here that is cut: it is
|
||||
// one long value with no natural break, where the lines below
|
||||
// it are short enough to wrap readably. Cut at the head,
|
||||
// because a path is identified by its tail and these all
|
||||
// share a long prefix. By the row's real width rather than a
|
||||
// character count, which was one guess for every font size
|
||||
// and screen.
|
||||
// one long value with no natural break. Cut at the head,
|
||||
// because a path is identified by its tail and these all share
|
||||
// a long prefix. By the row's real width rather than a
|
||||
// character count, which was one guess for every font size and
|
||||
// screen.
|
||||
session.cwd
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { cwd ->
|
||||
@@ -648,8 +613,7 @@ private fun ImportableList(
|
||||
color = warningColor,
|
||||
)
|
||||
}
|
||||
// Reported where it happened, in the server's own words, the
|
||||
// way every other failure in this app is shown.
|
||||
// Reported where it happened, in the server's own words.
|
||||
errors[session.id]?.let { message ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
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
|
||||
// session, the other is only what happened last in it.
|
||||
if (session.named) "named" else null,
|
||||
// What continuing it costs, which is the question this list is really asked. First
|
||||
// of the measurements for that reason, and absent rather than zero when nothing has
|
||||
// been measured -- a session with no turns yet has no figure, not a figure of none.
|
||||
// What continuing it costs, which is the question this list is really asked. Absent
|
||||
// rather than zero when nothing has been measured -- a session with no turns yet has no
|
||||
// figure, not a figure of none.
|
||||
session.contextTokens?.let { "${it / 1000}k context" },
|
||||
"${session.lines} lines",
|
||||
// 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
|
||||
// given, so a big file can be cheap to continue and a small one expensive.
|
||||
// transcript is history from before a compaction, so a big file can be cheap to
|
||||
// continue.
|
||||
humanSize(session.bytes),
|
||||
)
|
||||
.joinToString(" · ")
|
||||
@@ -689,16 +653,14 @@ private fun statsOf(session: Importable): String =
|
||||
* 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,
|
||||
* and no shade distinguishes them. The colour is what makes it findable; the words are what make it
|
||||
* actionable.
|
||||
* and no shade distinguishes them.
|
||||
*/
|
||||
private fun warningOf(session: Importable): String? =
|
||||
when (session.inUse) {
|
||||
// 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
|
||||
// people looking for a window that need not exist. It is just as likely another agent, or
|
||||
// this app on a session it spawned. Naming a place the reader then can't find turns a
|
||||
// correct refusal into a wrong instruction.
|
||||
// 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. Naming a place the reader then can't
|
||||
// find turns a correct refusal into a wrong instruction.
|
||||
"yes" -> "something on that machine is running it"
|
||||
"unknown" -> "can't tell if it's open"
|
||||
else -> null
|
||||
|
||||
@@ -49,10 +49,9 @@ data class Rules(
|
||||
/** Tokens that open a comment running to the end of the line. */
|
||||
val lineComments: List<String> = emptyList(),
|
||||
/**
|
||||
* 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 scanner exists to stop.
|
||||
* 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
|
||||
* scanner exists to stop.
|
||||
*/
|
||||
val lineCommentsAtWordStart: Boolean = false,
|
||||
val blockComment: BlockComment? = null,
|
||||
@@ -63,8 +62,8 @@ data class Rules(
|
||||
val rawStrings: Boolean = false,
|
||||
/**
|
||||
* 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
|
||||
* string that runs to the next apostrophe in the block.
|
||||
* Otherwise it is a lifetime or a label -- without this, `'a` opens a string that runs to the
|
||||
* next apostrophe in the block.
|
||||
*/
|
||||
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
|
||||
* be made of.
|
||||
*
|
||||
* Nearly every language here is tokens: keywords, strings and comments, which is a row of [RULES]
|
||||
* and the one shared scanner in [scan]. Markdown has none of those, and what a character means
|
||||
* there depends on where on the line it sits, so it brings a scanner of its own ([scanMarkdown]).
|
||||
* That is the whole extension point -- a new language is a row of rules or an entry in [SCANNERS],
|
||||
* and no caller learns which one it got.
|
||||
* Nearly every language here is tokens, which is a row of [RULES] and the one shared scanner.
|
||||
* Markdown has none of those, and what a character means there depends on where on the line it
|
||||
* sits, so it brings a scanner of its own. That is the whole extension point -- a new language is a
|
||||
* row of rules or an entry in [SCANNERS], and no caller learns which one it got.
|
||||
*/
|
||||
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,
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
),
|
||||
// `###` opens and closes a block comment and `#` opens a line one, which is why the
|
||||
// scanner tries the block opener first.
|
||||
// `###` opens and closes a block comment and `#` opens a line one, which is why the scanner
|
||||
// tries the block opener first.
|
||||
Language.COFFEESCRIPT to
|
||||
Rules(
|
||||
keywords = KEYWORDS_COFFEESCRIPT,
|
||||
@@ -162,8 +160,8 @@ private val RULES: Map<Language, Rules> by lazy {
|
||||
keywords = KEYWORDS_FISH,
|
||||
lineComments = listOf("#"),
|
||||
lineCommentsAtWordStart = true,
|
||||
// fish's single quotes escape only `\'` and `\\`, which is what "skip the
|
||||
// character after a backslash" already does.
|
||||
// fish's single quotes escape only `\'` and `\\`, which is what "skip the character
|
||||
// after a backslash" already does.
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
),
|
||||
Language.GO to
|
||||
@@ -288,10 +286,9 @@ private val RULES: Map<Language, Rules> by lazy {
|
||||
* The keyword sets.
|
||||
*
|
||||
* 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
|
||||
* coloured today turns plain. Entries that are not plain words were dropped -- Kotlin's `as?`,
|
||||
* `!in` and `!is`, Swift's `#if` family, Ruby's `defined?`, CoffeeScript's `=` and `->` -- because
|
||||
* the word scanner cannot reach them and the library only matched them by luck.
|
||||
* (Apache-2.0), the library this scanner replaced, so that no fence which is coloured today turns
|
||||
* plain. Entries that are not plain words were dropped -- Kotlin's `as?`, Swift's `#if` family,
|
||||
* Ruby's `defined?` -- because the word scanner cannot reach them.
|
||||
*/
|
||||
private fun words(list: String): Set<String> =
|
||||
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.
|
||||
*
|
||||
* [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> {
|
||||
data object Loading : LoadState<Nothing>()
|
||||
|
||||
@@ -28,14 +28,13 @@ import androidx.compose.ui.layout.layout
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
// Bumped whenever enrollment lands via an aiapp:// intent so the
|
||||
// composition below re-reads the stored settings.
|
||||
// Bumped whenever enrollment lands via an aiapp:// intent so the composition below re-reads the
|
||||
// stored settings.
|
||||
private var settingsVersion by mutableIntStateOf(0)
|
||||
|
||||
// The session a notification tap asked for, or null if nothing has. The
|
||||
// serial is what makes a second tap on the same session's notification a
|
||||
// second request: without it the two compare equal and the composition
|
||||
// below has nothing to react to.
|
||||
// The session a notification tap asked for, or null if nothing has. The serial is what makes a
|
||||
// second tap on the same session's notification a second request: without it the two compare
|
||||
// equal and the composition below has nothing to react to.
|
||||
private var openRequest by mutableStateOf<SessionOpenRequest?>(null)
|
||||
private var opens = 0
|
||||
|
||||
@@ -43,8 +42,8 @@ class MainActivity : ComponentActivity() {
|
||||
private var shareRequest by mutableStateOf<ShareRequest?>(null)
|
||||
private var shares = 0
|
||||
|
||||
// Registered up front since permission launchers must be registered
|
||||
// before the activity reaches STARTED.
|
||||
// Registered up front since permission launchers must be registered before the activity reaches
|
||||
// STARTED.
|
||||
private val requestLocalNetworkPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||
|
||||
@@ -52,8 +51,8 @@ class MainActivity : ComponentActivity() {
|
||||
* 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
|
||||
* settings, and a service that only ever started at the moment it was granted would then stay
|
||||
* down until the app was launched again -- which is the case notifications exist to avoid.
|
||||
* settings, and a service that only ever started at the moment it was granted would stay down
|
||||
* until the app was launched again.
|
||||
*/
|
||||
private val requestNotificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||
@@ -64,21 +63,46 @@ class MainActivity : ComponentActivity() {
|
||||
// Before anything else that could throw, so the first crash of a launch is caught too.
|
||||
installCrashLog(this)
|
||||
|
||||
// Transparent status bar on every version; the Surface below paints
|
||||
// through underneath it and content insets itself. Same reasoning
|
||||
// as dev-updater's MainActivity.
|
||||
// Transparent status bar on every version; the Surface below paints through underneath it
|
||||
// and content insets itself. Same reasoning as dev-updater's MainActivity.
|
||||
enableEdgeToEdge()
|
||||
// Dark status-bar icons only over a light background, decided from the scheme rather
|
||||
// than fixed. It was hardcoded to `true` -- dark icons -- which was right against the
|
||||
// default light surface and 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.
|
||||
|
||||
// The `bench` build's entire purpose (P0, docs/RUST.md): open straight onto the session
|
||||
// screen against BenchFixture's in-process fake backend, with no enrollment, no network
|
||||
// permission, and no notification prompt -- none of them mean anything with no server and
|
||||
// no real device to notify. See BenchFixture.kt and BenchNetwork.kt for how a screen built
|
||||
// to talk to a real backend is made to talk to this instead. Still needs the same
|
||||
// status/navigation-bar padding the ordinary flow below applies: edge-to-edge is the
|
||||
// platform's own default from Android 15 on this app's targetSdk, with or without the call
|
||||
// above, so skipping the padding here put the header's own buttons under the status bar --
|
||||
// there to look at, but not there for `ui-trace`'s tap-by-label to land on.
|
||||
if (BuildConfig.FIXTURE_MODE) {
|
||||
installFixtureNetworkOnce()
|
||||
BenchFixture.ensureLoaded(this)
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = AiAppColors) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
|
||||
SessionScreen(
|
||||
settings = BenchFixture.settings,
|
||||
summary = benchSessionSummary(),
|
||||
onBack = { finish() },
|
||||
onFiles = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// Dark status-bar icons only over a light background, decided from the scheme rather than
|
||||
// fixed. It was hardcoded to `true`, which was right against the default light surface and
|
||||
// became unreadable the moment the app wore Catppuccin Mocha.
|
||||
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
|
||||
AiAppColors.background.luminance() > 0.5f
|
||||
|
||||
// Android 17+ silently drops local-network traffic without this;
|
||||
// requested up front because a denial is invisible at the socket
|
||||
// layer (it just times out).
|
||||
// Android 17+ silently drops local-network traffic without this; requested up front because
|
||||
// a denial is invisible at the socket layer (it just times out).
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
}
|
||||
@@ -88,16 +112,14 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
handleIntent(intent)
|
||||
// After enrollment, so a first launch that arrives with a token
|
||||
// starts the service with something to connect to rather than
|
||||
// stopping it and waiting for the next launch.
|
||||
// After enrollment, so a first launch that arrives with a token starts the service with
|
||||
// something to connect to rather than stopping it and waiting for the next launch.
|
||||
NotificationService.sync(this)
|
||||
|
||||
setContent {
|
||||
// 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
|
||||
// the code block under it would otherwise change colour halfway. See
|
||||
// [AiAppSelectionColors].
|
||||
// transcript is one selection container, and a selection that ran from a reply into the
|
||||
// code block under it would otherwise change colour halfway.
|
||||
MaterialTheme(colorScheme = AiAppColors) {
|
||||
CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -107,8 +129,7 @@ class MainActivity : ComponentActivity() {
|
||||
// the frame's draw phase is where Compose's measurement lands, and
|
||||
// a report saying "draw is high" cannot otherwise say whether the
|
||||
// cost is the transcript or the chrome around it. The keyboard is
|
||||
// the case that made it matter -- every frame of the IME animation
|
||||
// relays out and re-records this whole box.
|
||||
// the case that made it matter.
|
||||
Modifier.layout { measurable, constraints ->
|
||||
val started = System.nanoTime()
|
||||
val placeable = measurable.measure(constraints)
|
||||
@@ -135,19 +156,16 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
// The gesture strip at the bottom of most
|
||||
// phones. Without it the send row sits under
|
||||
// the swipe area, where a tap is as likely to
|
||||
// navigate away as to press a button.
|
||||
// The gesture strip at the bottom of most phones. Without it
|
||||
// the send row sits under the swipe area, where a tap is as
|
||||
// likely to navigate away as to press a button.
|
||||
//
|
||||
// No imePadding here, deliberately: applied at the root it
|
||||
// resizes this whole box on every frame of the keyboard
|
||||
// animation, which re-measures, re-places and re-records every
|
||||
// screen's entire tree per frame -- measured above as most of
|
||||
// the frame budget. Each screen takes the keyboard itself
|
||||
// (AppRoot wraps the ordinary ones; the session screen moves
|
||||
// only its composer and transcript), so the per-frame cost is
|
||||
// scoped to what actually moves.
|
||||
// screen's entire tree per frame. Each screen takes the
|
||||
// keyboard itself, so the per-frame cost is scoped to what
|
||||
// actually moves.
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
AppRoot(settingsVersion, openRequest, shareRequest)
|
||||
@@ -158,9 +176,28 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// launchMode="singleTop": an enrollment scan, or a notification tapped
|
||||
// while the app is open, lands here rather than in a second activity
|
||||
// instance.
|
||||
/** The one session the `bench` build ever shows -- BenchFixture's session id, nothing else. */
|
||||
private fun benchSessionSummary() =
|
||||
SessionSummary(
|
||||
id = BenchFixture.SESSION_ID,
|
||||
setup = "bench",
|
||||
setupName = "bench",
|
||||
provider = "bench",
|
||||
title = "P0 benchmark",
|
||||
model = null,
|
||||
keepsOwnTranscript = false,
|
||||
permissionMode = null,
|
||||
imported = false,
|
||||
notify = false,
|
||||
cwd = null,
|
||||
contextTokens = null,
|
||||
maxImageEdge = null,
|
||||
status = "idle",
|
||||
lastActivity = 0.0,
|
||||
)
|
||||
|
||||
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
|
||||
// lands here rather than in a second activity instance.
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
@@ -171,8 +208,7 @@ class MainActivity : ComponentActivity() {
|
||||
*
|
||||
* 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
|
||||
* rather than by two entry points, so a further kind is a branch here rather than another
|
||||
* intent to remember to handle.
|
||||
* rather than by two entry points, so a further kind is a branch here.
|
||||
*/
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
intent ?: return
|
||||
@@ -195,8 +231,8 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
saveServerSettings(this, settings)
|
||||
settingsVersion++
|
||||
// Enrolling is the moment there is a backend to watch, and
|
||||
// re-enrolling elsewhere is the moment the old one stops being it.
|
||||
// Enrolling is the moment there is a backend to watch, and re-enrolling elsewhere is the
|
||||
// moment the old one stops being it.
|
||||
NotificationService.sync(this)
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* the same thing in less space and say one more thing besides: that these are places to be rather
|
||||
* than errands to run. Sessions, the machine's importable history, the models on it and the
|
||||
* machines themselves are all *the same backend*, looked at four ways, and none of them is a step
|
||||
* down from another. Settings still is a step down, which is why it stays a pushed screen and keeps
|
||||
* its own Back.
|
||||
* full. Tabs say the same thing in less space and say one more thing besides: that these are places
|
||||
* to be rather than errands to run. Sessions, the machine's importable history, the models on it
|
||||
* and the machines themselves are all *the same backend*, looked at four ways, and none is a step
|
||||
* down from another. Settings still is, which is why it stays a pushed screen with its own Back.
|
||||
*/
|
||||
private enum class MainTab(val label: String) {
|
||||
Sessions("Sessions"),
|
||||
@@ -50,6 +48,8 @@ fun MainScreen(
|
||||
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
|
||||
share: ShareRequest? = null,
|
||||
onOpen: (SessionSummary) -> Unit,
|
||||
/** Opens one session's subagent, from the expander under its card. */
|
||||
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
|
||||
onSpawn: () -> Unit,
|
||||
onImported: (SessionSummary) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
@@ -61,17 +61,12 @@ fun MainScreen(
|
||||
//
|
||||
// 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
|
||||
// phone that was away while the tunnel was down, or that fetched before the network came up,
|
||||
// came back to "Couldn't reach the server" sitting at the top of a list the server would now
|
||||
// answer for perfectly well, and nothing took it off until somebody pressed Refresh. A stale
|
||||
// failure is worse than a stale list: it is a claim about right now.
|
||||
// phone that was away while the tunnel was down came back to "Couldn't reach the server"
|
||||
// sitting at the top of a list the server would now answer for perfectly well. A stale 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
|
||||
// understand rather than a second path into each of them -- which is also what makes it cover
|
||||
// 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.
|
||||
// understand. Not on the first entry: the tab composing already asks.
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
LaunchedEffect(lifecycleOwner) {
|
||||
var opening = true
|
||||
@@ -81,9 +76,8 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// A tab the app put over the list has to step back to it rather than fall through to the
|
||||
// system default, which closes the app -- that reads as a crash to somebody who only meant to
|
||||
// get back to their sessions. Nested inside AppRoot's handler, so it wins while it is enabled.
|
||||
// A tab the app put over the list has to step back to it rather than fall through to the system
|
||||
// default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled.
|
||||
BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions }
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
@@ -98,20 +92,18 @@ fun MainScreen(
|
||||
)
|
||||
// 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
|
||||
// keep its own line. They sit on the title's row because they act on the whole
|
||||
// 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 of them
|
||||
// side by side already have two rings between their marks and one ring plus this
|
||||
// row's padding to the screen edge.
|
||||
// keep its own line. They sit on the title's row because they act on the whole screen.
|
||||
//
|
||||
// Flush against each other: a glyph button carries its own padding, so two side by side
|
||||
// already have two rings between their marks.
|
||||
Row {
|
||||
GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ })
|
||||
GlyphButton(SETTINGS_GLYPH, "Settings", onSettings)
|
||||
}
|
||||
}
|
||||
// What is waiting to be attached, and what to do about it. Said here because the list
|
||||
// below is where the choice is made, and a share that arrived with nothing on screen
|
||||
// saying so would read as a tap that did nothing.
|
||||
// What is waiting to be attached, and what to do about it. Said here because the list below
|
||||
// is where the choice is made, and a share that arrived with nothing on screen saying so
|
||||
// would read as a tap that did nothing.
|
||||
share?.let {
|
||||
Text(
|
||||
it.summary() + " -- open the session it belongs in.",
|
||||
@@ -127,8 +119,8 @@ fun MainScreen(
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Primary rather than the plain TabRow, which is deprecated in favour of the two that
|
||||
// say where they sit: these are the app's top-level destinations.
|
||||
// Primary rather than the plain TabRow, which is deprecated in favour of the two that say
|
||||
// where they sit: these are the app's top-level destinations.
|
||||
PrimaryTabRow(selectedTabIndex = tab.ordinal) {
|
||||
MainTab.entries.forEach { entry ->
|
||||
Tab(
|
||||
@@ -139,10 +131,9 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Refreshing means "ask again about what I am looking at", so the button feeds the tab
|
||||
// that is showing. The token from above means something else already changed what these
|
||||
// show; the two are the same instruction to the tab below, so they are summed rather than
|
||||
// tracked apart -- either one moving moves the sum, which is all a tab watches.
|
||||
// Refreshing means "ask again about what I am looking at", so the button feeds the tab that
|
||||
// is showing. The token from above means something else already changed what these show;
|
||||
// the two are the same instruction, so they are summed rather than tracked apart.
|
||||
val token = reloadToken + refreshToken
|
||||
when (tab) {
|
||||
MainTab.Sessions ->
|
||||
@@ -150,6 +141,7 @@ fun MainScreen(
|
||||
settings = settings,
|
||||
reloadToken = token,
|
||||
onOpen = onOpen,
|
||||
onOpenSubagent = onOpenSubagent,
|
||||
onSpawn = onSpawn,
|
||||
)
|
||||
MainTab.Import ->
|
||||
|
||||
@@ -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
|
||||
* -- 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
|
||||
* changed is re-recorded instead of the whole reply, which is worth a great deal while every delta
|
||||
* invalidates the message and a finished one can be twenty-five screens tall. It is worth nothing
|
||||
* once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows were re-recorded 65
|
||||
* 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.
|
||||
* message. And its pieces get a layer each, so only the piece that changed is re-recorded. That is
|
||||
* worth a great deal while every delta invalidates the message and worth nothing once it stops
|
||||
* changing -- 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 transcript's per-frame cost scales with.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownText(
|
||||
@@ -92,8 +89,8 @@ fun MarkdownText(
|
||||
var previousSegment: Segment? = null
|
||||
segments.forEachIndexed { at, segment ->
|
||||
val nextContinues = segments.getOrNull(at + 1)?.continues == true
|
||||
// Only the tail is still being written; a frozen segment is finished text that
|
||||
// happens to sit in a live reply, and it takes its colours now. See [MarkdownRoot].
|
||||
// Only the tail is still being written; a frozen segment is finished text that happens
|
||||
// to sit in a live reply, and it takes its colours now.
|
||||
MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) {
|
||||
segment.pieces.forEachIndexed { index, piece ->
|
||||
val gap =
|
||||
@@ -103,9 +100,9 @@ fun MarkdownText(
|
||||
if (segment.continues) 0.dp else BLOCK_SPACING
|
||||
else -> gapBefore(previous, piece)
|
||||
}
|
||||
// Keyed by where the piece starts in the message rather than by its position
|
||||
// in this column, so a delta landing in the last block leaves every other
|
||||
// piece's composition alone -- and a block keeps its key when it freezes.
|
||||
// Keyed by where the piece starts in the message rather than by its position in
|
||||
// this column, so a delta landing in the last block leaves every other piece's
|
||||
// composition alone -- and a block keeps its key when it freezes.
|
||||
key(segment.start, piece) {
|
||||
MarkdownPiece(
|
||||
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
|
||||
* 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
|
||||
* ended with, so the two draw as one list: no block gap between them, and neither the item above
|
||||
* the seam nor the one below it takes the padding of a list's edge.
|
||||
* ended with, so the two draw as one list.
|
||||
*/
|
||||
private class Segment(
|
||||
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
|
||||
* 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
|
||||
* at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment
|
||||
* later it was all there again. That is the "skipping up and down" this list must never do.
|
||||
* height, and the transcript above it collapses and springs back -- seen with five replies on
|
||||
* screen at once, the whole conversation shrunk to fit a single screen.
|
||||
*
|
||||
* 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
|
||||
* screen is always a real prefix of the reply rather than a guess at it; it is simply one parse
|
||||
* behind.
|
||||
* already has until the new one lands, so there is never a frame without a height.
|
||||
*/
|
||||
@Composable
|
||||
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
|
||||
* 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.
|
||||
* 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
|
||||
* finished once the next item has begun, since a line can only continue the item it is indented
|
||||
* under or start a new one. Without this a reply that is one long list -- forty sources -- parsed
|
||||
* 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
|
||||
* paragraph line once `-x` arrives, and cutting on it would draw that line as a new item.
|
||||
* 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. So every block but the last is
|
||||
* [frozen] with the parse that finished it, and only the tail is parsed again.
|
||||
*
|
||||
* A list is cut once more, at its last item, by the same reasoning one level down. Without this a
|
||||
* 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
|
||||
* it, since the frozen block's parse never sees it. The link draws as its brackets until the reply
|
||||
* settles and is parsed whole by [warm], which is the same moment every other transient of
|
||||
* streaming is put right.
|
||||
* it. The link draws as its brackets until the reply settles and is parsed whole by [warm].
|
||||
*/
|
||||
private class LiveParse(
|
||||
val text: String,
|
||||
@@ -217,8 +204,8 @@ private class LiveParse(
|
||||
get() = frozen + tail
|
||||
|
||||
fun advanceTo(next: String): LiveParse {
|
||||
// Anything but an append to what was frozen -- a message replaced, a stream reset --
|
||||
// starts over.
|
||||
// Anything but an append to what was frozen -- a message replaced, a stream reset -- starts
|
||||
// over.
|
||||
if (!next.regionMatches(0, text, 0, consumed)) return whole(next)
|
||||
val tailText = next.substring(consumed)
|
||||
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
|
||||
* piece of the last block when there is more than one block. Null when nothing before it is
|
||||
* finished, so the tail stays whole.
|
||||
* piece of the last block when there is more than one. Null when nothing before it is finished.
|
||||
*/
|
||||
private fun openPiece(parse: State.Success, all: List<Piece>): Piece? {
|
||||
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
|
||||
* 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
|
||||
* 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.
|
||||
* 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,
|
||||
* which was the last of its composables on the hot path and was here only to provide them. What
|
||||
* that buys is that nothing between a piece and the screen is the library's but the leaf
|
||||
* composables named in the component table, so a different parser could stand behind [State]
|
||||
* without the renderer's entry point being involved.
|
||||
* which was the last of its composables on the hot path and was here only to provide them. So
|
||||
* nothing between a piece and the screen is the library's but the leaf composables named in the
|
||||
* component table.
|
||||
*
|
||||
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules
|
||||
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
|
||||
* Colours come from the theme rather than the renderer's defaults. Nothing here picks one of its
|
||||
* own.
|
||||
*
|
||||
* [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
|
||||
* re-lexed at every delta on the composing thread. Measured streaming a two-hundred-line Kotlin
|
||||
* fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms -- for colours on
|
||||
* text that was being replaced as fast as they were computed. So a fence still being written is
|
||||
* 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.
|
||||
* care about: lexing is proportional to how much code there is. Measured streaming a two-hundred-
|
||||
* line Kotlin fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms --
|
||||
* for colours on text being replaced as fast as they were computed. So a fence still being written
|
||||
* is drawn plain and takes its colours when the block freezes.
|
||||
*/
|
||||
@Composable
|
||||
private fun MarkdownRoot(
|
||||
@@ -354,45 +336,40 @@ private fun MarkdownRoot(
|
||||
CompositionLocalProvider(
|
||||
LocalReferenceLinkHandler provides parse.referenceLinkHandler,
|
||||
LocalMarkdownPadding provides markdownPadding(),
|
||||
// Read by the renderer's own text composable, which no paragraph reaches any more, and
|
||||
// by its checkbox. Provided so a path that does reach them draws no image rather than
|
||||
// failing to compose.
|
||||
// Read by the renderer's own text composable, which no paragraph reaches any more, and by
|
||||
// its checkbox. Provided so a path that does reach them draws no image rather than failing
|
||||
// to compose.
|
||||
LocalImageTransformer provides remember { NoOpImageTransformerImpl() },
|
||||
LocalMarkdownAnimations provides markdownAnimations(),
|
||||
LocalMarkdownColors provides
|
||||
markdownColor(
|
||||
text = MaterialTheme.colorScheme.onSurface,
|
||||
dividerColor = MaterialTheme.colorScheme.outlineVariant,
|
||||
// The dark surface every verbatim thing in this app sits on -- see [rawSurface],
|
||||
// and the tool call above this reply, which now matches. `surfaceVariant` was
|
||||
// exactly a card's own fill, so a fenced block inside a tool call had no
|
||||
// background at all and one in a reply read as a step *up* out of the page.
|
||||
// The dark surface every verbatim thing in this app sits on -- and the tool call
|
||||
// above this reply, which now matches. `surfaceVariant` was exactly a card's own
|
||||
// fill, so a fenced block inside a tool call had no background at all.
|
||||
codeBackground = rawSurface,
|
||||
// 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
|
||||
// fenced block -- see `appendCodeChip`.
|
||||
// [LinkedText] behind the text, so a selection lands on top of it -- see
|
||||
// `appendCodeChip`.
|
||||
inlineCodeBackground = rawSurface,
|
||||
// The same tint a code block gets, rather than the renderer's 2%-alpha default:
|
||||
// two 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.
|
||||
// The same tint a code block gets, rather than the renderer's 2%-alpha default: two
|
||||
// adjacent tints that differ by a fiftieth read as one flat block on a phone.
|
||||
tableBackground = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
LocalMarkdownTypography provides
|
||||
markdownTypography(
|
||||
// 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
|
||||
// are the Material *display* styles -- `#` came out at 57sp and `##` at 45sp, which
|
||||
// is bigger than this app's own screen titles and reads as the reply shouting.
|
||||
//
|
||||
// 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.
|
||||
// are the Material *display* styles -- `#` came out at 57sp, bigger than this app's
|
||||
// own screen titles. Every step is a different size, so two levels of nesting never
|
||||
// draw the same.
|
||||
h1 = MaterialTheme.typography.headlineSmall,
|
||||
h2 = MaterialTheme.typography.titleLarge,
|
||||
h3 = MaterialTheme.typography.titleMedium,
|
||||
h4 = MaterialTheme.typography.titleSmall,
|
||||
h5 = MaterialTheme.typography.labelMedium,
|
||||
h6 = MaterialTheme.typography.labelSmall,
|
||||
// Body text at the size everything else in the transcript uses.
|
||||
text = body,
|
||||
paragraph = body,
|
||||
ordered = body,
|
||||
@@ -400,16 +377,10 @@ private fun MarkdownRoot(
|
||||
list = body,
|
||||
table = body,
|
||||
// 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
|
||||
// -- the palette's colour for a *literal*. A block of code is not a literal, it
|
||||
// is text that happens to be code, and painting all of it green said the whole
|
||||
// block was one. Where a literal really does appear inside code, the thing that
|
||||
// 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.
|
||||
// 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, and
|
||||
// painting all of it green said the whole block was one. Where a literal really
|
||||
// does appear inside code, what should colour it is a syntax highlighter.
|
||||
code =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
@@ -436,31 +407,26 @@ private fun MarkdownRoot(
|
||||
LocalMarkdownDimens provides
|
||||
markdownDimens(
|
||||
// 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
|
||||
// -- and the narrowest column is where the wrapping below has the least room.
|
||||
// the default a fifth of the narrowest column went on space rather than on words.
|
||||
tableCellPadding = 8.dp,
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// makes even a three-column table -- the commonest shape there is -- scroll, while
|
||||
// 136dp fits three across the phone this app is read on. Four and up still scroll,
|
||||
// which is the right answer for genuinely too many columns: squeezing six columns
|
||||
// into a phone would give every cell one word per line.
|
||||
//
|
||||
// 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.
|
||||
// makes even a three-column table scroll, while 136dp fits three across the phone
|
||||
// this app is read on. Four and up still scroll, which is the right answer for
|
||||
// genuinely too many columns. This is the widest minimum that keeps three on
|
||||
// screen.
|
||||
tableCellWidth = 136.dp,
|
||||
),
|
||||
LocalMarkdownComponents provides
|
||||
markdownComponents(
|
||||
// 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) },
|
||||
// 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
|
||||
// is the renderer's own pairing.
|
||||
// see [LinkedText]. Setext headings take the same styles as `#` and `##`.
|
||||
text = { LinkedText(it, it.typography.text) },
|
||||
paragraph = { LinkedText(it, it.typography.paragraph) },
|
||||
heading1 = { LinkedHeading(it, it.typography.h1) },
|
||||
@@ -471,8 +437,8 @@ private fun MarkdownRoot(
|
||||
heading6 = { LinkedHeading(it, it.typography.h6) },
|
||||
setextHeading1 = { LinkedHeading(it, it.typography.h1) },
|
||||
setextHeading2 = { LinkedHeading(it, it.typography.h2) },
|
||||
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote --
|
||||
// so they draw like the top-level ones the transcript cuts into items.
|
||||
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- so
|
||||
// they draw like the top-level ones the transcript cuts into items.
|
||||
orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
|
||||
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
|
||||
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.
|
||||
*
|
||||
* Each column has a floor ([markdownDimens]'s `tableCellWidth`), so the table is at least
|
||||
* columns-times-floor wide; narrower than the room it has, it spreads to fill it, and wider, it
|
||||
* scrolls sideways rather than squeezing. The renderer decided that with a `BoxWithConstraints`,
|
||||
* which is a subcomposition; here it is one layout modifier, and the trick is where it sits.
|
||||
* `fillMaxWidth` fixes the minimum width to the room available, the horizontal scroll passes that
|
||||
* minimum through to its content while lifting the maximum to unbounded, and the modifier after it
|
||||
* 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.
|
||||
* Each column has a floor, so the table is at least columns-times-floor wide; narrower than the
|
||||
* room it has, it spreads to fill it, and wider, it scrolls sideways rather than squeezing. The
|
||||
* renderer decided that with a `BoxWithConstraints`, which is a subcomposition; here it is one
|
||||
* layout modifier. `fillMaxWidth` fixes the minimum width to the room available, the horizontal
|
||||
* scroll passes that minimum through while lifting the maximum to unbounded, and the modifier after
|
||||
* it reads the minimum back and sizes the rows to the larger of that and the floor.
|
||||
*/
|
||||
@Composable
|
||||
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].
|
||||
*
|
||||
* 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
|
||||
* no way to see the rest, and an elided cell looks like a short one, so a table of measurements
|
||||
* reads as a table of plausible shorter measurements. And they draw a link in a cell as its own
|
||||
* layout node, the cost [LinkedText] exists to avoid.
|
||||
* most of a table is simply not readable: an elided cell looks like a short one, so a table of
|
||||
* measurements reads as a table of plausible shorter measurements. And they draw a link in a cell
|
||||
* as its own 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
|
||||
* beside a one-line one centred the short one against the middle of the tall one and lost the line
|
||||
* the reader was reading across. What the wrapping does *not* do is make a wide table fit;
|
||||
* [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.
|
||||
* beside a one-line one centred the short one against the middle of the tall one. What the wrapping
|
||||
* does *not* do is make a wide table fit; [LinkedTable] scrolls it instead.
|
||||
*
|
||||
* The semantics are the renderer's: each cell is an item of the table's collection, and a header
|
||||
* cell is a heading.
|
||||
* The semantics are the renderer's: each cell is an item of the table's collection.
|
||||
*/
|
||||
@Composable
|
||||
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
|
||||
* 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
|
||||
* tuned on -- so a page of history landing composed several rows that each stalled the frame they
|
||||
* appeared in. That is the lag when a block loads.
|
||||
* tuned on -- so a page of history landing composed several rows that each stalled the frame.
|
||||
*
|
||||
* Nothing here changes what a row does when it has no answer waiting: it parses inline, on the
|
||||
* composing thread, because a row measured at nothing before it is measured at its real height
|
||||
* collapses the transcript above it. The point is only that by the time the reader scrolls to a
|
||||
* 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.
|
||||
* Nothing here changes what a row does when it has no answer waiting: it parses inline, because a
|
||||
* row measured at nothing before its real height collapses the transcript above it. The point is
|
||||
* only that by the time the reader scrolls to a row, the answer is usually already made.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
* warmed, so a reply still streaming cannot fill it with hundreds of copies of itself.
|
||||
*/
|
||||
@Stable
|
||||
class ParsedReplies {
|
||||
@@ -607,14 +562,13 @@ class ParsedReplies {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* proportional to nothing.
|
||||
* fold, and walking the tree again each time is proportional to the message.
|
||||
*/
|
||||
private val pieces = ConcurrentHashMap<String, List<Piece>>()
|
||||
|
||||
/**
|
||||
* How each message divides into prose and memory notes, cached for the same reason as
|
||||
* [piecesOf]: the regex scan behind [messageParts] is proportional to the message.
|
||||
* How each message divides into prose and memory notes, cached for the same reason: the regex
|
||||
* scan behind [messageParts] is proportional to the message.
|
||||
*/
|
||||
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 --
|
||||
* 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,
|
||||
* measured. [warm] fills this off the drawing thread before the row is reached.
|
||||
* measured.
|
||||
*/
|
||||
private val highlights = ConcurrentHashMap<String, AnnotatedString>()
|
||||
|
||||
@@ -649,11 +603,10 @@ class ParsedReplies {
|
||||
* 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
|
||||
* message and the flatten runs on the composing thread -- so a reply not marked yet stays
|
||||
* whole, drawing the parse it already has, until the screen has warmed it and re-flattens. An
|
||||
* explicit mark rather than a peek into the parse cache, because a message with memory notes is
|
||||
* warmed as its *parts*: nothing ever parses its full text, and inferring readiness from the
|
||||
* cache left exactly that message unsplittable forever, re-warmed on every fold.
|
||||
* message and the flatten runs on the composing thread, so a reply not marked yet stays whole
|
||||
* until the screen has warmed it. An explicit mark rather than a peek into the parse cache,
|
||||
* because a message with memory notes is warmed as its *parts*: nothing ever parses its full
|
||||
* text, and inferring readiness from the cache left exactly that message unsplittable forever.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* The key carries the language, because the same code lexes differently under two of them.
|
||||
* [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.
|
||||
*/
|
||||
fun highlighted(code: String, language: Language?): AnnotatedString =
|
||||
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
|
||||
* 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
|
||||
* every core busy, with the frame's own thread waiting for one. That showed up as 21ms of
|
||||
* `waited` at the 90th percentile: the frame could not start, rather than taking too long.
|
||||
* a twelve second scroll on a Pixel 9 Pro XL -- and on the default dispatcher that is every
|
||||
* core busy, with the frame's own thread waiting for one: 21ms of `waited` at the 90th
|
||||
* percentile.
|
||||
*/
|
||||
suspend fun warm(texts: List<String>) {
|
||||
texts.forEach { text ->
|
||||
@@ -697,9 +649,8 @@ class ParsedReplies {
|
||||
DebugStats.timed("markdown warmed") { parseMarkdown(it) }
|
||||
}
|
||||
// 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
|
||||
// message it is in -- proportional to what was written, and charged to the frame
|
||||
// that first draws it if nobody paid it earlier.
|
||||
// parse this just made, and lexing one is the same kind of cost as parsing the message
|
||||
// it is in.
|
||||
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,
|
||||
* 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
|
||||
* annotations per link. Measured on the emulator against the same paragraphs with each link
|
||||
* replaced by its label and address as plain words -- *more* text, the same gestures -- the linked
|
||||
* version cost five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. On a
|
||||
* Pixel 9 Pro XL that was the bump at the list of sources in a reply, and nowhere else in it.
|
||||
* layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one annotation
|
||||
* per link. Measured on the emulator against the same paragraphs with each link replaced by its
|
||||
* label and address as plain words -- *more* text, the same gestures -- the linked version cost
|
||||
* five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* Every block the renderer dispatches through its component table comes here, which includes the
|
||||
* paragraphs inside lists, quotes and alerts, and so does every table cell through
|
||||
* [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it
|
||||
* resolves those against its definitions.
|
||||
* Every block the renderer dispatches through its component table comes here, and so does every
|
||||
* table cell. Reference-style links are the one kind still drawn the renderer's way.
|
||||
*
|
||||
* 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
|
||||
* model put something, with no sign of what fell out. The link says what was there and where, and
|
||||
* 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.
|
||||
* model put something. The link says what was there and where, and opens it.
|
||||
*/
|
||||
@Composable
|
||||
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
|
||||
* `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
|
||||
* for a week.
|
||||
* does not know, so handed the heading node itself it draws an empty line.
|
||||
*/
|
||||
@Composable
|
||||
fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) {
|
||||
@@ -113,18 +107,18 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
||||
BasicText(
|
||||
text = text,
|
||||
modifier =
|
||||
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the
|
||||
// second one has to be answered from inside the text rather than left to the card.
|
||||
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the second
|
||||
// one has to be answered from inside the text rather than left to the card.
|
||||
modifier.then(chipFill).pointerInput(text, onPlainTap) {
|
||||
awaitEachGesture {
|
||||
// Unconsumed is not required: something outside may already be tracking this
|
||||
// press, and it is still the press that may land on a link.
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
// A tap and nothing else. Null when the gesture became something somebody
|
||||
// else's -- a scroll, or a press held past the long-press timeout, which is
|
||||
// how a selection starts. The timeout is the load-bearing half: without it a
|
||||
// press held for a second and released was still an up with nothing consumed,
|
||||
// so holding a peer message to select from it shut the card instead.
|
||||
// A tap and nothing else. Null when the gesture became somebody else's -- a
|
||||
// scroll, or a press held past the long-press timeout, which is how a selection
|
||||
// starts. The timeout is the load-bearing half: without it a press held for a
|
||||
// second and released was still an up with nothing consumed, so holding a peer
|
||||
// message to select from it shut the card instead.
|
||||
val up =
|
||||
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
|
||||
waitForUpOrCancellation()
|
||||
@@ -156,27 +150,24 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
||||
* 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
|
||||
* composed by the renderer's own dispatch out of its component table, so nothing between a card and
|
||||
* the text inside it is ours to pass a parameter through; the renderer already hands its colours,
|
||||
* its typography and its components down the same way.
|
||||
* composed by the renderer's own dispatch, so nothing between a card and the text inside it is ours
|
||||
* to pass a parameter through.
|
||||
*
|
||||
* 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
|
||||
* the text -- consuming or not -- a tap on its words did nothing at all, and with the handler
|
||||
* removed entirely the same tap shut the card. So a card whose body is markdown cannot be shut by
|
||||
* pressing its words unless the words do the shutting, and "nothing happens when I press it" is
|
||||
* indistinguishable from a card that has stopped working.
|
||||
* handler never sees it. Measured against an opened peer message: with a handler on the text --
|
||||
* consuming or not -- a tap on its words did nothing at all, and with the handler removed the same
|
||||
* tap shut the card. So a card whose body is markdown cannot be shut by pressing its words unless
|
||||
* the words do the shutting.
|
||||
*
|
||||
* Provided as a value that outlives a recomposition (see [rememberMarkdownTap]), since a fresh
|
||||
* lambda per composition would invalidate every paragraph reading it.
|
||||
* Provided as a value that outlives a recomposition, since a fresh lambda per composition would
|
||||
* invalidate every paragraph reading it.
|
||||
*/
|
||||
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
|
||||
|
||||
/**
|
||||
* [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 under it on every recomposition of the card.
|
||||
* [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
|
||||
* under it on every recomposition of the card.
|
||||
*/
|
||||
@Composable
|
||||
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
|
||||
* 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
|
||||
* sentence highlighted every word of it except the ones in backticks. Anything drawn by a modifier
|
||||
* on the text is under both, which is where a fenced block's box already is and why one of those
|
||||
* always looked right. The [CODE_CHIP] annotation is what says where the fill goes.
|
||||
* sentence highlighted every word except the ones in backticks. Anything drawn by a modifier on the
|
||||
* text is under both, which is where a fenced block's box already is.
|
||||
*/
|
||||
private fun appendCodeChip(
|
||||
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
|
||||
* 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
|
||||
* stops being drawn: the same rule the selection rectangle obeys, so the two agree rather than the
|
||||
* 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.
|
||||
* stops being drawn -- the same rule the selection rectangle obeys, so the two agree.
|
||||
*
|
||||
* 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
|
||||
* 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> {
|
||||
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
|
||||
* 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
|
||||
* has to be bounded for the worst frame to be. Measured on a Pixel 9 Pro XL, the tallest row still
|
||||
* being drawn was 36,982px, twenty-five screens in one message. A piece is a paragraph, a fence, a
|
||||
* table, one bullet: bounded, so both costs are.
|
||||
* hundred short ones; and the list composes an item whole in the frame it scrolls into. Measured on
|
||||
* a Pixel 9 Pro XL, the tallest row still being drawn was 36,982px -- twenty-five screens in one
|
||||
* message. A piece is a paragraph, a fence, a 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
|
||||
* table and a nested list are each one node whatever is inside them, so nothing is ever split down
|
||||
* the middle. A list is the one block that is not bounded -- a reply's list of sources can be forty
|
||||
* items -- so it is cut once more, into its items, and a nested list stays inside the item that
|
||||
* holds it.
|
||||
* Cut where the parser says the blocks are, which is what makes it safe: a fence, a table and a
|
||||
* nested list are each one node whatever is inside them. A list is the one block that is not
|
||||
* bounded -- a reply's list of sources can be forty items -- so it is cut once more, into its
|
||||
* items.
|
||||
*
|
||||
* A piece is an *address* into the message's one parse ([block] indexes the root's children, [item]
|
||||
* the list items of that child) rather than a substring of the message. Every piece of a message is
|
||||
* drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and a
|
||||
* 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.
|
||||
* A piece is an *address* into the message's one parse rather than a substring of it. Every piece
|
||||
* is drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and
|
||||
* a reference definition at its foot still resolves the links above it.
|
||||
*/
|
||||
@Immutable
|
||||
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
|
||||
* newlines -- are not pieces.
|
||||
* The pieces of [parse], in reading order. Blank nodes between blocks are not pieces.
|
||||
*
|
||||
* 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.
|
||||
@@ -90,17 +85,15 @@ fun gapBefore(previous: Piece?, piece: Piece): Dp =
|
||||
val BLOCK_SPACING: Dp = 6.dp
|
||||
|
||||
/**
|
||||
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which is what carries the
|
||||
* theme, the components and the reference links to the renderer's element composables.
|
||||
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which carries the theme,
|
||||
* 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
|
||||
* or heading is a [LinkedText], a table is [LinkedTableRow]s, and a nested list comes back here
|
||||
* through [MarkdownList]. Only the list item is drawn directly, because a list item is the one
|
||||
* piece the renderer has no element for.
|
||||
* A whole block goes to the renderer's own dispatch with this app's component table. 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
|
||||
* `LiveParse`): an item that is the first or last of its own parse but not of the list the reader
|
||||
* sees keeps an inner item's padding, so nothing moves when the seam between segments does.
|
||||
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply: an
|
||||
* item that is the first or last of its own parse but not of the list the reader sees keeps an
|
||||
* inner item's padding, so nothing moves when the seam between segments does.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownPiece(
|
||||
@@ -112,8 +105,8 @@ fun MarkdownPiece(
|
||||
listContinues: Boolean = false,
|
||||
) {
|
||||
if (parse !is State.Success) {
|
||||
// The parser threw. Nothing else in the app has seen this happen; if it does, the words
|
||||
// are still worth more than a blank.
|
||||
// The parser threw. Nothing else in the app has seen this happen; if it does, the words are
|
||||
// still worth more than a blank.
|
||||
Text(text, modifier, style = MaterialTheme.typography.bodyLarge)
|
||||
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
|
||||
* list inside a quote, and the nested lists an item holds. Top-level lists never come here; they
|
||||
* are drawn an item at a time as pieces.
|
||||
* list inside a quote, and the nested lists an item holds. Top-level lists never come here.
|
||||
*/
|
||||
@Composable
|
||||
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
|
||||
* 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
|
||||
* rather than by a handler because it is the thing a reader might one day want styled -- a
|
||||
* different glyph per depth, a colour -- and this is the one place it is drawn.
|
||||
* The marker is drawn here rather than by a handler because it is the thing a reader might one day
|
||||
* want styled -- a different glyph per depth, a colour -- and this is the one place it is drawn.
|
||||
*/
|
||||
@Composable
|
||||
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
|
||||
* 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
|
||||
* platform lacks draws as a box, and that check is the price of adding one here.
|
||||
* Checked on the emulator's system fonts; a glyph the platform lacks draws as a box, and that check
|
||||
* is the price of adding one here.
|
||||
*/
|
||||
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:
|
||||
* 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
|
||||
* same line. The token scanner cannot ask either question, and answering them with its rules is how
|
||||
* a highlighter comes to grey out the second half of a paragraph.
|
||||
* same line. The token scanner cannot ask either question.
|
||||
*
|
||||
* Structure is read a line at a time and each line's prose is then read left to right, so every
|
||||
* decision is made inside one line -- except the two things that are not one line. A fenced block
|
||||
* is state carried forward, so an unclosed fence colours the rest of the text, which is also what
|
||||
* it looks like while somebody is still writing it. A table is found by its delimiter row
|
||||
* (`|---|---|`), which is the only line of one that cannot be anything else, and its header is the
|
||||
* line before that -- the one place here that looks ahead.
|
||||
* Structure is read a line at a time and each line's prose left to right, so every decision is made
|
||||
* inside one line -- except the two that are not. A fenced block is state carried forward, so an
|
||||
* unclosed fence colours the rest of the text, which is what it looks like while somebody is
|
||||
* writing it. A table is found by its delimiter row (`|---|---|`), the only line of one that cannot
|
||||
* be anything else, and its header is the 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
|
||||
* one, and four spaces after a bullet is a list item's second paragraph, and the two are told apart
|
||||
* by what came before rather than by the line itself. Colouring the wrong one of those as code is a
|
||||
* mistake the reader cannot see, so both are left plain, which is the safe answer.
|
||||
* one, four spaces after a bullet is a list item's second paragraph, and the two are told apart by
|
||||
* what came before. Colouring the wrong one as code is a mistake the reader cannot see.
|
||||
*
|
||||
* 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.
|
||||
* Like [scan], the spans come out ordered, non-overlapping and inside the text by construction.
|
||||
*/
|
||||
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. */
|
||||
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_TRAILING = ".,:;!?"
|
||||
|
||||
@@ -53,8 +49,8 @@ private class MarkdownScanner(private val code: String) {
|
||||
val end = lineEnd(at)
|
||||
val open = fence
|
||||
if (open != null) {
|
||||
// The content and the closing line alike: a fence is one block of code, and its
|
||||
// own delimiters belong to it the way a string's quotes belong to the string.
|
||||
// The content and the closing line alike: a fence is one block of code, and its own
|
||||
// delimiters belong to it the way a string's quotes belong to the string.
|
||||
emit(at, end, Kind.STRING)
|
||||
if (closesFence(at, end, open)) fence = null
|
||||
} 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.
|
||||
*
|
||||
* A table is recognised by its delimiter row (`|---|---|`), which is the only line of one that
|
||||
* cannot be anything else. That row comes *after* the header it belongs to, so the header is
|
||||
* found by looking one line ahead -- the single piece of lookahead here, and cheaper than the
|
||||
* alternative of colouring every `|` in the document, which would mark the pipes in a shell
|
||||
* command written in a paragraph.
|
||||
* A table is recognised by its delimiter row, the only line of one that cannot be anything
|
||||
* else. That row comes *after* the header it belongs to, so the header is found by looking one
|
||||
* line ahead -- the single piece of lookahead here, and cheaper than colouring every `|` in the
|
||||
* document, which would mark the pipes in a shell command written in a paragraph.
|
||||
*/
|
||||
private fun row(start: Int, end: Int, table: Boolean): Boolean {
|
||||
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.
|
||||
*
|
||||
* Worth doing here rather than leaving it to the caller: the line scanner emits per marker and
|
||||
* per word, so a heading would otherwise arrive as a dozen abutting spans of one colour.
|
||||
* 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
|
||||
* otherwise arrive as a dozen abutting spans of one colour.
|
||||
*/
|
||||
private fun emit(start: Int, end: Int, kind: Kind) {
|
||||
if (end <= start) return
|
||||
@@ -179,17 +173,16 @@ private class MarkdownScanner(private val code: String) {
|
||||
private fun opensFence(start: Int, end: Int): String? {
|
||||
val run = fenceRun(start, end) ?: return null
|
||||
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
|
||||
// rather than part of it -- the same reading as a Rust attribute above a struct.
|
||||
// The info word is what the fence is a fence *of*, which is metadata about the block rather
|
||||
// than part of it.
|
||||
emit(indented(run.last + 1, end), end, Kind.METADATA)
|
||||
return code.substring(run.first, run.last + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 backticks with a word after it does not close anything.
|
||||
* 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
|
||||
* backticks with a word after it does not close anything.
|
||||
*/
|
||||
private fun closesFence(start: Int, end: Int, open: String): Boolean {
|
||||
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.
|
||||
*
|
||||
* `---`, `***` 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
|
||||
* rule drawn across the page -- so they get one appearance rather than a lookback to tell them
|
||||
* apart. One `=` is enough because a setext underline may be a single character; a break needs
|
||||
* three, which is what keeps a `- ` bullet out of here.
|
||||
* setext heading. The two are the same line to look at and mean the same thing to a reader, so
|
||||
* they get one appearance rather than a lookback. One `=` is enough because a setext underline
|
||||
* may be a single character; a break needs three, which keeps a `- ` bullet out of here.
|
||||
*/
|
||||
private fun thematicBreak(start: Int, end: Int): Boolean {
|
||||
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.
|
||||
*
|
||||
* That count is what lets a span hold a backtick of its own (``` ``a ` b`` ```), and it is why
|
||||
* the search skips over a shorter or longer run rather than stopping at the first backtick.
|
||||
* `` `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
|
||||
* rather than stopping at the first backtick.
|
||||
*/
|
||||
private fun codeSpan(start: Int, end: Int): Int {
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* said to the reader. A `[text]` with no destination after it is left plain, because that is
|
||||
* what a reference link and a bracketed aside look like, and neither is worth guessing at.
|
||||
* are marked, and the destination is metadata. A `[text]` with no destination after it is left
|
||||
* plain, because that is what a reference link and a bracketed aside look like.
|
||||
*/
|
||||
private fun link(start: Int, bracket: Int, end: Int): Int {
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* `<img src="http://x">` has the colon but also a space.
|
||||
* scheme's colon or an at sign -- which is what keeps an HTML tag out.
|
||||
*/
|
||||
private fun autolink(start: Int, end: Int): Int {
|
||||
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 scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry, and
|
||||
* the pair of colons is what makes the match unambiguous enough to draw without a closer.
|
||||
* A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry.
|
||||
*
|
||||
* 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
|
||||
* URL -- otherwise a link in parentheses loses its `)` to the address. A pipe stops it too,
|
||||
* because a URL in a table cell must not swallow the cell's edge.
|
||||
* URL -- otherwise a link in parentheses loses its `)`. A pipe stops it too, because a URL in a
|
||||
* table cell must not swallow the cell's edge.
|
||||
*/
|
||||
private fun url(start: Int, end: Int): Int? {
|
||||
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 thing. The two guards are what keep this off code that happens to be in a paragraph --
|
||||
* the opener must be followed by something to emphasise and the closer preceded by something
|
||||
* emphasised, so `a * b * c` opens nothing and neither does the `*p = *q` of a C fragment.
|
||||
* Underscores additionally may not start or end inside a word, or every `snake_case_name` in a
|
||||
* document would be half emphasised.
|
||||
* The two guards keep this off code that happens to be in a paragraph: the opener must be
|
||||
* followed by something to emphasise and the closer preceded by something emphasised, so `a * b
|
||||
* * c` opens nothing and neither does the `*p = *q` of a C fragment. Underscores may not start
|
||||
* or end inside a word, or every `snake_case_name` would be half emphasised.
|
||||
*/
|
||||
private fun emphasis(start: Int, end: Int): Int {
|
||||
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
|
||||
* 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
|
||||
* HTML. It is really the opposite: a claim about where something came from, which is worth showing,
|
||||
* because "I was told this before" and "I worked this out just now" are different things and the
|
||||
* reader cannot otherwise tell them apart.
|
||||
* HTML. It is really the opposite: a claim about where something came from, and "I was told this
|
||||
* before" and "I worked this out just now" are different things the reader cannot otherwise tell
|
||||
* apart.
|
||||
*
|
||||
* A tag that has not finished arriving is left alone. Streaming means the closing tag may be
|
||||
* seconds away, and a half-written marker is not a marker yet.
|
||||
* A tag that has not finished arriving is left alone: a half-written marker is not a marker yet.
|
||||
*/
|
||||
@Composable
|
||||
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
|
||||
* 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
|
||||
* the rows draw: a string warmed under a key no row ever looks up is a miss that nothing reports,
|
||||
* and the row pays the parse in the frame it appears, which is the cost being removed.
|
||||
* the rows draw: a string warmed under a key no row ever looks up is a miss nothing reports.
|
||||
*
|
||||
* 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
|
||||
* message.
|
||||
* [ParsedReplies.partsOf] on any path that runs per fold or per page.
|
||||
*/
|
||||
fun messageParts(text: String): List<MessagePart> {
|
||||
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
|
||||
* 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
|
||||
* arrive several to a message.
|
||||
* reply in half around a card, and these arrive several to a message.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* tag has arrived, so a note stays open across the moment its reply settles.
|
||||
* list stopped composing it.
|
||||
*/
|
||||
@Composable
|
||||
fun MemoryNote(
|
||||
@@ -148,10 +143,8 @@ private val MEMORY_NOTE =
|
||||
Regex("""<cc-memory\s+filenames="([^"]*)"\s*>(.*?)</cc-memory>""", RegexOption.DOT_MATCHES_ALL)
|
||||
|
||||
/**
|
||||
* Splits [text] into prose and memory notes, in order.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
fun splitMemoryNotes(text: String): List<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
|
||||
* 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
|
||||
* the session can act on rather than a name this app made up.
|
||||
* It is also the Claude CLI's own word for "whatever is configured".
|
||||
*/
|
||||
const val DEFAULT_MODEL = "default"
|
||||
|
||||
/**
|
||||
* A model's name as a person reads it.
|
||||
*
|
||||
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to
|
||||
* `claude-haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session
|
||||
* using" and far too long for a button in a row that also has to hold Stop and Send.
|
||||
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to `claude-
|
||||
* haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session using"
|
||||
* 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
|
||||
* 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 --
|
||||
* `haiku-4-5` -- and anything that does not look like that is returned untouched, since a name this
|
||||
* does not recognise is a name it has no business editing.
|
||||
* one model rather than one model from another. Anything that does not look like that is returned
|
||||
* untouched.
|
||||
*
|
||||
* A display decision, not a correction: the full name is what the session reports and what a reader
|
||||
* is shown when there is room for it.
|
||||
* A display decision, not a correction: the full name is what the session reports.
|
||||
*/
|
||||
fun modelLabel(model: String?): String {
|
||||
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
|
||||
// any session, so it has no event stream of its own. Slow enough not
|
||||
// to matter, frequent enough that a bar moves.
|
||||
// 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.
|
||||
// Polled rather than pushed: a download belongs to the machine, not to any session, so it has
|
||||
// no event stream of its own. 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 a second and a half.
|
||||
LaunchedEffect(reloadToken) {
|
||||
while (true) {
|
||||
reload()
|
||||
@@ -186,11 +183,9 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inside the expanded repository's own item
|
||||
// rather than as a section after the list:
|
||||
// drawn after every card, a repository's files
|
||||
// read as belonging to whichever card happened
|
||||
// to be last.
|
||||
// Inside the expanded repository's own item rather than as a section
|
||||
// after the list: drawn after every card, a repository's files read as
|
||||
// belonging to whichever card happened to be last.
|
||||
if (open) {
|
||||
when (val files = repoFiles) {
|
||||
null -> {}
|
||||
@@ -257,15 +252,15 @@ private fun DownloadCard(download: Download, onCancel: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// A determinate bar only when the size is known. The server
|
||||
// sends no total when it was never told one, and a bar drawn
|
||||
// from a guess is worse than one that admits it is counting.
|
||||
// A determinate bar only when the size is known. The server sends no total when it was
|
||||
// never told one, and a bar drawn from a guess is worse than one that admits it is
|
||||
// counting.
|
||||
if (download.total != null && download.total > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { download.done.toFloat() / download.total.toFloat() },
|
||||
// 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
|
||||
// the opposite of what is happening.
|
||||
// nearing success, and colouring it like a limit being approached would say the
|
||||
// opposite.
|
||||
color = progressColor,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -327,8 +322,8 @@ private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
|
||||
repo.id,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
// The owner is the part that repeats; the model name at
|
||||
// the end is what tells two entries apart.
|
||||
// The owner is the part that repeats; the model name at the end is what tells
|
||||
// two entries apart.
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
)
|
||||
Text(
|
||||
@@ -356,11 +351,9 @@ private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Disabled rather than absent, so the row reads the same whether
|
||||
// this one is absent, already here, or on its way. Offering
|
||||
// "Download" for a file that is downloading would be a button that
|
||||
// does nothing anyone can see -- the server joins the running
|
||||
// download rather than starting a second.
|
||||
// Disabled rather than absent, so the row reads the same whether this one is absent,
|
||||
// already here, or on its way. Offering "Download" for a file that is downloading would be
|
||||
// a button that does nothing anyone can see.
|
||||
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
|
||||
Text(
|
||||
when {
|
||||
|
||||
@@ -26,26 +26,21 @@ import androidx.compose.ui.unit.sp
|
||||
* set and kept in step by hand.
|
||||
*
|
||||
* 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
|
||||
* the person who wrote it. That objection is about *relying* on a system font, and it is exactly
|
||||
* right: the answer is not to avoid glyphs but to ship them. The font here is
|
||||
* `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
|
||||
* font and committed -- so the codepoints below are resolved by an asset in the APK and cannot come
|
||||
* 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.
|
||||
* grounds that a system font may not have the glyph. That objection is about *relying* on a system
|
||||
* font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
|
||||
* is `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
|
||||
* 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.
|
||||
* That is what makes two icons the same size without either of them being given a size: the
|
||||
* proportional face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side
|
||||
* by side came out visibly different widths, and matching them at the call site would have meant
|
||||
* one hardcoded measurement per pair. [GLYPH_SIZE] carries the cost.
|
||||
* That is what makes two icons the same size without either being given a size: the proportional
|
||||
* face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side by side came
|
||||
* out visibly different widths. [GLYPH_SIZE] carries the cost.
|
||||
*
|
||||
* 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
|
||||
* and something else in the other is the failure this is worth preventing. The script is copied
|
||||
* rather than shared because most of what looks like duplication is the `GLYPHS` list, which has to
|
||||
* 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.
|
||||
* Material Design codepoints. Those two must not drift. The script is copied rather than shared
|
||||
* because most of what looks like duplication is the `GLYPHS` list, which has to differ -- the
|
||||
* point of subsetting is to ship only the codepoints one app draws.
|
||||
*/
|
||||
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
|
||||
* 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
|
||||
* is a pause, not a stop, and drawing it as a square said otherwise.
|
||||
* ends a turn and nothing else, which is a pause, not a stop.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* while looking identical would promise something immediate and do something that waits.
|
||||
* starting one, and one glyph doing both jobs would promise something immediate and do something
|
||||
* that waits.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* what makes it read as a measurement rather than as a trend.
|
||||
* for by name, and Material's chart glyphs are a bare line where this one has its axes.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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 confusion sharing them prevents. Doubles as the mark on a directory row inside the explorer,
|
||||
* which is what makes the button say where it leads.
|
||||
* the refresh arrow must not. Doubles as the mark on a directory row inside the explorer, which is
|
||||
* what makes the button say where it leads.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and
|
||||
* keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked
|
||||
* for. This is what the largest of them already drew at.
|
||||
* most 0.83 em of its point size, so the number was standing in for the headroom above the tallest
|
||||
* one; in the Mono face every glyph fills its em exactly, and keeping 20 would have stepped every
|
||||
* icon in the app up by a fifth.
|
||||
*/
|
||||
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 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
|
||||
* screen. That is what it was: the box was the size of the mark (28dp) and the separation was
|
||||
* its own. 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
|
||||
* 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
|
||||
* to find. It is what the pressed-state ripple draws, too: at 28dp that circle was inscribed in the
|
||||
* mark's own corners, and beside a title it arrived at the first letter. And it is taller than any
|
||||
* header's text, which is what lets the button fill a header row rather than sit in the middle of
|
||||
* one -- the rows add no vertical padding of their own for the same reason they add no gap.
|
||||
* to find, and what the pressed-state ripple draws: at 28dp that circle was inscribed in the mark's
|
||||
* own corners and beside a title it arrived at the first letter. And it is taller than any header's
|
||||
* text, which is what lets the button fill a header row rather than sit in the middle of one.
|
||||
*/
|
||||
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
|
||||
* a back arrow.
|
||||
*
|
||||
* Two glyph buttons need nothing between them: each brings its own ring and the two add up, which
|
||||
* is why a row of them sets no spacing. Text brings none, so the second ring has to be asked for.
|
||||
* Without it the pressed-state circle, which fills the whole square, arrives at the first letter of
|
||||
* 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.
|
||||
* Two glyph buttons need nothing between them: each brings its own ring and the two add up. Text
|
||||
* brings none, so the second ring has to be asked for -- without it the pressed-state circle
|
||||
* arrives at the first letter of the title.
|
||||
*/
|
||||
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
|
||||
* 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"
|
||||
* six months from now.
|
||||
* which is also the answer to "what was that button for" six months from now.
|
||||
*
|
||||
* [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
|
||||
@@ -220,9 +206,8 @@ fun GlyphButton(
|
||||
* 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
|
||||
* size, spacing and touch target every other icon button on this app's headers already is -- so
|
||||
* this is [GlyphButton] with the mark left to the caller rather than a second set of measurements
|
||||
* beside it. The caller still owes it a [label]: nothing here draws a word.
|
||||
* size, spacing and touch target every other icon button already is. The caller still owes it a
|
||||
* [label]: nothing here draws a word.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkButton(
|
||||
@@ -245,9 +230,7 @@ fun MarkButton(
|
||||
* 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
|
||||
* swapping one for the other leaves everything in the row exactly where it was -- a control that
|
||||
* changed the width of its header while it worked would move its neighbours at the moment somebody
|
||||
* was pressing them.
|
||||
* swapping one for the other leaves everything in the row exactly where it was.
|
||||
*/
|
||||
@Composable
|
||||
fun GlyphSpinner(label: String, modifier: Modifier = Modifier) {
|
||||
@@ -273,10 +256,9 @@ fun Glyph(
|
||||
size: TextUnit = GLYPH_SIZE,
|
||||
) {
|
||||
// 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.
|
||||
// Left to the inherited body style the line box was 24sp tall around a 17sp-wide mark, so a
|
||||
// glyph took a seventh more vertical space than horizontal wherever one is drawn without a box
|
||||
// around it -- and where there is a box, that leading is what its padding is measured through.
|
||||
// ascent and descent add up to exactly one em. Left to the inherited body style the line box
|
||||
// was 24sp tall around a 17sp-wide mark, so a glyph took a seventh more vertical space than
|
||||
// horizontal.
|
||||
Text(
|
||||
glyph,
|
||||
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
|
||||
* 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
|
||||
* made as quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no
|
||||
* sound, shows no status-bar icon, and sits at the bottom of the shade -- the same arrangement
|
||||
* Syncthing's "hide the persistent notification" option produces. It is not hidden outright,
|
||||
* because it cannot be and because it should not be: it is the honest indicator that something is
|
||||
* holding a connection open.
|
||||
* The cost Android charges is a notification of its own that cannot be dismissed. That is made as
|
||||
* quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
|
||||
* no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
|
||||
* cannot be and because it should not be: it is the honest indicator that something is holding a
|
||||
* connection open.
|
||||
*/
|
||||
class NotificationService : Service() {
|
||||
@Volatile private var stream: HttpURLConnection? = null
|
||||
@@ -50,18 +49,18 @@ class NotificationService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val settings = loadServerSettings(this)
|
||||
if (settings == null) {
|
||||
// Nothing to connect to. Stopping rather than idling: a service
|
||||
// holding no connection still costs the ongoing notification,
|
||||
// which would then be announcing work that is not happening.
|
||||
// Nothing to connect to. Stopping rather than idling: a service holding no connection
|
||||
// still costs the ongoing notification, which would be announcing work that is not
|
||||
// happening.
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// Through ServiceCompat so the type is stated once and ignored on
|
||||
// the versions that predate types, rather than branching here.
|
||||
// Through ServiceCompat so the type is stated once and ignored on the versions that predate
|
||||
// types, rather than branching here.
|
||||
ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType())
|
||||
thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) }
|
||||
// Restarted if Android kills it, which is the whole point: the
|
||||
// window this covers is exactly the one where nobody is watching.
|
||||
// Restarted if Android kills it, which is the whole point: the window this covers is
|
||||
// exactly the one where nobody is watching.
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
@@ -73,11 +72,10 @@ class NotificationService : Service() {
|
||||
/**
|
||||
* Follows the backend's notification stream, reconnecting until stopped.
|
||||
*
|
||||
* A dropped connection is the ordinary case here rather than an error -- a phone changes
|
||||
* networks, the tunnel comes and goes, the backend restarts -- so it retries quietly and
|
||||
* forever. Nothing is shown when it cannot connect: a notification saying "I could not tell you
|
||||
* whether anything happened" on a phone in somebody's pocket is noise about a condition they
|
||||
* cannot act on, and the session list already says what is waiting when they next look.
|
||||
* A dropped connection is the ordinary case here rather than an error, so it retries quietly
|
||||
* and forever. Nothing is shown when it cannot connect: a notification saying "I could not tell
|
||||
* you whether anything happened" is noise about a condition nobody can act on, and the session
|
||||
* list already says what is waiting when they next look.
|
||||
*/
|
||||
private fun follow(settings: ServerSettings) {
|
||||
while (!stopping) {
|
||||
@@ -102,8 +100,8 @@ class NotificationService : Service() {
|
||||
try {
|
||||
connection.applyPinnedTls()
|
||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
// No read timeout, for the reason EventStream gives: between
|
||||
// notifications there is nothing to read, possibly for hours.
|
||||
// No read timeout, for the reason EventStream gives: between notifications there is
|
||||
// nothing to read, possibly for hours.
|
||||
connection.readTimeout = 0
|
||||
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
|
||||
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
|
||||
* 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
|
||||
* becomes something to clear rather than read.
|
||||
* question. A stack of stale rows is how a drawer becomes something to clear rather than read.
|
||||
*/
|
||||
private fun show(notification: SessionNotification) {
|
||||
// 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
|
||||
// what the screen is showing.
|
||||
// them is already saying it.
|
||||
if (isOnScreen(notification.sessionId)) return
|
||||
// The app is up: it says this itself, as a banner over whatever screen they are on. See
|
||||
// [forTheScreen]. Never both -- one thing happened, and a drawer filling up behind an
|
||||
// app that already showed you each one is a drawer nobody reads.
|
||||
// The app is up: it says this itself, as a banner over whatever screen they are on. Never
|
||||
// both -- one thing happened, and a drawer filling up behind an app that already showed you
|
||||
// each one is a drawer nobody reads.
|
||||
if (handOver(notification)) return
|
||||
val manager = NotificationManagerCompat.from(this)
|
||||
// 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
|
||||
// is reported anywhere -- the person said no, and saying it back to them through the
|
||||
// channel they closed is not available anyway.
|
||||
// refused, and notifications switched off for the app in Android's own settings.
|
||||
//
|
||||
// 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
|
||||
// every notification on Android 12 and below was silently dropped. Before 13 the
|
||||
// switch in Android's own settings, checked below, is the whole of the answer.
|
||||
// "denied" for a name it does not know, which read as the person having said no -- so every
|
||||
// notification on Android 12 and below was silently dropped.
|
||||
val allowed =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* the case lint's InlinedApi exists to catch. Zero is what ServiceCompat wants where types do
|
||||
* not apply.
|
||||
* compile time and would be handed to platforms that have no concept of it, which is what
|
||||
* lint's InlinedApi exists to catch.
|
||||
*/
|
||||
private fun foregroundType(): Int =
|
||||
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.
|
||||
*
|
||||
* The alerts are what somebody turned this on for, so they get the default importance and
|
||||
* whatever sound and heads-up display the person has chosen for the app. The ongoing one is
|
||||
* the platform's tax for staying connected, so it takes the lowest importance that exists.
|
||||
* Both are created before the service starts, since posting to a channel that does not
|
||||
* exist is silently dropped.
|
||||
* The alerts are what somebody turned this on for, so they get the default importance. The
|
||||
* ongoing one is the platform's tax for staying connected, so it takes the lowest
|
||||
* importance that exists. Both are created before the service starts, since posting to a
|
||||
* channel that does not exist is silently dropped.
|
||||
*/
|
||||
private fun createChannels(context: 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.
|
||||
*
|
||||
* 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
|
||||
* could be passed through. [showing] and [stoppedShowing] are the pair, both called from
|
||||
* the one composable that shows a session. Clearing names the session rather than setting
|
||||
* null outright, because moving from one session to another composes the new screen before
|
||||
* 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.
|
||||
* service and the composition draws the screen, so the two have no common owner. Clearing
|
||||
* names the session rather than setting null outright, because moving from one session to
|
||||
* another composes the new screen before the old one's coroutine is cancelled -- an
|
||||
* unconditional clear would throw away the new screen's claim.
|
||||
*/
|
||||
@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.
|
||||
*
|
||||
* 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
|
||||
* could be left saying the app is up after it has gone. `tryEmit` neither suspends nor
|
||||
* blocks the thread reading the stream, and the buffer is there so a handful of sessions
|
||||
* finishing together all land rather than the last one winning.
|
||||
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
|
||||
* nor blocks the thread reading the stream, and the buffer is there so a handful of
|
||||
* sessions finishing together all land rather than the last one winning.
|
||||
*/
|
||||
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. */
|
||||
fun showing(context: Context, sessionId: String) {
|
||||
onScreen = sessionId
|
||||
// Whatever was posted about it before is about to be read, so it has nothing left
|
||||
// to say -- and a row in the drawer for the conversation on screen is the same
|
||||
// duplication this whole rule is about.
|
||||
// Whatever was posted about it before is about to be read, so it has nothing left to
|
||||
// say.
|
||||
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 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
|
||||
* when an intent arrives rather than two.
|
||||
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look
|
||||
* at.
|
||||
*
|
||||
* 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
|
||||
@@ -345,9 +332,8 @@ data class SessionNotification(
|
||||
* 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
|
||||
* nothing to somebody reading a lock screen. One function because the same fact is now shown in two
|
||||
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift. The
|
||||
* banner colours the line as well, which is its own decision and stays with the drawing.
|
||||
* 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.
|
||||
*/
|
||||
fun attentionLine(kind: String): String =
|
||||
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
|
||||
* 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,
|
||||
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for the measurements that
|
||||
* bought; what matters here is that the pieces have to add up to the card that was there before, so
|
||||
* the fill, the corner radius and the padding all live in [peerSurface] rather than being written
|
||||
* out at each piece.
|
||||
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for what that bought;
|
||||
* what matters here is that the pieces have to add up to the card that was there before, so the
|
||||
* fill, the corner radius and the padding all live in [peerSurface].
|
||||
*/
|
||||
@Composable
|
||||
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].
|
||||
// Without this the card closes everywhere except on the text, which is most of it.
|
||||
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
|
||||
// The gap the card's own column used to provide between its heading and its prose,
|
||||
// and between one block and the next -- inside the piece, so the card's fill runs
|
||||
// through it.
|
||||
// The gap the card's own column used to provide between its heading and its prose, and
|
||||
// between one block and the next -- inside the piece, so the card's fill runs through
|
||||
// it.
|
||||
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.
|
||||
*
|
||||
* A filled Material card is elevation zero ([CardDefaults] takes it from `FilledCardTokens`, which
|
||||
* is `Level0`), so there is no shadow that a seam would show through -- which is the whole reason a
|
||||
* card can be cut up at all. Each piece paints the caller's container colour the way a
|
||||
* [androidx.compose .material3.Card] would and rounds only the corners at the ends of the message,
|
||||
* so the pieces abut into one continuous card. Shared by the two rows that are cut this way -- an
|
||||
* opened peer message and a long user message -- because two copies of the corner logic is how one
|
||||
* of them grows a seam.
|
||||
* A filled Material card is elevation zero, so there is no shadow that a seam would show through --
|
||||
* which is the whole reason a card can be cut up at all. Each piece paints the caller's container
|
||||
* colour and rounds only the corners at the ends of the message, so the pieces abut into one
|
||||
* continuous card. Shared by the two rows cut this way -- an 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 top of it, the bottom piece the bottom, and the middle pieces neither.
|
||||
* The padding is the other half: 12dp all round was the card's own, so the top piece keeps the top
|
||||
* of it, the bottom piece the bottom, and the middle pieces neither.
|
||||
*/
|
||||
@Composable
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* these are attached to is the message being typed -- which is why they sit here rather than
|
||||
* anywhere else on the screen.
|
||||
* find out *which* image was to send it. A control belongs with the thing it acts on.
|
||||
*
|
||||
* 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
|
||||
* is a tile of the same height carrying its name, since a name is all there is to show of it.
|
||||
* in it, so four attachments look like four of the same thing rather than four smaller ones.
|
||||
*/
|
||||
@Composable
|
||||
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
|
||||
* 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
|
||||
* the label is what says so, since nothing about the picture does.
|
||||
* corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip.
|
||||
*/
|
||||
@Composable
|
||||
private fun PendingThumbnail(
|
||||
@@ -84,8 +80,7 @@ private fun PendingThumbnail(
|
||||
.clip(shape)
|
||||
// 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
|
||||
// background the tile then had no edge at all, and the only thing saying an image was
|
||||
// attached was the cross drawn on top of nothing.
|
||||
// background the tile then had no edge at all.
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||
// Behind the picture as well as under a missing one, so the tile is a tile before
|
||||
// anything has arrived to fill it.
|
||||
@@ -105,9 +100,9 @@ private fun PendingThumbnail(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
// A spinner, as the transcript's images have: one appearance for "a picture
|
||||
// is on its way", learned once. An ellipsis had to be read as a spinner that
|
||||
// was not moving.
|
||||
// A spinner, as the transcript's images have: one appearance for "a picture is
|
||||
// on its way", learned once. An ellipsis had to be read as a spinner not
|
||||
// moving.
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
else ->
|
||||
@@ -118,14 +113,12 @@ private fun PendingThumbnail(
|
||||
modifier = Modifier.size(THUMBNAIL),
|
||||
)
|
||||
}
|
||||
// The whole square removes it, and this only says so. A cross small enough to sit in
|
||||
// the corner of a 64dp thumbnail is smaller than a fingertip, so making it the target
|
||||
// would be a control drawn at a size nobody can hit.
|
||||
// The whole square removes it, and this only says so. A cross small enough to sit in the
|
||||
// corner of a 64dp thumbnail is smaller than a fingertip.
|
||||
//
|
||||
// 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
|
||||
// to the corner hung the visible mark over the edge and put its backing somewhere the
|
||||
// eye reads as a second, misplaced square.
|
||||
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box to
|
||||
// the corner hung the visible mark over the edge.
|
||||
Box(
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
.padding(2.dp)
|
||||
|
||||
@@ -3,15 +3,13 @@ package com.example.aiapp
|
||||
import com.example.wgapplink.PinnedTls
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
// PINNED_CA_PEM is generated at build time from the CA on the machine doing
|
||||
// the build -- see the generatePinnedCert task in build.gradle.kts. It is
|
||||
// deliberately not a checked-in constant: the private key that signs against
|
||||
// it must never be anywhere this repo is, and an APK should pin whatever CA
|
||||
// the backend it was built for actually serves.
|
||||
// PINNED_CA_PEM is generated at build time from the CA on the machine doing the build -- see the
|
||||
// generatePinnedCert task in build.gradle.kts. It is deliberately not a checked-in constant: the
|
||||
// private key that signs against it must never be anywhere this repo is, and an APK should pin
|
||||
// whatever CA the backend it was built for actually serves.
|
||||
//
|
||||
// The pinning itself lives in wg-app-link, since dev-updater needs exactly
|
||||
// the same thing. What stays here is the one product-specific fact -- which
|
||||
// certificate this app pins.
|
||||
// The pinning itself lives in wg-app-link, since dev-updater needs exactly the same thing. What
|
||||
// stays here is which certificate this app pins.
|
||||
private val pinned = PinnedTls(PINNED_CA_PEM)
|
||||
|
||||
/** 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 --
|
||||
* 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
|
||||
* 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.
|
||||
* The colour is [rawSurface], which is also what a code block inside a reply is given.
|
||||
*/
|
||||
@Composable
|
||||
fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a
|
||||
// rounded rectangle drawn at the same radius as the rounded rectangle behind it reads
|
||||
// as a misprint rather than as nesting.
|
||||
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a rounded
|
||||
// rectangle drawn at the same radius as the one behind it reads as a misprint.
|
||||
.clip(MaterialTheme.shapes.extraSmall)
|
||||
.background(rawSurface)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
|
||||
@@ -4,17 +4,16 @@ import java.time.Duration
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
// 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
|
||||
// returns the span or the state on its own and leaves the wording to the caller.
|
||||
// arithmetic is the same in both, so everything here returns the span or the state on its own and
|
||||
// leaves the wording to the caller.
|
||||
|
||||
/**
|
||||
* "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
|
||||
* 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
|
||||
* start something was consistently told less headroom than they had. One rule, so the session bar
|
||||
* and the usage dialog cannot round a shared measurement two different ways.
|
||||
* figure on a minute it has already spent. One rule, so the session bar and the usage dialog cannot
|
||||
* round a shared measurement two different ways.
|
||||
*/
|
||||
fun formatSpan(until: Duration): String {
|
||||
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
|
||||
* 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
|
||||
* counting down and the API says so by omitting the field -- measured against a live response on
|
||||
* 2026-08-31, where the five-hour window's reset was exactly five hours after the moment work
|
||||
* 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.
|
||||
* counting down and the API says so by omitting the field. A timestamp that did arrive and could
|
||||
* not be read is the genuinely unknown case.
|
||||
*
|
||||
* 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
|
||||
* same field, quietly drew nothing. Two rules for one missing value; this is the rule.
|
||||
* same field, quietly drew nothing.
|
||||
*/
|
||||
sealed class WindowEnd {
|
||||
/** 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
|
||||
* 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.
|
||||
* The row key looks stable and is not: a tool row is named after its run, `joinPages` gives a run
|
||||
* the name of its newest half, and the newest half is whatever the newest page happened to start
|
||||
* with -- so an active session renames its tool runs every time it is reopened, and an anchor
|
||||
* naming one is never found. A seq is the server's own numbering, assigned once and never moved.
|
||||
* fetched newest-first. The row key looks stable and is not: a tool row is named after its run,
|
||||
* `joinPages` gives a run the name of its newest half, and the newest half is whatever the newest
|
||||
* page started with -- so an active session renames its tool runs every time it is reopened. 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
|
||||
* [offset] how far that unit was scrolled past the viewport's newest edge, in pixels. A seq alone
|
||||
* is not a place: a reply is one seq and can be forty blocks long, and a reader stopped halfway
|
||||
* down it is put back at that block, not at the reply.
|
||||
* [unit] is which unit of the row the viewport started at and [offset] how far that unit was
|
||||
* scrolled past the viewport's newest edge. A seq alone is not a place: a reply is one seq and can
|
||||
* be forty blocks long.
|
||||
*/
|
||||
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
|
||||
* 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
|
||||
* surprise rather than a convenience.
|
||||
* somebody's hand is pointed.
|
||||
*/
|
||||
fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
|
||||
val stored =
|
||||
@@ -36,8 +33,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
|
||||
val fields = stored.split(':')
|
||||
val seq = fields.getOrNull(0)?.toLongOrNull() ?: 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
|
||||
// closest older place -- the same choice [unitIndexFor] makes when a unit is gone.
|
||||
// Positions saved before the unit was recorded name the row's oldest unit, which is the closest
|
||||
// older place -- the same choice [unitIndexFor] makes when a unit is gone.
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* case. A session *deleted* while it held an anchor leaves its key behind, for the reason and at
|
||||
* the cost `Drafts.kt` describes.
|
||||
* left at the bottom has nothing to restore. A session *deleted* while it held an anchor leaves its
|
||||
* key behind, for the reason and at the cost `Drafts.kt` describes.
|
||||
*/
|
||||
fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) {
|
||||
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.
|
||||
*
|
||||
* Both values are load-bearing and neither may be changed casually. The scheme is what routes a
|
||||
* scanned QR here rather than to Dev Updater, and the key alias names the Android Keystore key the
|
||||
* token is already sealed under on every enrolled phone -- changing it would leave those phones
|
||||
* reading as not enrolled, with no error to explain why.
|
||||
* Both values are load-bearing. The scheme is what routes a scanned QR here rather than to Dev
|
||||
* Updater, and the key alias names the Android Keystore key the token is already sealed under on
|
||||
* every enrolled phone -- changing it would leave those phones reading as not enrolled.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* Two places can carry the same fact and only one of them is right at a time. A row in the shade is
|
||||
* for somebody looking at something else: it makes a sound, it waits however long it has to, and
|
||||
* acting on it means leaving whatever they were doing. Somebody with this app open needs none of
|
||||
* that -- they are already here, and what a tap on the notification would have done is what a tap
|
||||
* on this does. So while these are on screen the stream is delivered here instead, which is
|
||||
* arranged by the collection below and nothing else; see `NotificationService.forTheScreen`.
|
||||
* Two places can carry the same fact and only one is right at a time. A row in the shade is for
|
||||
* somebody looking at something else: it makes a sound, it waits however long it has to, and acting
|
||||
* on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
|
||||
* while these are on screen the stream is delivered here instead, which is arranged by the
|
||||
* collection below and nothing else.
|
||||
*
|
||||
* A banner can go three ways, and each is somebody deciding something different: tapped, which
|
||||
* opens the session; pushed off either side; or left alone, in which case it goes by itself when
|
||||
* the bar across its foot runs out.
|
||||
* A banner can go three ways, each somebody deciding something different: tapped, which opens the
|
||||
* session; pushed off either side; or left alone, in which case it goes when the bar runs out.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) {
|
||||
@@ -58,28 +56,26 @@ fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Mod
|
||||
arrivals++
|
||||
val alert = SessionAlert(notification, arrivals)
|
||||
// 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
|
||||
// asked a question is one thing to know about, the question. It keeps its
|
||||
// place in the queue rather than moving to the end, because the reader may
|
||||
// already be reaching for it.
|
||||
// drawer follows: a session that finished and then asked a question is one
|
||||
// thing to know about, the question. It keeps its place in the queue rather
|
||||
// than moving to the end, because the reader may already be reaching for it.
|
||||
val already = queue.indexOfFirst {
|
||||
it.notification.sessionId == notification.sessionId
|
||||
}
|
||||
if (already >= 0) queue[already] = alert else queue.add(alert)
|
||||
}
|
||||
} finally {
|
||||
// Leaving the app hands the job back to the drawer, so nothing arriving while it
|
||||
// is away is lost. What would be lost is the truth of what is already up: these
|
||||
// say a session wants somebody *now*, and one still sitting here on a return
|
||||
// several minutes later is a claim nobody checked. Frozen, too -- Compose stops
|
||||
// the clock with the window, so the timer that was going to retire it has been
|
||||
// standing still the whole time.
|
||||
// Leaving the app hands the job back to the drawer, so nothing arriving while it is
|
||||
// away is lost. What would be lost is the truth of what is already up: these say a
|
||||
// session wants somebody *now*, and one still sitting here on a return several
|
||||
// minutes later is a claim nobody checked. Frozen, too -- Compose stops the clock
|
||||
// with the window.
|
||||
queue.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Oldest at the top, so a new one appears below the ones already being read instead of
|
||||
// shoving them down the screen mid-reach.
|
||||
// Oldest at the top, so a new one appears below the ones already being read instead of shoving
|
||||
// them down the screen mid-reach.
|
||||
Column(modifier.fillMaxWidth().padding(8.dp)) {
|
||||
queue.forEach { alert ->
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
* would be two accounts of the same countdown and only one can be the one that fires.
|
||||
*/
|
||||
@Composable
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
// relying on colour alone reads as one more row in the way. The border is the one cue.
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
|
||||
) {
|
||||
@@ -145,8 +137,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
|
||||
Text(
|
||||
alert.notification.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
// One line, cut at the tail: a session is identified by the start of its
|
||||
// name, and a banner that grew with the name would move the one below it.
|
||||
// One line, cut at the tail: a session is identified by the start of its name,
|
||||
// and a banner that grew with the name would move the one below it.
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -163,9 +155,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
|
||||
LinearProgressIndicator(
|
||||
progress = { life.value },
|
||||
// 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,
|
||||
// which is the card's own colour so that the spent part reads as empty rather
|
||||
// than as a second bar.
|
||||
// judgement on it. Stated beside the track, which is the card's own colour so that
|
||||
// the spent part reads as empty rather than as a second bar.
|
||||
color = progressColor,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
drawStopIndicator = {},
|
||||
@@ -180,7 +171,6 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
|
||||
* 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
|
||||
* while somebody is still on the screen that produced them. The bar makes the number visible, so
|
||||
* this is a duration the reader can watch rather than one they have to learn.
|
||||
* while somebody is still on the screen that produced them. The bar makes the number visible.
|
||||
*/
|
||||
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
|
||||
* 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 written twice.
|
||||
* 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
|
||||
* written twice.
|
||||
*/
|
||||
@Composable
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* somebody was looking at it -- and in a bottom-anchored list, images loading above the viewport
|
||||
* moved the text under the reader's eyes. Reserving the final height makes loading invisible, which
|
||||
* is what it should be.
|
||||
* finished loading pushed everything below it, so a transcript being read scrolled itself -- and in
|
||||
* a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes.
|
||||
*
|
||||
* 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
|
||||
* here. [onOpen] hands the ref to the screen, which draws [SessionImageViewer] outside the list;
|
||||
* see that function for the reason.
|
||||
* than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the
|
||||
* screen, which draws [SessionImageViewer] outside the list.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionImage(
|
||||
@@ -97,9 +93,8 @@ fun SessionImage(
|
||||
val heightPx = with(LocalDensity.current) { height.roundToPx() }
|
||||
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
||||
when (val image = bitmap) {
|
||||
// Two states, not one: an image still arriving and an image that will never arrive
|
||||
// look nothing alike to a reader who can do something about the second. So one gets a
|
||||
// spinner in the space the picture is about to fill, and the other gets words.
|
||||
// Two states, not one: an image still arriving and an image that will never arrive look
|
||||
// nothing alike to a reader who can do something about the second.
|
||||
null ->
|
||||
if (failed) {
|
||||
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
|
||||
* 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
|
||||
* transcript because the session made another tool call. The same happens to a row regrouped by a
|
||||
* page of history landing.
|
||||
* transcript because the session made another tool call.
|
||||
*
|
||||
* Held by the screen, none of that reaches it: what is open is a property of the screen, not of
|
||||
* whichever row happened to draw the thumbnail.
|
||||
* Held by the screen, none of that reaches it: what is open is a property of the screen.
|
||||
*
|
||||
* 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
|
||||
* asked to see, and the loading and unavailable states below are the same two the thumbnail draws.
|
||||
* through. Paid deliberately: it is one request for a picture somebody asked to see.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionImageViewer(
|
||||
@@ -157,9 +149,9 @@ fun SessionImageViewer(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when (val image = bitmap) {
|
||||
// Two states, not one, exactly as the thumbnail has them: still coming, and never
|
||||
// coming. Stated in white because this box paints its own black behind them and a
|
||||
// theme colour would be picked against a surface that is not there.
|
||||
// Two states, not one, exactly as the thumbnail has them. Stated in white because
|
||||
// this box paints its own black behind them and a theme colour would be picked
|
||||
// against a surface that is not there.
|
||||
null ->
|
||||
if (failed) {
|
||||
Text(
|
||||
@@ -170,8 +162,7 @@ fun SessionImageViewer(
|
||||
} else {
|
||||
// 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
|
||||
// beside it are: this box paints its own black, and a theme colour would
|
||||
// be chosen against a surface that is not there.
|
||||
// beside it are.
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
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
|
||||
* [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
|
||||
* thing to "the size of it" that can be drawn before knowing.
|
||||
* placeholder would promise a picture wider than most turn out to be.
|
||||
*
|
||||
* Tinted, so the reader can see that something is being kept for a picture. That is also what
|
||||
* distinguishes it from the failure beside it, which is words on the ordinary surface.
|
||||
* 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, words on the ordinary surface.
|
||||
*/
|
||||
@Composable
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* height would be wrong.
|
||||
* changes -- including when the reader has scaled fonts up, which is when a hardcoded height is
|
||||
* wrong.
|
||||
*/
|
||||
@Composable
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* per image rather than a preference set once.
|
||||
* image with hard pixel edges stays readable. Shrinking wants the opposite.
|
||||
*/
|
||||
private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality =
|
||||
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
|
||||
* 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
|
||||
private fun ZoomableImage(image: ImageBitmap) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -9,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -17,6 +20,7 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -30,6 +34,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -47,30 +53,53 @@ fun SessionListScreen(
|
||||
settings: ServerSettings,
|
||||
reloadToken: Int,
|
||||
onOpen: (SessionSummary) -> Unit,
|
||||
/** Opens one session's subagent, from the expander under its card. */
|
||||
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
|
||||
onSpawn: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
||||
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
|
||||
|
||||
// Failures that belong to one session rather than to the list, keyed by
|
||||
// its id and shown on its own card. The two scopes are decided by
|
||||
// whether the server answered: it answered and refused, so this says
|
||||
// nothing about the other rows, where a server that has stopped
|
||||
// answering leaves every row stale and is `listState`'s to report.
|
||||
// Which session cards are expanded to show their subagents, and what each expansion fetched.
|
||||
// Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt
|
||||
// from
|
||||
// whatever the server last said, and this belongs to the reader's own choice, which survives a
|
||||
// refresh.
|
||||
var expandedSessions by remember { mutableStateOf(setOf<String>()) }
|
||||
var subagentLoads by remember {
|
||||
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
|
||||
}
|
||||
|
||||
fun loadSubagents(sessionId: String) {
|
||||
subagentLoads = subagentLoads + (sessionId to LoadState.Loading)
|
||||
scope.launch {
|
||||
subagentLoads =
|
||||
subagentLoads +
|
||||
(sessionId to
|
||||
try {
|
||||
LoadState.Loaded(
|
||||
withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) }
|
||||
)
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
|
||||
// own card. The two scopes are decided by whether the server answered: it answered and refused,
|
||||
// so this says nothing about the other rows.
|
||||
//
|
||||
// Cleared on the next successful load below -- an entry outlives its
|
||||
// session otherwise, and would reappear against whatever the phone
|
||||
// fetched next.
|
||||
// Cleared on the next successful load below -- an entry outlives its session otherwise.
|
||||
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,
|
||||
// because the rows are rebuilt from whatever the server last said and this belongs to the
|
||||
// request rather than to the session.
|
||||
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, because
|
||||
// the rows are rebuilt from whatever the server last said and this belongs to the request.
|
||||
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.
|
||||
// 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)) }
|
||||
|
||||
@@ -83,13 +112,20 @@ fun SessionListScreen(
|
||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
|
||||
deleteErrors = emptyMap()
|
||||
// The path out for a cached transcript whose session was deleted somewhere
|
||||
// else -- from another device, or at the backend. This list is the only place
|
||||
// that ever learns the full set, and what the residue costs here is megabytes
|
||||
// rather than a draft's few bytes. On the answer rather than in `finally`: a
|
||||
// list that failed to arrive says nothing about which sessions exist.
|
||||
// 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())
|
||||
}
|
||||
// A session gone from this answer cannot still be expanded, and an expanded one
|
||||
// that is still here asks again -- its subagents may have changed since the
|
||||
// last
|
||||
// fetch.
|
||||
val ids = loaded.value.map { it.id }.toSet()
|
||||
expandedSessions = expandedSessions intersect ids
|
||||
subagentLoads = subagentLoads.filterKeys { it in ids }
|
||||
expandedSessions.forEach(::loadSubagents)
|
||||
loaded
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
@@ -103,12 +139,9 @@ fun SessionListScreen(
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
when (val state = listState) {
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
// The message as Api.kt wrote it, with nothing added: it is
|
||||
// already a whole sentence naming the address and what to
|
||||
// check, so a prefix here read "Couldn't reach the server:
|
||||
// Couldn't reach the server at ...". It was also a guess --
|
||||
// a delete that the server itself refused had reached it
|
||||
// fine.
|
||||
// The message as Api.kt wrote it, with nothing added: it is already a whole
|
||||
// sentence naming the address and what to check, so a prefix here read "Couldn't
|
||||
// reach the server: Couldn't reach the server at ...".
|
||||
is LoadState.Error ->
|
||||
Text(
|
||||
state.message,
|
||||
@@ -122,8 +155,7 @@ fun SessionListScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Awaiting-answer first (the point of the screen), then
|
||||
// most recently active.
|
||||
// Awaiting-answer first (the point of the screen), then most recently active.
|
||||
val ordered =
|
||||
state.value.sortedWith(
|
||||
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
|
||||
@@ -137,6 +169,17 @@ fun SessionListScreen(
|
||||
deleting = session.id in deleting,
|
||||
onOpen = { onOpen(session) },
|
||||
onLongPress = { confirmingDelete = session },
|
||||
expanded = session.id in expandedSessions,
|
||||
subagents = subagentLoads[session.id],
|
||||
onToggleSubagents = {
|
||||
if (session.id in expandedSessions) {
|
||||
expandedSessions = expandedSessions - session.id
|
||||
} else {
|
||||
expandedSessions = expandedSessions + session.id
|
||||
loadSubagents(session.id)
|
||||
}
|
||||
},
|
||||
onOpenSubagent = { subagent -> onOpenSubagent(session, subagent) },
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
@@ -155,40 +198,37 @@ fun SessionListScreen(
|
||||
|
||||
confirmingDelete?.let { session ->
|
||||
// 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) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmingDelete = null },
|
||||
title = { Text("Delete \"${session.title}\"?") },
|
||||
text = {
|
||||
// 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
|
||||
// conversation -- the Claude Code CLI does, under ~/.claude/projects, whether
|
||||
// this app spawned the session or imported it; echo and llama.cpp do not, and
|
||||
// for those the app's transcript is the only copy there is.
|
||||
// separates them is whether the *driver* keeps its own record of the conversation
|
||||
// -- the Claude Code CLI does, whether this app spawned the session or imported it;
|
||||
// echo and llama.cpp do not.
|
||||
//
|
||||
// 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
|
||||
// claude-cli session this app spawned, and the two warnings disagreed about
|
||||
// sessions that were equally recoverable. Getting it wrong in that direction
|
||||
// is the expensive one: "this can't be undone", said of something that can,
|
||||
// spends the credibility the sentence needs on the sessions where it is true.
|
||||
// started here has no copy anywhere". That was false for every claude-cli session
|
||||
// this app spawned, and getting it wrong in that direction is the expensive one:
|
||||
// "this can't be undone", said of something that can, spends the credibility the
|
||||
// sentence needs.
|
||||
//
|
||||
// Neither branch promises a restore. The recoverable one says what is known --
|
||||
// the driver keeps its own record -- rather than that the file is still there,
|
||||
// which nothing here checked; and it names what goes either way, because this
|
||||
// app's transcript holds images, peer messages and commands that the CLI's own
|
||||
// record never had.
|
||||
// Neither branch promises a restore. The recoverable one says what is known -- the
|
||||
// driver keeps its own record -- rather than that the file is still there, and it
|
||||
// names what goes either way, because this app's transcript holds images, peer
|
||||
// messages and commands the CLI's own record never had.
|
||||
Column {
|
||||
Text(
|
||||
when {
|
||||
!session.keepsOwnTranscript ->
|
||||
"Kills the process and deletes the conversation. Nothing else " +
|
||||
"keeps a copy, so this can't be undone."
|
||||
// The sentence below is the one the toggle makes false, which is why
|
||||
// it is written twice rather than appended to: leaving "should still
|
||||
// be there to import again" on screen beside a switch that removes it
|
||||
// is the reassurance being read at the moment it stops being true.
|
||||
// The sentence below is the one the toggle makes false, which is why it
|
||||
// is written twice rather than appended to: leaving "should still be
|
||||
// there to import again" on screen beside a switch that removes it is
|
||||
// the reassurance being read at the moment it stops being true.
|
||||
alsoDeleteForeign ->
|
||||
"Kills the process and deletes both copies of the conversation: " +
|
||||
"this app's, and Claude Code's own transcript on the " +
|
||||
@@ -203,12 +243,12 @@ fun SessionListScreen(
|
||||
)
|
||||
// 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
|
||||
// llama.cpp there is no other transcript, and a switch offering to delete
|
||||
// one would be asking about something that does not exist.
|
||||
// llama.cpp there is no other transcript, and a switch offering to delete one
|
||||
// would be asking about something that does not exist.
|
||||
if (session.keepsOwnTranscript) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// Its own row rather than beside the paragraph: a switch is taller than
|
||||
// a line of text and re-centres whatever shares a row with it.
|
||||
// Its own row rather than beside the paragraph: a switch is taller than a
|
||||
// line of text and re-centres whatever shares a row with it.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Delete Claude Code's transcript too",
|
||||
@@ -229,8 +269,7 @@ fun SessionListScreen(
|
||||
onClick = {
|
||||
confirmingDelete = null
|
||||
// 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
|
||||
// is the whole of what this state is for.
|
||||
// something is happening to it from the moment it is asked for.
|
||||
deleting = deleting + session.id
|
||||
deleteErrors = deleteErrors - session.id
|
||||
scope.launch {
|
||||
@@ -239,12 +278,11 @@ fun SessionListScreen(
|
||||
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()
|
||||
transcriptCache.session(TranscriptAddress(session.id)).purge()
|
||||
}
|
||||
// Only this row, and only what changed. Refetching the list
|
||||
// instead put every other session back through loading and
|
||||
// handed the reader an empty screen -- to report on something
|
||||
// that was never in doubt.
|
||||
// Only this row, and only what changed. Refetching the list instead
|
||||
// put every other session back through loading and handed the
|
||||
// reader an empty screen, to report on something never in doubt.
|
||||
val loaded = listState
|
||||
if (loaded is LoadState.Loaded) {
|
||||
listState =
|
||||
@@ -263,8 +301,8 @@ fun SessionListScreen(
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Coloured by consequence: this takes something away, and does so wherever
|
||||
// it appears -- the same rule the import screen's Delete follows.
|
||||
// Coloured by consequence: this takes something away, and does so wherever it
|
||||
// appears -- the same rule the import screen's Delete follows.
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
@@ -286,18 +324,23 @@ private fun SessionCard(
|
||||
*
|
||||
* 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
|
||||
* about a request that has not been answered yet, and putting it back when the server refuses
|
||||
* is worse than never having taken it away.
|
||||
* about a request that has not been answered yet.
|
||||
*/
|
||||
deleting: Boolean,
|
||||
onOpen: () -> Unit,
|
||||
onLongPress: () -> Unit,
|
||||
/** Whether the expander below is open. Collapsed by default; see [SessionListScreen]. */
|
||||
expanded: Boolean,
|
||||
/** What the expander's own fetch answered, or null before it has been asked. */
|
||||
subagents: LoadState<List<SubagentSummary>>?,
|
||||
onToggleSubagents: () -> Unit,
|
||||
onOpenSubagent: (SubagentSummary) -> Unit,
|
||||
) {
|
||||
BusyItem(label = if (deleting) "deleting" else null) {
|
||||
Card(
|
||||
// Off while the delete is in flight: a card that still opens a session it is
|
||||
// deleting is a race the reader can start by tapping. On the card rather than in
|
||||
// [BusyItem], which leaves gestures alone so the list still scrolls.
|
||||
// Off while the delete is in flight: a card that still opens a session it is deleting
|
||||
// is a race the reader can start by tapping. On the card rather than in [BusyItem],
|
||||
// which leaves gestures alone so the list still scrolls.
|
||||
Modifier.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
enabled = !deleting,
|
||||
@@ -320,9 +363,9 @@ private fun SessionCard(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
// Machine, then what runs on it, then what it is set to: the same order
|
||||
// and separator as the session screen's header and the usage dialog, so
|
||||
// one pair of facts is not written three ways.
|
||||
// Machine, then what runs on it, then what it is set to: the same order and
|
||||
// separator as the session screen's header and the usage dialog, so one
|
||||
// pair of facts is not written three ways.
|
||||
listOfNotNull(
|
||||
session.setupName,
|
||||
session.provider,
|
||||
@@ -341,18 +384,111 @@ private fun SessionCard(
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// The server's own words, unprefixed, the way every other
|
||||
// failure in this app is shown.
|
||||
// The server's own words, unprefixed, the way every other failure is shown.
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
// Nothing at all for a card with no subagents: a disabled expander here would be
|
||||
// noise on every ordinary session's card. Its own row at the bottom rather than
|
||||
// beside the title or the machine line, so opening it never displaces text that was
|
||||
// already on screen -- see UI_RULES on a control not displacing the text beside it.
|
||||
if (session.subagents > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// The platform's minimum touch height, not the chevron's own ten or so dp:
|
||||
// at the chevron's height a tap meant for it landed on the first subcard
|
||||
// beneath and opened a subagent instead.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.clickable(enabled = !deleting, onClick = onToggleSubagents)
|
||||
.semantics {
|
||||
contentDescription =
|
||||
if (expanded) "Collapse subagents" else "Expand subagents"
|
||||
},
|
||||
) {
|
||||
Chevron(if (expanded) Pointing.Up else Pointing.Down)
|
||||
}
|
||||
if (expanded) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
when (subagents) {
|
||||
null,
|
||||
is LoadState.Loading ->
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(20.dp).height(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
is LoadState.Error ->
|
||||
// Said here rather than left silent: a fetch that failed and an
|
||||
// expander that simply found nothing must not look the same --
|
||||
// see UI_RULES on designing the unknown state first.
|
||||
Text(
|
||||
subagents.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
is LoadState.Loaded ->
|
||||
subagents.value.forEach { subagent ->
|
||||
SubagentCard(
|
||||
subagent,
|
||||
onClick = { onOpenSubagent(subagent) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One subagent, indented inside its session's card -- the way dev-updater draws a project's
|
||||
* components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own
|
||||
* filled one, so the nesting reads as one step rather than as another session.
|
||||
*/
|
||||
@Composable
|
||||
private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
|
||||
OutlinedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
subagentStatusLabel(subagent.status),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
relativeTime(subagent.lastActivity),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
|
||||
* rather than a branch inside [StatusText], because a subagent's three states are not that
|
||||
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
|
||||
* and never something of its own to have merely stopped.
|
||||
*/
|
||||
private fun subagentStatusLabel(status: String) =
|
||||
when (status) {
|
||||
"running" -> "running"
|
||||
"exited" -> "finished"
|
||||
else -> "unknown"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatusText(status: String) {
|
||||
@@ -362,16 +498,16 @@ fun StatusText(status: String) {
|
||||
"running" -> "running" to runningColor
|
||||
"compacting" -> "compacting" to commandColor
|
||||
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
// Said in words, because it differs in kind from the others rather than in degree:
|
||||
// the session is not idle and has not exited, nobody has been able to find out
|
||||
// which. A muted colour alone would read as one of the quiet states.
|
||||
// Said in words, because it differs in kind from the others rather than in degree: the
|
||||
// session is not idle and has not exited, nobody has been able to find out which. A
|
||||
// muted colour alone would read as one of the quiet states.
|
||||
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (sessionWorking(status)) {
|
||||
// The same colour as the word beside it: the two are one signal, and a spinner in
|
||||
// the theme's accent says the state is something other than what the label says.
|
||||
// The same colour as the word beside it: the two are one signal, and a spinner in the
|
||||
// theme's accent says the state is something other than what the label says.
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(14.dp).height(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -26,6 +28,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -39,14 +45,11 @@ import kotlinx.coroutines.withContext
|
||||
* 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
|
||||
* are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs
|
||||
* with the thing it acts on.
|
||||
* are changed *while* reading a turn -- "not this model, try that one".
|
||||
*
|
||||
* Captions are for what a control costs rather than for what it is. Each control is a labelled noun
|
||||
* with a switch or a field beside it, and a paragraph under every one of them made the dialog
|
||||
* longer than the conversation it covers -- so Notifications has none, while Move and Reload do,
|
||||
* because what those two take away is not visible from here. Failures get their words for the same
|
||||
* reason: they are what the reader cannot work out by looking.
|
||||
* Captions are for what a control costs rather than for what it is. A paragraph under every control
|
||||
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
|
||||
* and Reload do, because what those two take away is not visible from here.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionSettingsDialog(
|
||||
@@ -57,6 +60,16 @@ fun SessionSettingsDialog(
|
||||
*/
|
||||
title: String,
|
||||
onRenamed: (String) -> Unit,
|
||||
/**
|
||||
* How hard the model thinks, as the session reports it, or null for the CLI's own default.
|
||||
*
|
||||
* Taken from the row this dialog was opened over rather than fetched, because unlike the
|
||||
* notification switch there is nothing else that changes it: the level is this app's to set and
|
||||
* the server does not resolve it into something else.
|
||||
*/
|
||||
effort: String?,
|
||||
/** Whether a level does anything here; the row is left out entirely where it does not. */
|
||||
takesEffort: Boolean,
|
||||
/**
|
||||
* 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.
|
||||
@@ -66,26 +79,43 @@ fun SessionSettingsDialog(
|
||||
onDismiss: () -> Unit,
|
||||
/**
|
||||
* 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,
|
||||
/**
|
||||
* Runs P0's scripted scroll-and-stream benchmark and copies the extended report, or null on
|
||||
* every build but `bench` -- see [BuildConfig.FIXTURE_MODE] and BenchRun.kt. Null rather than
|
||||
* always-present-but-disabled: this has no meaning at all outside the bench build, and a
|
||||
* control with nothing behind it on every other build is not a state worth drawing.
|
||||
*/
|
||||
onRunBenchmark: (() -> Unit)? = null,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by remember(sessionId) { mutableStateOf(title) }
|
||||
var level by remember(sessionId) { mutableStateOf(effort) }
|
||||
var effortError by remember { mutableStateOf<String?>(null) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
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
|
||||
// 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
|
||||
// nothing to say so. Until the answer arrives the switch is disabled and a spinner sits beside
|
||||
// it, which is what not knowing looks like: distinguishable from off, and from a refusal.
|
||||
// position that may have been changed since. Until the answer arrives the switch is disabled
|
||||
// and a spinner sits beside it, which is what not knowing looks like.
|
||||
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
||||
var notifyError by remember { mutableStateOf<String?>(null) }
|
||||
// Where the session works. Null until the server has been asked, for the same reason the
|
||||
// switch above is: the row this dialog opened over is a snapshot, and a path drawn from it
|
||||
// could be one somebody changed from another device. An empty answer is a session that was
|
||||
// never given a directory, which is not the same as one whose directory is unknown -- the
|
||||
// field is only enabled once one of those two is settled.
|
||||
// The same three-state shape the notification switch has, for the same reason: until the
|
||||
// server has answered, the switch is disabled rather than showing a position nothing confirmed.
|
||||
var autoResume by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
||||
var resumeMessage by remember(sessionId) { mutableStateOf(DEFAULT_RESUME_MESSAGE) }
|
||||
// When the server next intends to ask whether the limit has lifted, or null when nothing is
|
||||
// waiting. Read once with everything else: it moves on the server's schedule, not this
|
||||
// screen's, and a figure that redrew itself here would be this app re-measuring what it was
|
||||
// told.
|
||||
var resumeAt by remember(sessionId) { mutableStateOf<Double?>(null) }
|
||||
var resumeError by remember { mutableStateOf<String?>(null) }
|
||||
// Where the session works. Null until the server has been asked, for the same reason the switch
|
||||
// above is. An empty answer is a session that was never given a directory, which is not the
|
||||
// same as one whose directory is unknown -- the field is only enabled once one of those is
|
||||
// settled.
|
||||
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
var typedCwd by remember(sessionId) { mutableStateOf("") }
|
||||
var cwdError by remember { mutableStateOf<String?>(null) }
|
||||
@@ -95,13 +125,18 @@ fun SessionSettingsDialog(
|
||||
try {
|
||||
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
||||
notify = fresh.notify
|
||||
autoResume = fresh.autoResume
|
||||
resumeMessage = fresh.autoResumeMessage
|
||||
resumeAt = fresh.resumeAt
|
||||
cwd = fresh.cwd.orEmpty()
|
||||
typedCwd = fresh.cwd.orEmpty()
|
||||
} catch (e: ApiException) {
|
||||
// Left unknown rather than falling back to the stale row: the switch stays
|
||||
// disabled, instead of offering a position nothing confirmed.
|
||||
// Left unknown rather than falling back to the stale row: the switch stays disabled,
|
||||
// instead of offering a position nothing confirmed.
|
||||
notifyError = e.message
|
||||
notify = null
|
||||
resumeError = e.message
|
||||
autoResume = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +165,29 @@ fun SessionSettingsDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses a thinking level, which ends the process the old level was launched with.
|
||||
*
|
||||
* Put back if the request is refused, for the reason the notification switch below gives: a
|
||||
* control that stays where it was put after a refusal is stating something untrue.
|
||||
*/
|
||||
fun setEffort(chosen: String?) {
|
||||
val was = level
|
||||
level = chosen
|
||||
effortError = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
|
||||
} catch (e: ApiException) {
|
||||
level = was
|
||||
effortError = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// tunnel, and one that stays moved after a refusal lies.
|
||||
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
|
||||
// and one that stays moved after a refusal lies.
|
||||
fun setNotify(wanted: Boolean) {
|
||||
val was = notify
|
||||
notify = wanted
|
||||
@@ -147,6 +202,39 @@ fun SessionSettingsDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns auto-resume on or off, or changes what it would say.
|
||||
*
|
||||
* One request for both, because the server takes one: switching it on and typing the message
|
||||
* are two halves of the same decision, and sending them separately would leave a moment where
|
||||
* the session is armed with the old words.
|
||||
*
|
||||
* Put back if refused, like the notification switch. Turning it off also clears what was
|
||||
* scheduled -- said here rather than only on the server, or the row would go on naming a time
|
||||
* that no longer exists.
|
||||
*/
|
||||
fun setAutoResume(on: Boolean, message: String) {
|
||||
val wasOn = autoResume
|
||||
val wasMessage = resumeMessage
|
||||
val wasAt = resumeAt
|
||||
autoResume = on
|
||||
resumeMessage = message
|
||||
if (!on) resumeAt = null
|
||||
resumeError = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
setSessionAutoResume(settings, sessionId, on, message)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
autoResume = wasOn
|
||||
resumeMessage = wasMessage
|
||||
resumeAt = wasAt
|
||||
resumeError = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing to do when the name has not changed, so the button says so rather than sending a
|
||||
// request whose success would look exactly like the failure of having typed nothing.
|
||||
val changed = name.trim().isNotEmpty() && name.trim() != title
|
||||
@@ -162,7 +250,7 @@ fun SessionSettingsDialog(
|
||||
onRenamed(chosen)
|
||||
} catch (e: ApiException) {
|
||||
// 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
|
||||
saving = false
|
||||
}
|
||||
@@ -173,7 +261,10 @@ fun SessionSettingsDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Session settings") },
|
||||
text = {
|
||||
Column {
|
||||
// Scrollable, because this dialog grew past a screenful: a Material dialog constrains
|
||||
// its own height and clips what does not fit, so the last control on the list is one
|
||||
// large system font away from being unreachable with nothing on screen to say so.
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
@@ -181,8 +272,8 @@ fun SessionSettingsDialog(
|
||||
singleLine = true,
|
||||
enabled = !saving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
// The keyboard's own action does what the button does: a one-field form
|
||||
// where the return key does nothing is a form people press return at anyway.
|
||||
// The keyboard's own action does what the button does: a one-field form where
|
||||
// the return key does nothing is a form people press return at anyway.
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { save() }),
|
||||
)
|
||||
@@ -207,8 +298,8 @@ fun SessionSettingsDialog(
|
||||
enabled = notify != null,
|
||||
)
|
||||
}
|
||||
// Beside the switch that failed, not with the rename's error: they are two
|
||||
// requests and a reader has to be able to tell which one the server refused.
|
||||
// Beside the switch that failed, not with the rename's error: they are two requests
|
||||
// and a reader has to be able to tell which one the server refused.
|
||||
notifyError?.let {
|
||||
Text(
|
||||
it,
|
||||
@@ -217,6 +308,70 @@ fun SessionSettingsDialog(
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Resume after a usage limit", modifier = Modifier.weight(1f))
|
||||
if (autoResume == null && resumeError == null) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(16.dp).height(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Switch(
|
||||
checked = autoResume == true,
|
||||
onCheckedChange = { setAutoResume(it, resumeMessage) },
|
||||
enabled = autoResume != null,
|
||||
)
|
||||
}
|
||||
// Disabled rather than hidden while the switch is off: a field that comes and goes
|
||||
// makes its own presence the signal, and a visible one teaches what the switch will
|
||||
// do. Committed on the keyboard's Done rather than on every keystroke, so typing a
|
||||
// sentence is one request instead of one per letter.
|
||||
OutlinedTextField(
|
||||
value = resumeMessage,
|
||||
onValueChange = { resumeMessage = it },
|
||||
label = { Text("Message to send") },
|
||||
// What an empty field means, in the field: the server's own word rather than a
|
||||
// session poked with nothing to read.
|
||||
placeholder = { Text(DEFAULT_RESUME_MESSAGE) },
|
||||
singleLine = true,
|
||||
enabled = autoResume == true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions =
|
||||
KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }),
|
||||
)
|
||||
// What it does and what it costs, in the order it happens. The last sentence is the
|
||||
// one that matters: the time below is when the server will *ask*, not a promise
|
||||
// about when the session speaks.
|
||||
Text(
|
||||
"When this session stops because the account is out of quota, the server " +
|
||||
"checks the limit and sends this message once it has lifted. It checks " +
|
||||
"again if the limit is still on.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Only where something is actually waiting. Absent is not a state worth a row: a
|
||||
// session that has not hit a limit has nothing scheduled, which the reader can see
|
||||
// from the switch.
|
||||
resumeAt?.let { at ->
|
||||
Text(
|
||||
"Waiting now -- next check ${formatCheckTime(at)}.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
resumeError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -225,9 +380,9 @@ fun SessionSettingsDialog(
|
||||
value = typedCwd,
|
||||
onValueChange = { typedCwd = it },
|
||||
label = { Text("Working directory") },
|
||||
// What the field cannot say by being empty: a session that was never
|
||||
// given one starts wherever its launcher does, and this names that
|
||||
// rather than showing a path nobody chose.
|
||||
// What the field cannot say by being empty: a session that was never given
|
||||
// one starts wherever its launcher does, and this names that rather than
|
||||
// showing a path nobody chose.
|
||||
placeholder = { Text("wherever the session was started") },
|
||||
singleLine = true,
|
||||
enabled = cwd != null && !movingCwd,
|
||||
@@ -247,9 +402,8 @@ fun SessionSettingsDialog(
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// under a running session -- it is ended, and the next thing said to the session
|
||||
// starts it in the new place.
|
||||
// directory is settled when the process is spawned, so it is ended and the next
|
||||
// thing said to the session starts it in the new place.
|
||||
Text(
|
||||
"Moving stops the session's process. It starts again in the new directory " +
|
||||
"with the next message, or with Start.",
|
||||
@@ -263,16 +417,54 @@ fun SessionSettingsDialog(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
// Left out rather than disabled, the one place this dialog does that: a disabled
|
||||
// control teaches what the thing can do, and a llama session cannot do this at all
|
||||
// -- the row would be teaching something false about it.
|
||||
if (takesEffort) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Thinking", modifier = Modifier.weight(1f))
|
||||
PickerButton(
|
||||
current = level ?: DEFAULT_EFFORT,
|
||||
// The level the CLI picks for itself is in the list as well as in the
|
||||
// button, so leaving a level is not a one-way trip -- the same
|
||||
// correction the model picker carries.
|
||||
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
||||
onPick = { chosen ->
|
||||
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
|
||||
},
|
||||
)
|
||||
}
|
||||
// What it costs, said where it is about to be pressed, like Move above: the
|
||||
// CLI reads the level when it launches and has no control request for
|
||||
// changing one.
|
||||
Text(
|
||||
"Changing this stops the session's process. It starts again with the " +
|
||||
"next message, or with Start.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
effortError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
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.
|
||||
// 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(
|
||||
@@ -323,10 +515,25 @@ fun SessionSettingsDialog(
|
||||
Text("Render timings", modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
||||
}
|
||||
// Bench-build only: see [onRunBenchmark]. Named exactly "Run benchmark" because
|
||||
// ui-trace and the emulator smoke run find it by that label, the same way every
|
||||
// other control here is found -- see AGENTS.md's "Driving the UI".
|
||||
onRunBenchmark?.let { run ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("P0 benchmark", modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = run) { Text("Run benchmark") }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Disabled rather than absent while there is nothing to save: a button that comes and
|
||||
// goes makes its own presence the signal, and its absence cannot say why.
|
||||
// Disabled rather than absent while there is nothing to save: a button that comes and goes
|
||||
// makes its own presence the signal, and its absence cannot say why.
|
||||
confirmButton = {
|
||||
TextButton(onClick = { save() }, enabled = changed && !saving) {
|
||||
Text(if (saving) "Saving..." else "Save")
|
||||
@@ -335,3 +542,21 @@ fun SessionSettingsDialog(
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* When the server will next look, as a local time.
|
||||
*
|
||||
* A time rather than a countdown, for the reason the transcript's own limit row gives: this screen
|
||||
* reads the figure once, and a span drawn from a value nothing refreshes goes stale while somebody
|
||||
* is looking at it.
|
||||
*/
|
||||
private fun formatCheckTime(epochSeconds: Double): String =
|
||||
try {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
.withZone(ZoneId.systemDefault())
|
||||
.format(Instant.ofEpochSecond(epochSeconds.toLong()))
|
||||
} catch (_: Exception) {
|
||||
// A time that cannot be read is not a time to show: the sentence above still says a check
|
||||
// is coming, which is the part the reader can act on.
|
||||
"soon"
|
||||
}
|
||||
@@ -34,20 +34,19 @@ sealed class SessionUsage {
|
||||
/**
|
||||
* 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
|
||||
* on a local llama.cpp has no paid quota at all, which is a fact about how it was set up and
|
||||
* not a failure to find something out. The backend never asks such a machine, so it returns no
|
||||
* snapshot for it -- and reading that silence as "couldn't find out" is exactly the mistake of
|
||||
* answering with the nearest available word. Drawn as nothing, because there is nothing.
|
||||
* Separate from [Unavailable], and the distinction is the point: a session on `echo` or on a
|
||||
* local llama.cpp has no paid quota at all, which is a fact about how it was set up and not a
|
||||
* failure to find something out. The backend never asks such a machine, and reading that
|
||||
* silence as "couldn't find out" is answering with the nearest available word.
|
||||
*/
|
||||
data object NotMetered : SessionUsage()
|
||||
|
||||
/**
|
||||
* 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
|
||||
* never share an appearance: a bar sitting at zero because a machine is unreachable reads as
|
||||
* plenty of headroom, which is the opposite of the truth.
|
||||
* Its own state because "we couldn't find out" and "none of it is used" must never share an
|
||||
* appearance: a bar sitting at zero because a machine is unreachable reads as plenty of
|
||||
* headroom.
|
||||
*/
|
||||
data class Unavailable(val why: String) : SessionUsage()
|
||||
}
|
||||
@@ -59,27 +58,33 @@ private const val REFRESH_MS = 60_000L
|
||||
* 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
|
||||
* of the button beside it, and the dialog that button opens -- and each of those used to fetch for
|
||||
* itself. Two fetches say one thing twice and then disagree about it: the bar's copy can be a whole
|
||||
* refresh interval old when the dialog opens with a fresh one, so the header read 42% while the
|
||||
* screen over it read 47%, about a number somebody is deciding on. One feed per screen, and
|
||||
* [refresh] moves both.
|
||||
* of the button beside it, and the dialog that button opens -- and each used to fetch for itself.
|
||||
* Two fetches say one thing twice and then disagree: the bar's copy can be a whole refresh interval
|
||||
* old when the dialog opens with a fresh one, so the header read 42% while the screen over it read
|
||||
* 47%.
|
||||
*/
|
||||
class UsageFeed(
|
||||
val snapshots: LoadState<List<UsageSnapshot>>,
|
||||
/**
|
||||
* A fetch is outstanding. Only ever true over an answer already shown; see [rememberUsageFeed].
|
||||
*/
|
||||
/** A fetch is outstanding. Only ever true over an answer already shown. */
|
||||
val refreshing: Boolean,
|
||||
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
||||
val refresh: () -> Unit,
|
||||
) {
|
||||
/** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */
|
||||
fun forSetup(setup: String): SessionUsage =
|
||||
when (val state = snapshots) {
|
||||
/**
|
||||
* What meters [session], and what that meter came back as. See [usageFor] for the states.
|
||||
*
|
||||
* A session rather than a machine, because a machine is not what is metered: one machine runs
|
||||
* the Claude CLI and an echo session side by side, and only the first of them spends anything.
|
||||
*/
|
||||
fun forSession(session: SessionSummary): SessionUsage {
|
||||
// Settled without asking anybody: a session nothing meters has nothing to check, and
|
||||
// "checking" is what the fetch's own states would say about it for as long as one is out.
|
||||
val provider = session.usageProvider ?: return SessionUsage.NotMetered
|
||||
return when (val state = snapshots) {
|
||||
is LoadState.Loading -> SessionUsage.Waiting
|
||||
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
||||
is LoadState.Loaded -> usageFor(state.value, setup)
|
||||
is LoadState.Loaded -> usageFor(state.value, session.setup, provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,15 +98,15 @@ class UsageFeed(
|
||||
fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
|
||||
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
|
||||
var refreshing by remember { mutableStateOf(true) }
|
||||
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh
|
||||
// also resets the countdown to the next one rather than leaving one due immediately after.
|
||||
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh also
|
||||
// resets the countdown rather than leaving one due immediately after.
|
||||
var asked by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(asked) {
|
||||
while (true) {
|
||||
refreshing = true
|
||||
// Replaces the answer only once the next one is in hand: dropping back to Loading
|
||||
// would blank a bar somebody is reading for the length of a round trip, and what was
|
||||
// on screen is still the last thing the machine actually said.
|
||||
// Replaces the answer only once the next one is in hand: dropping back to Loading would
|
||||
// blank a bar somebody is reading for the length of a round trip, and what was on
|
||||
// screen is still the last thing the machine actually said.
|
||||
snapshots =
|
||||
try {
|
||||
LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) })
|
||||
@@ -121,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
|
||||
* 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
|
||||
* deliberately passes windows it does not recognise straight through, so a fourth one is a thing
|
||||
* that happens rather than a thing to notice later.
|
||||
* passes windows it does not recognise straight through.
|
||||
*
|
||||
* 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
|
||||
* unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind
|
||||
* the button is where those say, in words, which one they are.
|
||||
* unknown blue would say "measured, and fine" about a machine nobody could reach.
|
||||
*/
|
||||
@Composable
|
||||
fun usageGlyphColour(usage: SessionUsage): Color =
|
||||
@@ -145,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
|
||||
* 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
|
||||
* account. It is never derived from what this app has watched go past: the transcript's token
|
||||
* counts are a different quantity, measured differently, and a bar shaped like a quota gauge built
|
||||
* out of them would be a guess wearing a measurement's clothes.
|
||||
* What it shows is the paid service's own metering, never derived from what this app has watched go
|
||||
* past: the transcript's token counts are a different quantity, measured differently, and a bar
|
||||
* built out of them would be a guess wearing a measurement's clothes.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
||||
DebugStats.count("usage bar recomposed")
|
||||
// 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
|
||||
// value, Compose skips the recomposition, and a "left" that only ticked when the quota
|
||||
// happened to move would sit at a stale figure for hours.
|
||||
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
|
||||
// would sit at a stale figure for hours.
|
||||
var now by remember { mutableStateOf(OffsetDateTime.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
@@ -165,9 +167,15 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would
|
||||
// report a problem about a setup somebody chose, on every screen, forever.
|
||||
if (usage is SessionUsage.NotMetered) {
|
||||
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
|
||||
// a problem about a setup somebody chose, on every screen, forever.
|
||||
//
|
||||
// And nothing while the first fetch is out, which is a different silence. A request in flight
|
||||
// is not a state to report -- and the session that meters nothing is exactly the one this
|
||||
// cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
|
||||
// second and was then taken away. A row that has to be withdrawn is worse than one that
|
||||
// arrives late.
|
||||
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -178,9 +186,10 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
||||
// Words, not a colour and not an empty bar: every one of these is a different kind of
|
||||
// answer from "this much is used", and only words carry a difference in kind.
|
||||
when (val state = usage) {
|
||||
SessionUsage.NotMetered -> Unit
|
||||
// Both handled above, before the row exists at all.
|
||||
SessionUsage.NotMetered,
|
||||
SessionUsage.Waiting -> Unit
|
||||
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
|
||||
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
|
||||
is SessionUsage.Known -> {
|
||||
val window = state.windows.firstOrNull { it.kind == "session" }
|
||||
if (window == null) {
|
||||
@@ -222,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
|
||||
* 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
|
||||
* [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is
|
||||
* no countdown to report and inventing one would be the same fault as inventing the number.
|
||||
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
|
||||
* that is not running gets the percentage and nothing else.
|
||||
*/
|
||||
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
||||
val percent = "${window.percent.toInt()}%"
|
||||
@@ -242,17 +250,22 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
||||
}
|
||||
|
||||
/**
|
||||
* One machine's snapshot, out of every machine's.
|
||||
* One meter's snapshot, out of every machine's: [setup]'s row for [provider].
|
||||
*
|
||||
* 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.
|
||||
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
|
||||
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
|
||||
* service on one machine.
|
||||
*
|
||||
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
|
||||
* 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.
|
||||
*/
|
||||
fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage {
|
||||
// No snapshot at all means the backend never asked, which it only does for a machine with
|
||||
// nothing metered on it. That is a different answer from having asked and failed.
|
||||
val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered
|
||||
fun usageFor(snapshots: List<UsageSnapshot>, setup: String, provider: String): SessionUsage {
|
||||
// No snapshot at all means the backend never asked, which it only does where there is nothing
|
||||
// to ask about. That is a different answer from having asked and failed.
|
||||
val mine =
|
||||
snapshots.firstOrNull { it.setup == setup && it.provider == provider }
|
||||
?: return SessionUsage.NotMetered
|
||||
if (mine.state != "ok") {
|
||||
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
||||
}
|
||||
|
||||
@@ -47,15 +47,14 @@ fun SettingsScreen(
|
||||
val context = LocalContext.current
|
||||
var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") }
|
||||
var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) }
|
||||
// Never pre-filled from the stored token: this screen shouldn't be a
|
||||
// way to read the credential back off the device.
|
||||
// Never pre-filled from the stored token: this screen shouldn't be a way to read the credential
|
||||
// back off the device.
|
||||
var token by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val scanLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
|
||||
// Null contents means the user backed out of the scanner -- not an
|
||||
// error, so nothing to report.
|
||||
// Null contents means the user backed out of the scanner -- not an error.
|
||||
val contents = result.contents ?: return@rememberLauncherForActivityResult
|
||||
val settings = parseEnrollmentUri(contents.toUri())
|
||||
if (settings == null) {
|
||||
@@ -83,8 +82,8 @@ fun SettingsScreen(
|
||||
// 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
|
||||
// control come and go: there is no screen underneath yet, so a Back here would not be
|
||||
// a capability being withheld but a promise it could not keep.
|
||||
// control come and go: there is no screen underneath yet, so a Back here would not be a
|
||||
// capability being withheld but a promise it could not keep.
|
||||
if (onBack != null) {
|
||||
GlyphButton(BACK_GLYPH, "Back", onBack)
|
||||
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
||||
@@ -106,14 +105,11 @@ fun SettingsScreen(
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
// Hold the camera permission before the scanner starts.
|
||||
// Letting its activity ask on our behalf is what the
|
||||
// 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 "Sorry, the Android camera
|
||||
// encountered a problem" over it, and works on the second
|
||||
// try. Nothing is wrong with the camera, so nothing should
|
||||
// say there is.
|
||||
// Hold the camera permission before the scanner starts. Letting its activity ask on
|
||||
// our behalf is what the 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
|
||||
// "Sorry, the Android camera encountered a problem" over it, and works on the
|
||||
// second try.
|
||||
if (
|
||||
context.checkSelfPermission(Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
@@ -187,11 +183,10 @@ fun SettingsScreen(
|
||||
* just been granted.
|
||||
*
|
||||
* 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
|
||||
* dark-themed terminal it comes out as a photographic negative the scanner silently never matches.
|
||||
* Which way round it renders is the terminal's business, not something this app should depend on.
|
||||
* The mixed decoder alternates normal and inverted frames, costing half the frame rate at each
|
||||
* polarity and nothing else.
|
||||
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a dark-
|
||||
* themed terminal it comes out as a photographic negative the scanner silently never matches. The
|
||||
* mixed decoder alternates normal and inverted frames, costing half the frame rate at each
|
||||
* polarity.
|
||||
*/
|
||||
private fun enrollmentScanOptions(): ScanOptions =
|
||||
ScanOptions()
|
||||
|
||||
@@ -58,8 +58,8 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
LaunchedEffect(reloadToken) { reload() }
|
||||
|
||||
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
|
||||
// and stays with the list it adds to.
|
||||
// The heading and Back are the tab row's now; adding a machine is this tab's own work and
|
||||
// stays with the list it adds to.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
TextButton(onClick = { adding = true }) { Text("Add machine") }
|
||||
}
|
||||
@@ -200,9 +200,8 @@ private fun SetupCard(
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(setup.name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
// Not "this machine": the seeded setup is *called* that,
|
||||
// and the card read "this machine / this machine". The
|
||||
// line has to say something the name cannot also be.
|
||||
// Not "this machine": the seeded setup is *called* that, and the card read "this
|
||||
// machine / this machine".
|
||||
setup.address ?: "runs where the backend does",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -237,6 +236,7 @@ private fun AddSetupDialog(
|
||||
var address by remember { mutableStateOf("") }
|
||||
var identity by remember { mutableStateOf("") }
|
||||
var attachmentsDir by remember { mutableStateOf("") }
|
||||
var modelsDir by remember { mutableStateOf("") }
|
||||
var tested by remember { mutableStateOf<String?>(null) }
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -251,6 +251,7 @@ private fun AddSetupDialog(
|
||||
port = typedPort,
|
||||
identityFile = identity.trim().ifEmpty { null },
|
||||
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
|
||||
modelsDir = modelsDir.trim().ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -275,9 +276,8 @@ private fun AddSetupDialog(
|
||||
OutlinedTextField(
|
||||
value = address,
|
||||
onValueChange = { address = it },
|
||||
// Just the shape. What a blank one means is said once, in the text above
|
||||
// this form -- repeating it here wrapped the label onto a second line and
|
||||
// made this field taller than the two beside it for no information.
|
||||
// Just the shape. What a blank one means is said once, in the text above this
|
||||
// form -- repeating it here wrapped the label onto a second line.
|
||||
label = { Text("user@host[:port]") },
|
||||
singleLine = true,
|
||||
)
|
||||
@@ -288,14 +288,21 @@ private fun AddSetupDialog(
|
||||
singleLine = true,
|
||||
)
|
||||
// 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
|
||||
// path typed on a phone.
|
||||
// session's own directory, which is what most people want.
|
||||
OutlinedTextField(
|
||||
value = attachmentsDir,
|
||||
onValueChange = { attachmentsDir = it },
|
||||
label = { Text("Folder for attached files (optional)") },
|
||||
singleLine = true,
|
||||
)
|
||||
// Where that machine's GGUFs are, for a llama.cpp session on it. Blank means
|
||||
// the same place this backend keeps its own downloads, read on that machine.
|
||||
OutlinedTextField(
|
||||
value = modelsDir,
|
||||
onValueChange = { modelsDir = it },
|
||||
label = { Text("Folder for models (optional)") },
|
||||
singleLine = true,
|
||||
)
|
||||
tested?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
@@ -309,9 +316,8 @@ private fun AddSetupDialog(
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
// Tried before saving, so a wrong address or an
|
||||
// unauthorised key is caught while this form is still on
|
||||
// screen rather than at the first spawn.
|
||||
// Tried before saving, so a wrong address or an unauthorised key is caught while
|
||||
// this form is still on screen rather than at the first spawn.
|
||||
TextButton(
|
||||
enabled = !testing,
|
||||
onClick = {
|
||||
@@ -377,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.
|
||||
*
|
||||
* One field rather than two because that is how an address is written and read everywhere else --
|
||||
* and because a port that is almost always 22 does not deserve a box of its own on a phone
|
||||
* keyboard. Null rather than 22: the backend already decides the default, and writing 22 here would
|
||||
* put a second answer to that question in a second place.
|
||||
* One field rather than two because that is how an address is written and read everywhere else, and
|
||||
* because a port that is almost always 22 does not deserve a box of its own on a phone keyboard.
|
||||
* Null rather than 22: the backend already decides the default.
|
||||
*
|
||||
* 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
|
||||
* address and a port. So the rule is: brackets, or exactly one colon followed by digits.
|
||||
* `[::1]:22`; a bare `::1` keeps every colon. So the rule is: brackets, or exactly one colon
|
||||
* followed by digits.
|
||||
*/
|
||||
private fun splitHostAndPort(typed: String): Pair<String, Int?> {
|
||||
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
|
||||
* 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)
|
||||
|
||||
|
||||
@@ -4,15 +4,12 @@ package com.example.aiapp
|
||||
* A byte count at the coarsest unit that still says something, so rows stay comparable.
|
||||
*
|
||||
* Null at zero and below, because the screens that ask disagree about what nothing means and only
|
||||
* the caller knows: a transcript of no bytes is a measurement that has not happened, and is left
|
||||
* off the row; a file of no bytes is a file with nothing in it, and the explorer says `0 B` rather
|
||||
* than leaving a gap the reader would have to interpret; a session with no cached transcript says
|
||||
* "nothing cached", because a figure of none would read as a measurement.
|
||||
* the caller knows: a transcript of no bytes is a measurement that has not happened; a file of no
|
||||
* bytes is a file with nothing in it, and the explorer says `0 B`; a session with no cached
|
||||
* 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: three screens now say a size, and
|
||||
* a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B.
|
||||
* `ModelsScreen`'s `gigabytes` is deliberately not folded in -- it writes a download's size to two
|
||||
* decimal places, which is a different question about a much larger number.
|
||||
*/
|
||||
fun humanSize(bytes: Long): String? =
|
||||
when {
|
||||
|
||||
@@ -37,7 +37,7 @@ import kotlinx.coroutines.withContext
|
||||
* 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
|
||||
* with no app rebuild -- and because they are independent, any provider can be sent to any host.
|
||||
* with no app rebuild.
|
||||
*/
|
||||
@Composable
|
||||
fun SpawnScreen(
|
||||
@@ -46,39 +46,46 @@ fun SpawnScreen(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
// What the form is made of, and whether we have it yet. A failure here
|
||||
// is not the same as a server with nothing to offer, so it must not
|
||||
// reach the pickers as empty lists -- see LoadState.
|
||||
// What the form is made of, and whether we have it yet. A failure here is not the same as a
|
||||
// server with nothing to offer, so it must not reach the pickers as empty lists.
|
||||
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
||||
|
||||
// Setup first, then one of its providers. Choosing a setup can
|
||||
// invalidate the provider, so the provider is stored by name and
|
||||
// resolved against the current setup rather than held as an object
|
||||
// that could outlive the list it came from.
|
||||
// Setup first, then one of its providers. Choosing a setup can invalidate the provider, so the
|
||||
// provider is stored by name and resolved against the current setup rather than held as an
|
||||
// object that could outlive the list it came from.
|
||||
var setupName by remember { mutableStateOf<String?>(null) }
|
||||
var providerName by remember { mutableStateOf<String?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var model by remember { mutableStateOf("") }
|
||||
var cwd by remember { mutableStateOf("") }
|
||||
// "auto" rather than "manual": on a phone every ask is a round trip to
|
||||
// a question card, and answering "allow Bash?" dozens of times per task
|
||||
// is what this app exists to avoid. Manual stays one tap away for a
|
||||
// session that warrants it.
|
||||
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
|
||||
// answering "allow Bash?" dozens of times per task is what this app exists to avoid.
|
||||
var permissionMode by remember { mutableStateOf("auto") }
|
||||
// Null until the server has been asked, and null again if it answers "no level chosen" -- the
|
||||
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer
|
||||
// arrives is one you can spawn at without having chosen it.
|
||||
var effort by remember { mutableStateOf<String?>(null) }
|
||||
var defaultsAsked by remember { mutableStateOf(false) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
// Only the spawn's own failure. The fetch's lives in `options`: this
|
||||
// one leaves a filled-in form worth keeping, and that one leaves
|
||||
// nothing to fill in.
|
||||
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
|
||||
// form worth keeping, and that one leaves nothing to fill in.
|
||||
var spawnError by remember { mutableStateOf<String?>(null) }
|
||||
// Downloaded models, for a llama provider to choose between. Fetched
|
||||
// beside the setups but kept separate: a Claude session needs none, so
|
||||
// failing to list them must not stop the screen rendering.
|
||||
// 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
|
||||
// screen rendering. Refetched when the machine changes, because a model is a file on one
|
||||
// machine -- see [fetchSetupModels].
|
||||
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
|
||||
var modelKey by remember { mutableStateOf<String?>(null) }
|
||||
var contextSize by remember { mutableStateOf("") }
|
||||
var temperature by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Separate from the setups fetch below and deliberately not fatal: failing to learn the
|
||||
// default must leave a screen you can still spawn from, so the picker stays on "default"
|
||||
// and says so rather than the whole form refusing to draw.
|
||||
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
|
||||
.onSuccess { effort = it }
|
||||
defaultsAsked = true
|
||||
options =
|
||||
try {
|
||||
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
||||
@@ -89,9 +96,6 @@ fun SpawnScreen(
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
models =
|
||||
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||
@@ -105,10 +109,9 @@ fun SpawnScreen(
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Nothing below is fillable until the options are here, and a
|
||||
// failure to fetch them leaves no form worth showing -- so this
|
||||
// reports and stops, rather than offering empty pickers under an
|
||||
// error message.
|
||||
// Nothing below is fillable until the options are here, and a failure to fetch them leaves
|
||||
// no form worth showing -- so this reports and stops, rather than offering empty pickers
|
||||
// under an error message.
|
||||
val setups =
|
||||
when (val state = options) {
|
||||
is LoadState.Loading -> {
|
||||
@@ -122,11 +125,21 @@ fun SpawnScreen(
|
||||
is LoadState.Loaded -> state.value
|
||||
}
|
||||
val setup = setups.firstOrNull { it.name == setupName }
|
||||
// Whichever machine is chosen now, asked again when that changes. The old machine's list
|
||||
// is dropped first rather than left on screen: a file name from another machine looks
|
||||
// exactly like one from this one.
|
||||
LaunchedEffect(setup?.id) {
|
||||
models = emptyList()
|
||||
modelKey = null
|
||||
val id = setup?.id ?: return@LaunchedEffect
|
||||
models =
|
||||
runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
val current = setup?.providers?.firstOrNull { it.name == providerName }
|
||||
// Only the Claude CLI has models, a working directory and
|
||||
// permission modes; keying the extra fields on the kind rather
|
||||
// than the provider name keeps a second Claude provider from
|
||||
// needing anything here.
|
||||
// Only the Claude CLI has models, a working directory and permission modes; keying the
|
||||
// extra fields on the kind rather than the provider name keeps a second Claude provider
|
||||
// from needing anything here.
|
||||
val isClaude = current?.kind == "claude_cli"
|
||||
val isLlama = current?.kind == "llama_cpp"
|
||||
|
||||
@@ -137,9 +150,9 @@ fun SpawnScreen(
|
||||
selected = setupName,
|
||||
onSelect = { name ->
|
||||
setupName = name
|
||||
// The provider list changes with the machine, so a name
|
||||
// carried over from the previous one would be a selection
|
||||
// that isn't in the picker. Take that machine's first.
|
||||
// The provider list changes with the machine, so a name carried over from the
|
||||
// previous one would be a selection that isn't in the picker. Take that machine's
|
||||
// first.
|
||||
providerName =
|
||||
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
|
||||
},
|
||||
@@ -150,13 +163,13 @@ fun SpawnScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// The address belongs to the setup above it, not to the
|
||||
// provider label below; without this they read as one block.
|
||||
// The address belongs to the setup above it, not to the provider label below; without
|
||||
// this they read as one block.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Only what this machine actually has. A setup with none says so
|
||||
// rather than showing an empty row that reads as a failure.
|
||||
// Only what this machine actually has. A setup with none says so rather than showing an
|
||||
// empty row that reads as a failure.
|
||||
if (setup != null && setup.providers.isEmpty()) {
|
||||
Text(
|
||||
"\"${setup.name}\" has no providers configured.",
|
||||
@@ -183,22 +196,21 @@ fun SpawnScreen(
|
||||
)
|
||||
|
||||
if (isLlama) {
|
||||
// A llama session names one of the models this backend has
|
||||
// downloaded, so the choice is that list rather than free
|
||||
// text -- there is nothing sensible to type here, and a name
|
||||
// that is not on disk is a session that cannot start.
|
||||
// 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 -- a name that is not on that machine's
|
||||
// disk is a session that cannot start.
|
||||
if (models.isEmpty()) {
|
||||
Text(
|
||||
"No models downloaded yet. Get one from the Models screen first.",
|
||||
"No models on ${setup?.name ?: "this machine"}. The Models screen downloads " +
|
||||
"to the backend; another machine needs the file put there itself.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
ChipGroup(
|
||||
label = "Model",
|
||||
// The file, not the whole key: the repository is the
|
||||
// same for every quantisation of a model, so the file
|
||||
// name is what tells two of them apart.
|
||||
// The file, not the whole key: the repository is the same for every
|
||||
// quantisation of a model, so the file name is what tells two of them apart.
|
||||
options = models.map { it.file },
|
||||
selected = models.firstOrNull { it.key == modelKey }?.file,
|
||||
onSelect = { file -> modelKey = models.first { it.file == file }.key },
|
||||
@@ -261,6 +273,19 @@ fun SpawnScreen(
|
||||
selected = permissionMode,
|
||||
onSelect = { permissionMode = it },
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Says what it does to *later* spawns as well, because it does: the level chosen here
|
||||
// is stored as the default, which is the whole way that default is set. A picker that
|
||||
// quietly changed a global would be the same control with the fact left out.
|
||||
ChipGroup(
|
||||
label = "Thinking (kept as the default for new sessions)",
|
||||
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
||||
// The CLI's own default is a level in the list, so this cannot be a one-way trip.
|
||||
// Disabled-looking until the server has answered, for the reason above.
|
||||
selected = if (defaultsAsked) effort ?: DEFAULT_EFFORT else null,
|
||||
onSelect = { chosen -> effort = chosen.takeIf { it != DEFAULT_EFFORT } },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
@@ -278,15 +303,18 @@ fun SpawnScreen(
|
||||
try {
|
||||
val spawned =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Stored before the spawn and not after it: choosing a level is
|
||||
// an intent about new sessions in general, so a spawn that then
|
||||
// fails must not also lose the choice. Non-fatal for the same
|
||||
// reason the fetch above is -- the session is what was asked for.
|
||||
if (isClaude) {
|
||||
runCatching { setDefaultEffort(settings, effort) }
|
||||
}
|
||||
spawnSession(
|
||||
settings,
|
||||
// The id, not the label: labels are
|
||||
// editable and the server resolves by
|
||||
// id.
|
||||
// Non-null here: `chosen` came from
|
||||
// `setup`'s own provider list, so
|
||||
// reaching this point proves there was
|
||||
// a setup to take it from.
|
||||
// The id, not the label: labels are editable and the server
|
||||
// resolves by id. Non-null here, since `chosen` came from
|
||||
// `setup`'s own provider list.
|
||||
setup = setup.id,
|
||||
provider = chosen.name,
|
||||
title = title.trim(),
|
||||
@@ -294,9 +322,9 @@ fun SpawnScreen(
|
||||
if (isLlama) modelKey else model.trim().takeIf { isClaude },
|
||||
cwd = cwd.trim().takeIf { isClaude },
|
||||
permissionMode = permissionMode.takeIf { isClaude },
|
||||
// Sent only when set, so blank means
|
||||
// "whatever llama.cpp does by default"
|
||||
// rather than a zero.
|
||||
effort = effort.takeIf { isClaude },
|
||||
// Sent only when set, so blank means "whatever llama.cpp does
|
||||
// by default" rather than a zero.
|
||||
params =
|
||||
buildMap {
|
||||
if (isLlama) {
|
||||
|
||||
@@ -17,14 +17,13 @@ const val RECONNECT_DELAY_MS = 1500L
|
||||
* One server-sent-events connection, framed.
|
||||
*
|
||||
* 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
|
||||
* payload or a payload with no name. Two screens follow two different streams — a session's
|
||||
* transcript and what a machine's import list is doing — and neither should be re-deriving that.
|
||||
* line ends the frame, comments start with `:`, and a frame is either named with no payload or a
|
||||
* payload with no name. Two screens follow two different streams and neither should re-derive that.
|
||||
*
|
||||
* 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
|
||||
* rather than throwing, so a deliberate close is not reported as a connection error. Reconnecting
|
||||
* belongs to the caller, which is the only one that knows where to resume from.
|
||||
* cancellation path -- it disconnects the socket, which unblocks the read, and [run] then returns
|
||||
* rather than throwing. Reconnecting belongs to the caller, which is the only one that knows where
|
||||
* to resume from.
|
||||
*/
|
||||
class Sse(private val settings: ServerSettings) {
|
||||
@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
|
||||
* 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
|
||||
* advanced past -- and that lives in the query string.
|
||||
* because a caller that reconnects usually resumes from somewhere new.
|
||||
*
|
||||
* [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
|
||||
* first *event* instead left an idle stream displaying a connection error it had already
|
||||
* recovered from, indefinitely.
|
||||
* first *event* instead left an idle stream displaying an error it had already recovered from.
|
||||
*/
|
||||
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
|
||||
// caller the same kind of failure -- both callers retry an [ApiException] and let anything
|
||||
// else reach the top of the app -- and a connection that could not even be constructed
|
||||
// used to escape as a raw `IOException` from a line no `catch` covered.
|
||||
// caller the same kind of failure, and a connection that could not even be constructed used
|
||||
// to escape as a raw `IOException` from a line no `catch` covered.
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
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*
|
||||
* -- 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
|
||||
* becomes a thing to fight rather than a thing to use.
|
||||
* is a preference, not a contract.
|
||||
*
|
||||
* 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
|
||||
* page is Base, a component's outlined card stays Base beside it, and a project's card is Surface
|
||||
* 0: one visible step up, which is the whole of what the nesting has to say.
|
||||
* Base, Surface 0, Surface 1 -- so the page is Base, a component's outlined card stays Base beside
|
||||
* it, and a project's card is Surface 0: one visible step up, which is the whole of what the
|
||||
* nesting has to say.
|
||||
*
|
||||
* Accents on this palette are light, so anything filled with one takes Crust for its text rather
|
||||
* than the near-white the roles default to.
|
||||
* Accents on this palette are light, so anything filled with one takes Crust for its text.
|
||||
*/
|
||||
val AiAppColors =
|
||||
darkColorScheme(
|
||||
@@ -94,10 +92,8 @@ val AiAppColors =
|
||||
* What a session is doing, said in colour.
|
||||
*
|
||||
* 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
|
||||
* was a slightly different colour depending which screen you looked at, and none of them belonged
|
||||
* 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.
|
||||
* other files, so the same state was a slightly different colour depending which screen you looked
|
||||
* at. A colour that carries meaning is part of the scheme, not a value typed where it was needed.
|
||||
*/
|
||||
val runningColor: Color
|
||||
@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
|
||||
* 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
|
||||
@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.
|
||||
*
|
||||
* 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
|
||||
* remembers, taking a new name -- and none of it appears in the transcript as an answer to
|
||||
* anything. A reader who has learned that blue means "not stuck, but not replying to you either"
|
||||
* has learned the thing that distinguishes it from a session that has hung.
|
||||
* through what was asked of it; this is the session acting on itself, and none of it appears in the
|
||||
* transcript as an answer to anything. A reader who has learned that blue means "not stuck, but not
|
||||
* replying to you either" has learned what distinguishes it from a session that has hung.
|
||||
*/
|
||||
val commandColor: Color
|
||||
@Composable get() = Mocha.Blue
|
||||
@@ -128,10 +123,9 @@ val commandColor: Color
|
||||
* 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
|
||||
* deliberate choice is not a problem to report. It is the same red as [failedColor] and [stopColor]
|
||||
* for a third reason, which is worth naming rather than collapsing: this is neither a fault nor a
|
||||
* button, it is the mark left where something was taken away. The reader never has to tell the
|
||||
* three apart, because no two of them can appear as the same kind of thing.
|
||||
* deliberate choice is not a problem to report. The same red as [failedColor] and [stopColor] for a
|
||||
* third reason: this is neither a fault nor a button, it is the mark left where something was taken
|
||||
* away. No two of the three can appear as the same kind of thing.
|
||||
*/
|
||||
val clearedColor: Color
|
||||
@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.
|
||||
*
|
||||
* 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
|
||||
* compaction, has no limit to be near: it finishes. Only a bar measuring a *quota* escalates, and
|
||||
* that one is [quotaColor].
|
||||
* made it the loudest thing on a screen the reader opened to do something else. A download has no
|
||||
* limit to be near: it finishes. Only a bar measuring a *quota* escalates -- that one is
|
||||
* [quotaColor].
|
||||
*/
|
||||
val progressColor: Color
|
||||
@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.
|
||||
*
|
||||
* One function rather than the same `when` written beside each bar, because the whole point of
|
||||
* colouring by consequence is that the reader learns the step once -- two bars showing the same 80%
|
||||
* in different colours teaches nothing except that the colour cannot be trusted. It reads as a
|
||||
* difference in degree, which is all colour can carry: the states that differ in *kind* from this
|
||||
* -- 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.
|
||||
* One function rather than the same `when` written beside each bar, because the point of colouring
|
||||
* by consequence is that the reader learns the step once. It reads as a difference in degree, which
|
||||
* is all colour can carry: the states that differ in *kind* -- a window nobody could read, a
|
||||
* machine that meters nothing -- are said in words elsewhere.
|
||||
*
|
||||
* [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
|
||||
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 darkest value in the palette rather than a step up from the page, and that is the whole point
|
||||
* -- everything else on this screen is somebody's prose, and this is what a machine was handed and
|
||||
* 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
|
||||
* 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
|
||||
* is; a tint chosen upwards has to be picked twice 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 clear step down both on the page and on a card; a tint chosen upwards has to be picked twice
|
||||
* and still collides with the card it lands on.
|
||||
*
|
||||
* 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].
|
||||
*
|
||||
* 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
|
||||
* splitting them out would make code the one surface whose palette came from somewhere else.
|
||||
* fence is drawn in are the same accents every other coloured thing already uses.
|
||||
*
|
||||
* Not a composable, because [highlight] runs off the drawing thread; these colours never vary with
|
||||
* the theme.
|
||||
* Not a composable, because [highlight] runs off the drawing thread; these never vary with the
|
||||
* theme.
|
||||
*/
|
||||
fun catppuccinSyntax(): SyntaxPalette =
|
||||
SyntaxPalette(
|
||||
@@ -227,8 +216,7 @@ fun catppuccinSyntax(): SyntaxPalette =
|
||||
* 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
|
||||
* is upstream's choice and not an omission here. A program that uses bright red to mean something
|
||||
* other than red is relying on a distinction its own terminal may not draw either.
|
||||
* is upstream's choice and not an omission here.
|
||||
*
|
||||
* 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.
|
||||
@@ -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
|
||||
* 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
|
||||
* selecting a line of code looks like nothing happened even though the selection is there and
|
||||
* copies correctly.
|
||||
* code block, on Crust, the same 40% composites to a barely-there smudge, so selecting a line of
|
||||
* code looks like nothing happened even though it copies correctly.
|
||||
*
|
||||
* Fixed and stronger, because "this is selected" is a meaning rather than decoration: a colour that
|
||||
* means something must carry its own contrast instead of borrowing it from the surface it happens
|
||||
* to land on. Raised only as far as it takes to read on the darkest of them -- past this the fill
|
||||
* starts competing with the syntax colours it sits behind, which are the thing being read.
|
||||
* Fixed and stronger, because "this is selected" is a meaning rather than decoration. Raised only
|
||||
* as far as it takes to read on the darkest of them -- past this the fill starts competing with the
|
||||
* syntax colours it sits behind.
|
||||
*/
|
||||
val AiAppSelectionColors =
|
||||
TextSelectionColors(
|
||||
@@ -288,11 +274,10 @@ val linkColor: Color
|
||||
* 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
|
||||
* than words: coloured, the items of a list can be counted without reading them, and a nested list
|
||||
* reads as a shape before it reads as text. Lavender is not one of the colours that mean something
|
||||
* here -- green, red, peach and yellow are states and actions -- and it is the same at every depth,
|
||||
* since depth is said by the glyph and the indent; a colour per depth would make a difference in
|
||||
* degree look like one in kind.
|
||||
* than words: coloured, the items of a list can be counted without reading them. Lavender is not
|
||||
* one of the colours that mean something here, and it is the same at every depth, since depth is
|
||||
* said by the glyph and the indent -- a colour per depth would make a difference in degree look
|
||||
* like one in kind.
|
||||
*/
|
||||
val listMarkerColor: Color
|
||||
@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.
|
||||
*
|
||||
* 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
|
||||
* naming rather than collapsing: [runningColor] is green because a session is working,
|
||||
* [failedColor] is red because one fell over, [awaitingColor] is the same orange because a session
|
||||
* 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.
|
||||
* flight, red ends the process. The near-collisions with the states above are deliberate: 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
|
||||
@Composable get() = Mocha.Green
|
||||
@@ -322,9 +305,8 @@ val queueColor: Color
|
||||
* 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
|
||||
* there holding the conversation, and the next message starts a turn as though nothing had
|
||||
* happened. Red is spent on [stopColor], which is the same button in the same place when what it
|
||||
* would end is the session's process.
|
||||
* there holding the conversation. Red is spent on [stopColor], which is the same button in the same
|
||||
* place when what it would end is the session's process.
|
||||
*/
|
||||
val pauseColor: Color
|
||||
@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
|
||||
* 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
|
||||
* every accent on this palette takes, which is the same reason `onPrimary` is Crust above.
|
||||
* will not change to rescue a foreground that stops being readable on one of them.
|
||||
*/
|
||||
@Composable
|
||||
fun actionButtonColors(fill: Color): ButtonColors =
|
||||
|
||||
@@ -16,11 +16,10 @@ import org.json.JSONObject
|
||||
/**
|
||||
* 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":"…",
|
||||
* "timeout":120000}` themselves to find the one line they care about. So the fields that carry the
|
||||
* meaning are pulled out -- the command a shell will run, what it is for, how long it may take --
|
||||
* and anything left over is still shown, because dropping a field would be claiming the tool has no
|
||||
* other input when it might.
|
||||
* Every tool's input arrives as JSON, and showing it raw makes the reader parse
|
||||
* `{"command":"…","timeout":120000}` themselves to find the one line they care about. So the fields
|
||||
* that carry the meaning are pulled out, and anything left over is still shown, because dropping a
|
||||
* field would be claiming the tool has no other input when it might.
|
||||
*/
|
||||
data class ToolInput(
|
||||
/** 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. */
|
||||
val description: String?,
|
||||
/**
|
||||
* How long the call may take, in the largest units it fits ([formatMillis]). Shown apart
|
||||
* because it is a limit on the call rather than part of what the call does.
|
||||
* How long the call may take, in the largest units it fits. Shown apart because it is a limit
|
||||
* on the call rather than part of what the call does.
|
||||
*/
|
||||
val timeout: String?,
|
||||
/** 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
|
||||
* 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?>> =
|
||||
mapOf(
|
||||
@@ -68,8 +67,8 @@ fun parseToolInput(tool: String, input: String): ToolInput {
|
||||
try {
|
||||
JSONObject(input)
|
||||
} catch (_: org.json.JSONException) {
|
||||
// Not an object: older transcripts and some tools send a bare
|
||||
// string. It is still the input, so it is still shown.
|
||||
// Not an object: older transcripts and some tools send a bare string. It is still the
|
||||
// input, so it is still shown.
|
||||
return ToolInput(
|
||||
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.
|
||||
*
|
||||
* On the dark surface every verbatim thing in the app sits on -- see [RawBlock]. Drawn as nothing
|
||||
* at all when the call carried neither, rather than as an empty block: a tinted rectangle with
|
||||
* nothing in it is a rendering fault, and it is the shape a tool with no input actually has.
|
||||
* On the dark surface every verbatim thing in the app sits on. Drawn as nothing at all when the
|
||||
* call carried neither, rather than as an empty block: a tinted rectangle with nothing in it is a
|
||||
* rendering fault.
|
||||
*
|
||||
* 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.
|
||||
@@ -113,11 +112,11 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
||||
if (parsed.subject == null && parsed.rest.isEmpty()) return
|
||||
RawBlock(modifier) {
|
||||
parsed.subject?.let { subject ->
|
||||
// Not wrapped: a wrapped command hides where its arguments end,
|
||||
// and the long one is the one being read closely.
|
||||
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
|
||||
// one being read closely.
|
||||
Text(
|
||||
// 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.
|
||||
// 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.
|
||||
remember(subject, parsed.language) { highlight(subject, parsed.language) },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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
|
||||
* of "these belong together" must not reach back into it.
|
||||
*
|
||||
* Immutable, and said so, because Compose cannot tell.
|
||||
*
|
||||
* A row is a value: it is rebuilt from the transcript rather than edited, and two rows describing
|
||||
* the same events are equal. Compose infers stability from a class's fields, and a `List` field --
|
||||
* which several of these carry -- makes it assume the worst, so every composable taking one
|
||||
* 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.
|
||||
* 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
|
||||
* class's fields, and a `List` field -- which several of these carry -- makes it assume the worst,
|
||||
* so 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* screen: when a key changes, the list loses its anchor and the transcript steps under whoever
|
||||
* is reading it.
|
||||
* the other, moves the rows and not the reader. When a key changes, the list loses its anchor
|
||||
* and the transcript steps under whoever is reading it.
|
||||
*
|
||||
* 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
|
||||
* that gains a neighbour becomes a group without changing identity, which is the case a
|
||||
* 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.
|
||||
* and it is the *same* value whether the run is drawn as one card or as a group. Which value
|
||||
* that is belongs to the item ([TranscriptItem.key]), not to a `when` here.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* was first when it was folded, which changes as pages arrive. A seq is the server's own
|
||||
* numbering: it is assigned once, never moves, and means the same thing to every device. So
|
||||
* 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].
|
||||
* decision; a seq is the server's own numbering, assigned once and meaning the same thing to
|
||||
* every device. So anything that has to point at a place in the conversation and still find it
|
||||
* later -- a saved scroll position -- points with this.
|
||||
*/
|
||||
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.
|
||||
// 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
|
||||
// change which call is *first*, and a group named after its first member is a different
|
||||
// group every time that happens.
|
||||
// change which call is *first*.
|
||||
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
|
||||
run += item
|
||||
} 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
|
||||
* 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
|
||||
* [GROUP_INSET], which is the container's own padding rather than an indent: they are the same rows
|
||||
* they would be on their own, and a rounded corner drawn hard against a rounded corner reads as a
|
||||
* notch.
|
||||
* [GROUP_INSET], which is the container's own padding rather than an indent.
|
||||
*
|
||||
* 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
|
||||
* separating them. See [connectedShape].
|
||||
* the run reads as one thing broken into its parts; see [connectedShape].
|
||||
*
|
||||
* 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
|
||||
* bar at the foot is the same height as the heading at the top, so the surface the calls sit on is
|
||||
* as thick below them as above.
|
||||
* screen, and the reader who wants it shut is looking at the bottom. The bar at the foot is the
|
||||
* same height as the heading at the top.
|
||||
*/
|
||||
@Composable
|
||||
fun ToolGroup(
|
||||
@@ -171,8 +156,7 @@ fun ToolGroup(
|
||||
expanded: Boolean,
|
||||
/**
|
||||
* 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
|
||||
* itself and this just says that one happened.
|
||||
* at each end, and only the row knows where its own ends are.
|
||||
*/
|
||||
onToggle: () -> Unit,
|
||||
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
|
||||
// group, and what they are looking at is what follows it.
|
||||
// Shutting it from here anchors the other end: the reader is at the bottom of a long group,
|
||||
// and what they are looking at is what follows it.
|
||||
CollapseBar(barHeight, onToggle)
|
||||
}
|
||||
}
|
||||
@@ -232,8 +216,8 @@ fun ToolGroup(
|
||||
* 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
|
||||
* match and a pair of numbers chosen to look equal stops being equal the moment either the style or
|
||||
* the density changes. Taking the line height also means the heading cannot be clipped by it.
|
||||
* match and a pair of numbers chosen to look equal stops being equal the moment the density
|
||||
* changes.
|
||||
*/
|
||||
@Composable
|
||||
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.
|
||||
*
|
||||
* Given the heading's height rather than padded to something that looks close, so the surface the
|
||||
* calls sit on is the same thickness at both ends. See [groupBarHeight].
|
||||
* 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
|
||||
* thickness at both ends.
|
||||
*/
|
||||
@Composable
|
||||
private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
|
||||
@@ -266,8 +249,7 @@ private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
|
||||
* does not.
|
||||
*
|
||||
* 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
|
||||
* when a run turns out to have three calls in it.
|
||||
* that are one rule -- and the middle one is what a hand-written first/last pair gets wrong.
|
||||
*/
|
||||
@Composable
|
||||
private fun connectedShape(index: Int, count: Int): CornerBasedShape {
|
||||
@@ -294,12 +276,10 @@ private val GROUP_GAP = 2.dp
|
||||
* One tool call.
|
||||
*
|
||||
* 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
|
||||
* name plus the intent is what somebody scanning the transcript is reading for.
|
||||
* on it, because a wrapped command turns one row into four and a run of them into a wall.
|
||||
*
|
||||
* 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
|
||||
* seeing beside the command it constrains rather than buried in the fields below it.
|
||||
* the top right: it is a limit on the call rather than part of what the call does.
|
||||
*
|
||||
* 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.
|
||||
@@ -342,10 +322,9 @@ fun ToolCard(
|
||||
)
|
||||
} ?: Spacer(Modifier.weight(1f))
|
||||
}
|
||||
// A spinner says the machine is working. While this call is waiting on an
|
||||
// answer the machine is doing nothing at all -- the turn is stopped on the
|
||||
// person reading it -- so it says whose move it is instead, in the colour this
|
||||
// app uses everywhere for that.
|
||||
// A spinner says the machine is working. While this call is waiting on an answer
|
||||
// the machine is doing nothing at all -- the turn is stopped on the person reading
|
||||
// it -- so it says whose move it is instead.
|
||||
if (deciding) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
@@ -370,9 +349,9 @@ fun ToolCard(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
// Everything AskUserQuestion carries is the questions, and those are drawn
|
||||
// below as something answerable; dumping the same JSON above them would be the
|
||||
// decision stated twice, once unreadably.
|
||||
// Everything AskUserQuestion carries is the questions, and those are drawn below as
|
||||
// something answerable; dumping the same JSON above them would be the decision
|
||||
// stated twice, once unreadably.
|
||||
if (tool.tool != ASK_USER_QUESTION) {
|
||||
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
|
||||
}
|
||||
@@ -381,14 +360,12 @@ fun ToolCard(
|
||||
Text("Output", style = MaterialTheme.typography.labelSmall)
|
||||
// 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
|
||||
// prose -- a directory listing, a diff, a table of numbers -- and a
|
||||
// proportional font silently destroys the alignment that carried the meaning.
|
||||
// prose, and a proportional font silently destroys the alignment that carried
|
||||
// the meaning.
|
||||
//
|
||||
// Its terminal styling applied and the rest of the escapes taken out, since
|
||||
// what a shell prints is written for a terminal: colour is often the whole of
|
||||
// what a diff or a test run is saying, and the sequences that carry it are
|
||||
// unreadable drawn verbatim. Remembered against the text, so a card that is
|
||||
// open through a scroll parses once. See [ansiStyled].
|
||||
// Its terminal styling applied and the rest of the escapes taken out: colour is
|
||||
// often the whole of what a diff or a test run is saying. Remembered against
|
||||
// the text, so a card that is open through a scroll parses once.
|
||||
val palette = remember { ansiPalette() }
|
||||
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
|
||||
RawBlock(Modifier.padding(top = 2.dp)) {
|
||||
@@ -400,10 +377,8 @@ fun ToolCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shown open or closed. A call that produced a picture is one
|
||||
// whose result *is* the picture, and a row that hides it says
|
||||
// less than the one line it replaced -- unlike a command, which
|
||||
// is what the closed line already summarises.
|
||||
// Shown open or closed. A call that produced a picture is one whose result *is* the
|
||||
// picture, and a row that hides it says less than the one line it replaced.
|
||||
tool.images.forEach { ref -> image(ref) }
|
||||
if (tool.asks.isNotEmpty()) {
|
||||
if (tool.tool == ASK_USER_QUESTION) {
|
||||
@@ -428,11 +403,10 @@ private fun PermissionAsk(
|
||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||
) {
|
||||
// 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 --
|
||||
// so the press has to be its own acknowledgement or the row sits unchanged for a round trip
|
||||
// and reads as having missed the tap. Cleared when the request settles: by then either the
|
||||
// answer is in `ask.answers` and the mark stands on a measurement, or it failed and the
|
||||
// buttons come back rather than leaving a decision marked that nothing recorded.
|
||||
// step -- unlike a question card, where the answer is worth reviewing -- so the press has to be
|
||||
// its own acknowledgement or the row sits unchanged for a round trip. Cleared when the request
|
||||
// settles: by then either the answer is in `ask.answers`, or it failed and the buttons come
|
||||
// back.
|
||||
var pressed by remember(ask.id) { mutableStateOf<String?>(null) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
@@ -442,8 +416,7 @@ private fun PermissionAsk(
|
||||
)
|
||||
// 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
|
||||
// matters most: "Answered: Deny" alone does not say that Allow was the alternative, and
|
||||
// whether a tool was allowed or refused is the thing a reader comes back to this row for.
|
||||
// matters most: "Answered: Deny" alone does not say that Allow was the alternative.
|
||||
val settled = ask.answers.isNotEmpty()
|
||||
AnswerOptions(
|
||||
ask.options,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.example.aiapp
|
||||
|
||||
/**
|
||||
* Where one transcript lives: a session's own, or one of its subagents'.
|
||||
*
|
||||
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
|
||||
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
|
||||
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
|
||||
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
|
||||
*/
|
||||
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
|
||||
/** The URL segment naming this transcript, before `/transcript` or `/events`. */
|
||||
val urlPath: String
|
||||
get() =
|
||||
if (subagentId == null) "sessions/$sessionId"
|
||||
else "sessions/$sessionId/subagents/$subagentId"
|
||||
|
||||
/**
|
||||
* Where this transcript's cache lives on the phone, relative to the cache root.
|
||||
*
|
||||
* A subagent's nests under its session's directory rather than sitting beside it, so deleting a
|
||||
* session's cache directory takes its subagents' with it -- the same one-way door the server's
|
||||
* own storage describes.
|
||||
*/
|
||||
val cachePath: String
|
||||
get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId"
|
||||
}
|
||||
@@ -11,44 +11,44 @@ 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 -- the
|
||||
* elements of a `/transcript` page and the payload of each SSE frame. 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 ([SessionEvent.Unknown])
|
||||
* keeps every field it arrived with, on disk, for the build that will. Rows are deliberately *not*
|
||||
* what is stored: a row is a rendering of events, its shape changes whenever the fold does, and a
|
||||
* cache of rows would need throwing away on every app update that touched `foldEvent`.
|
||||
* 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.
|
||||
* 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. It is also why there is no JSON parser in here: what it needs off a
|
||||
* line is the sequence number and whether the line is a streamed delta, and both are read with a
|
||||
* regex over text the server wrote. A line it cannot read that way is treated as damage, which
|
||||
* gives the same answer as having no cache at all.
|
||||
*
|
||||
* [warn] is where failures are said, for the same reason -- `android.util.Log` is a stub that
|
||||
* throws under the JVM tests, and this file has to be exercisable there.
|
||||
* 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)
|
||||
/**
|
||||
* The cache for one transcript, whether or not anything has been stored for it yet.
|
||||
*
|
||||
* A subagent's [TranscriptAddress.cachePath] nests it under its session's directory, so
|
||||
* deleting the session (below) takes its subagents' caches with it -- there is no separate
|
||||
* purge for one.
|
||||
*/
|
||||
fun session(address: TranscriptAddress): SessionCache =
|
||||
SessionCache(File(root, address.cachePath), warn)
|
||||
|
||||
/**
|
||||
* Deletes every session directory not in [ids], called after a successful list fetch.
|
||||
*
|
||||
* The path out for a session deleted on another device or at the backend: nothing here would
|
||||
* otherwise ever hear about it, and unlike a draft's few bytes what it leaves behind is
|
||||
* megabytes.
|
||||
* 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) {
|
||||
@@ -57,11 +57,9 @@ class TranscriptCache(
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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) {
|
||||
@@ -81,18 +79,16 @@ class TranscriptCache(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 and by the same caller: the seq is what the request asks about,
|
||||
* and the line is what its answer is compared with.
|
||||
* 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)
|
||||
|
||||
@@ -101,35 +97,31 @@ data class CachedTail(val seq: Long, val line: String)
|
||||
*
|
||||
* 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 in fact 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:
|
||||
* 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
|
||||
* <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 of
|
||||
* adjacent chunks 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.
|
||||
* 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 -- 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 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.
|
||||
* 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. The
|
||||
* lock is uncontended in the ordinary case and what it buys is that the open chunk's name, its end
|
||||
* and its writer are never read half-rotated -- which would show up as a page silently fetched
|
||||
* again, or as a stored chunk overlapping the run it was written beside.
|
||||
* 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,
|
||||
@@ -141,9 +133,8 @@ class SessionCache(
|
||||
* 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 -- a syscall each is the thing to avoid. 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.
|
||||
* 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
|
||||
@@ -153,8 +144,7 @@ class SessionCache(
|
||||
* 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 -- see
|
||||
* `TranscriptSource.probe`.
|
||||
* still be the server's own line before anything is resumed from it.
|
||||
*/
|
||||
@Synchronized
|
||||
fun tail(): CachedTail? =
|
||||
@@ -187,30 +177,26 @@ class SessionCache(
|
||||
* 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. It is 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
|
||||
* 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 -- the screen draws the newest eighty lines of
|
||||
* it -- 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.
|
||||
* 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.
|
||||
*
|
||||
* A short page is fine, and is what a walk that reaches the oldest chunk of the suffix returns:
|
||||
* the caller already treats a short page as a page.
|
||||
*
|
||||
* With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`:
|
||||
* every event that is not a streamed delta is a row, and each maximal run of deltas is one row.
|
||||
* 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, so anchors and the next `before` land where they do today.
|
||||
* 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.
|
||||
// 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
|
||||
@@ -220,14 +206,14 @@ class SessionCache(
|
||||
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 the
|
||||
// ones already on screen.
|
||||
// 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.
|
||||
// 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++
|
||||
@@ -248,8 +234,7 @@ class SessionCache(
|
||||
* 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 and the history behind it is served
|
||||
* locally from then on.
|
||||
* the gap is closed with exactly the bytes it is wide.
|
||||
*/
|
||||
@Synchronized
|
||||
fun coveredUpTo(before: Long): Long? =
|
||||
@@ -260,9 +245,8 @@ class SessionCache(
|
||||
*
|
||||
* 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 --
|
||||
* from a server without the `after` parameter, say. Such a page is still drawn; it is only not
|
||||
* kept.
|
||||
* 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.
|
||||
@@ -282,18 +266,17 @@ class SessionCache(
|
||||
* 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
|
||||
* before starting a new one at [seq]. A smaller one is already covered and is ignored; the SSE
|
||||
* contract is `seq > after`, so that is a guard rather than a path.
|
||||
* `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.
|
||||
// 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
|
||||
@@ -329,9 +312,7 @@ class SessionCache(
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* and a hundred names is a directory listing.
|
||||
* Recomputed per operation rather than kept: another operation may have changed the directory.
|
||||
*/
|
||||
private fun chunks(): List<Chunk> {
|
||||
writer?.flush()
|
||||
@@ -354,9 +335,9 @@ class SessionCache(
|
||||
* 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 before anything is served from it, 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.
|
||||
* 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
|
||||
@@ -373,8 +354,7 @@ class SessionCache(
|
||||
* 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 rather than served across
|
||||
* whatever else is wrong with it.
|
||||
* the directory is not to be trusted -- so the session is discarded.
|
||||
*/
|
||||
private fun suffix(): List<Chunk> {
|
||||
val all = chunks()
|
||||
@@ -393,10 +373,9 @@ class SessionCache(
|
||||
/**
|
||||
* 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 --
|
||||
* the tail, the opening window, the page before a cursor -- 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.
|
||||
* 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
|
||||
@@ -433,8 +412,8 @@ class SessionCache(
|
||||
}
|
||||
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.
|
||||
// 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()
|
||||
@@ -480,12 +459,12 @@ class SessionCache(
|
||||
*
|
||||
* 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. It is logged, and damage discards this session's cache, which is what makes the
|
||||
* next open an ordinary cold one.
|
||||
* 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.
|
||||
// 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,
|
||||
@@ -515,8 +494,7 @@ 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 (`SeqEvent`'s declaration order, with the
|
||||
* event flattened after it), so the first match is the top-level one.
|
||||
* 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()
|
||||
|
||||
@@ -536,11 +514,10 @@ private const val READ_BLOCK = 64 * 1024
|
||||
*
|
||||
* 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. This reads blocks from the end and stops where the caller stops.
|
||||
* 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, so nothing is cut through a character. A missing file
|
||||
* yields nothing, which is the same answer as an empty one.
|
||||
* 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
|
||||
@@ -581,8 +558,8 @@ 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, and everything before it
|
||||
* is intact. A second bad line is not this, and is left for the read path to notice.
|
||||
* 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
|
||||
@@ -598,9 +575,7 @@ private fun sizeOf(file: File): Long =
|
||||
|
||||
/**
|
||||
* The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance.
|
||||
*
|
||||
* [onFailure] is what the caller does about it beyond answering [ifBroken] -- for a session's
|
||||
* cache, giving up on writing.
|
||||
* [onFailure] is what the caller does about it beyond answering [ifBroken].
|
||||
*/
|
||||
private fun <T> guardIo(
|
||||
ifBroken: T,
|
||||
|
||||
@@ -9,10 +9,9 @@ import kotlinx.coroutines.withContext
|
||||
*
|
||||
* 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. Since 2026-09-04 the cache is where most of them
|
||||
* come from on a session opened again -- see [TranscriptCache], which 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.
|
||||
* 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
|
||||
sealed class TranscriptItem {
|
||||
@@ -24,10 +23,8 @@ sealed class TranscriptItem {
|
||||
* 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.
|
||||
*
|
||||
* A seq is the right identity because it is what the transcript itself is ordered by, it never
|
||||
* changes, and it is already carried by every event. A row built from several events -- a
|
||||
* streaming message, a tool call and its result -- keeps the seq of the first, so it holds
|
||||
* still while the rest of it arrives.
|
||||
* A row built from several events keeps the seq of the first, so it holds still while the rest
|
||||
* of it arrives.
|
||||
*/
|
||||
abstract val seq: Long
|
||||
|
||||
@@ -35,10 +32,8 @@ sealed class TranscriptItem {
|
||||
* 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
|
||||
* the two that know why: a tool call is named after its run, and a peer note is *sorted* by the
|
||||
* turn it started rather than by where it arrived. Asking each item what it is called is also
|
||||
* 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.
|
||||
* the two that know why. Asking each item what it is called is also what stops the next such
|
||||
* item being missed -- a `when` over concrete types would have to gain a case, silently.
|
||||
*/
|
||||
open val key: Any
|
||||
get() = seq
|
||||
@@ -59,15 +54,13 @@ sealed class TranscriptItem {
|
||||
* 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
|
||||
* 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
|
||||
* Pro XL: one 34,996px reply on screen put the frame's draw phase at 13.8ms, 79% of it the
|
||||
* framework's own bookkeeping, which grows with alive nodes.
|
||||
* drawing it as one item indefinitely. Measured on a Pixel 9 Pro XL: one 34,996px reply on
|
||||
* screen put the frame's draw phase at 13.8ms, 79% of it framework bookkeeping.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* be at the newest end; a screen state does not.
|
||||
* inside that reply would step the list under them.
|
||||
*/
|
||||
val settled: Boolean = false,
|
||||
) : TranscriptItem()
|
||||
@@ -79,11 +72,10 @@ sealed class TranscriptItem {
|
||||
* The run of adjacent calls this one belongs to, named once when the call is folded in and
|
||||
* never recomputed.
|
||||
*
|
||||
* Carried rather than derived because a run can gain members at *either* end -- a new call
|
||||
* arriving beside it, or a page of history arriving in front of it -- so no function of its
|
||||
* current members is stable. It is the first call's id at the moment the run started, which
|
||||
* is a name rather than a description: [joinPages] hands it to older calls that turn out to
|
||||
* belong to the same run, instead of renaming the run they joined.
|
||||
* Carried rather than derived because a run can gain members at *either* end, so no
|
||||
* function of its current members is stable. It is the first call's id at the moment the
|
||||
* run started, which is a name rather than a description: [joinPages] hands it to older
|
||||
* calls that turn out to belong to the same run.
|
||||
*/
|
||||
val runId: String,
|
||||
val tool: String,
|
||||
@@ -94,19 +86,16 @@ sealed class TranscriptItem {
|
||||
* 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
|
||||
* repeating the input verbatim, so the reader saw the same command twice and had to work
|
||||
* out that it was one event. The backend says which call a question is about, so this is a
|
||||
* fact rather than a match on the input.
|
||||
* repeating the input verbatim, so the reader saw the same command twice. The backend says
|
||||
* which call a question is about, so this is a 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 permission is the case of exactly one, not a different shape.
|
||||
* A list because AskUserQuestion asks up to four at once, and a permission is the case of
|
||||
* exactly one rather than a different shape.
|
||||
*/
|
||||
val asks: List<QuestionCard> = emptyList(),
|
||||
/**
|
||||
* Images this call's result carried, drawn under it.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
val images: List<String> = emptyList(),
|
||||
) : TranscriptItem() {
|
||||
@@ -134,9 +123,8 @@ sealed class TranscriptItem {
|
||||
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
|
||||
|
||||
/**
|
||||
* A message another agent sent this session.
|
||||
*
|
||||
* Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters.
|
||||
* A message another agent sent this session. Its own row rather than a [UserMsg]: see
|
||||
* [PeerMessageRow] for why the voice matters.
|
||||
*/
|
||||
data class PeerNote(
|
||||
override val seq: Long,
|
||||
@@ -145,11 +133,9 @@ sealed class TranscriptItem {
|
||||
/**
|
||||
* 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
|
||||
* so the note is drawn above the reply it caused. Two messages that arrive during one turn
|
||||
* therefore share a seq -- and sharing an identity as well killed the app, because the
|
||||
* transcript list refuses two items with one key. Two agents writing to a session mid-turn
|
||||
* is an ordinary afternoon, not a corner.
|
||||
* [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began
|
||||
* at. Two messages that arrive during one turn therefore share a seq -- and sharing an
|
||||
* identity as well killed the app, because the list refuses two items with one key.
|
||||
*/
|
||||
val arrived: Long = seq,
|
||||
) : TranscriptItem() {
|
||||
@@ -158,10 +144,9 @@ sealed class TranscriptItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 suddenly has half the context, or a session with a new name.
|
||||
* 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
|
||||
* suddenly has half the context, or a session with a new name.
|
||||
*/
|
||||
data class CommandRow(override val seq: Long, val text: String) : TranscriptItem()
|
||||
|
||||
@@ -170,7 +155,6 @@ sealed class TranscriptItem {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
data class ClearedNote(override val seq: Long) : TranscriptItem()
|
||||
@@ -179,34 +163,43 @@ sealed class TranscriptItem {
|
||||
* 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
|
||||
* it finishes and this is the part worth keeping: it is the explanation for a gap in the
|
||||
* conversation, and for a minute or two in which the session was busy with nothing to show.
|
||||
* it finishes and this is the part worth keeping: the explanation for a gap in the
|
||||
* conversation.
|
||||
*
|
||||
* The wire also says what triggered it, and this deliberately does not carry that: the row says
|
||||
* the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would
|
||||
* be a field nothing can read.
|
||||
* The wire also says what triggered it, and this deliberately does not carry that -- the row
|
||||
* says the two sizes and nothing else, so keeping the trigger would be a field nothing can
|
||||
* read.
|
||||
*/
|
||||
data class CompactedNote(
|
||||
override val seq: Long,
|
||||
val preTokens: Long?,
|
||||
val postTokens: Long?,
|
||||
) : TranscriptItem()
|
||||
|
||||
/**
|
||||
* The account ran out of quota, so the turn stopped here.
|
||||
*
|
||||
* A divider rather than an error: nothing failed, and what a reader scrolling back needs from
|
||||
* it is the same thing a clear or a compaction gives them -- why the conversation stops at this
|
||||
* line.
|
||||
*
|
||||
* [resetsAt] is epoch seconds and null where the session was told nothing, which is a state the
|
||||
* row has words for rather than a time it invents.
|
||||
*/
|
||||
data class LimitNote(override val seq: Long, val resetsAt: Double?) : TranscriptItem()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* keeps whatever it was called when it started, however many calls arrive at either end of it
|
||||
* afterwards.
|
||||
* keeps whatever it was called when it started, however many calls arrive at either end afterwards.
|
||||
*
|
||||
* 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
|
||||
* always visible, since a run of one is drawn as itself rather than as a group; and the calls
|
||||
* around it fall into a group before it and a group after it, so where the reader was asked
|
||||
* something is legible in the shape of the transcript without opening anything. It ends the run
|
||||
* 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.
|
||||
* rather than inside a collapsed "Called 6 tools" card. Two things follow: it is always visible,
|
||||
* since a run of one is drawn as itself; and the calls around it fall into a group before it and a
|
||||
* group after it, so where the reader was asked something is legible in the shape of the transcript
|
||||
* without opening anything.
|
||||
*/
|
||||
private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String {
|
||||
val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id
|
||||
@@ -219,31 +212,23 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
|
||||
* boundary cut in two.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
* from the rest of itself. Both were one thing before the transcript was cut into pages.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* concatenating the two lists left *both*: the same call twice, once as a proper card and once as a
|
||||
* 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.
|
||||
* concatenating the two lists left *both*: the same call twice.
|
||||
*
|
||||
* 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
|
||||
* newer on what an end knows (the output, and whether it finished), which is the only way round
|
||||
* that loses nothing.
|
||||
* boundary destroys. The older row wins on what a start knows and the newer on what an end knows,
|
||||
* which is the only way round that loses nothing.
|
||||
*
|
||||
* 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
|
||||
* falls cleanly between two finished calls, which is most of them, went straight to concatenation
|
||||
* and left the older page's calls under the run name they were folded with. On screen: one run of
|
||||
* tool calls drawn as two groups, with the seam wherever the reader happened to have paged. The two
|
||||
* early returns were an optimisation on a list the size of one page, and they were skipping work
|
||||
* rather than saving it.
|
||||
* falls cleanly between two finished calls, which is most of them, left the older page's calls
|
||||
* under the run name they were folded with. On screen: one run of tool calls drawn as two groups,
|
||||
* with the seam wherever the reader happened to have paged.
|
||||
*/
|
||||
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
|
||||
val (older, newer) = healSplitMessage(earlier, later)
|
||||
@@ -276,15 +261,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.
|
||||
*
|
||||
* [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas
|
||||
* accumulate into the message before them -- so two meeting at a join are always the two halves of
|
||||
* one reply, and leaving them apart drew a single answer as two, with a paragraph break through the
|
||||
* middle of a sentence.
|
||||
* [foldEvent] never leaves two assistant messages next to each other inside one page, so two
|
||||
* meeting at a join are always the two halves of one reply, and leaving them apart drew a single
|
||||
* answer as two with a paragraph break through the middle of a sentence.
|
||||
*
|
||||
* The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on
|
||||
* screen, and renaming that is how the list loses its anchor. It grows by what the older half
|
||||
* brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded,
|
||||
* so the growth extends off the top of the screen, away from the row the list anchors to.
|
||||
* The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older
|
||||
* half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
|
||||
* loaded, so the growth extends off the top of the screen.
|
||||
*/
|
||||
private fun healSplitMessage(
|
||||
earlier: List<TranscriptItem>,
|
||||
@@ -302,19 +285,18 @@ private fun healSplitMessage(
|
||||
* 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
|
||||
* two names. Naming the joined run after the *older* half would be the obvious way round and is the
|
||||
* wrong one: 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
|
||||
* calls take the name of the ones already there, and nothing visible changes identity.
|
||||
* two names. Naming the joined run after the *older* half would be the obvious way round and is
|
||||
* 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.
|
||||
*/
|
||||
private fun adoptRun(
|
||||
earlier: List<TranscriptItem>,
|
||||
later: List<TranscriptItem>,
|
||||
): List<TranscriptItem> {
|
||||
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
|
||||
// the two pages been folded as one -- see `runIdFor`. Without this the heal would merge a
|
||||
// group straight through the row the reader was asked something on.
|
||||
// A question is in a run of its own on both sides of the join, the same as it would be had the
|
||||
// two pages been folded as one. Without this the heal would merge a group straight through the
|
||||
// row the reader was asked something on.
|
||||
if (first.tool == ASK_USER_QUESTION) return earlier
|
||||
val joining = first.runId
|
||||
val tail = earlier.takeLastWhile {
|
||||
@@ -329,10 +311,8 @@ private fun adoptRun(
|
||||
* 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
|
||||
* until the turn's `result`, so the event lands below the whole reply it caused -- the answer
|
||||
* printed above the question. The server stamps it with where that turn began
|
||||
* ([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.
|
||||
* until the turn's `result`, so the event lands below the whole reply it caused. The server stamps
|
||||
* it with where that turn began and the note takes that seq.
|
||||
*
|
||||
* 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
|
||||
@@ -340,8 +320,7 @@ private fun adoptRun(
|
||||
* 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.
|
||||
*
|
||||
* Without a stamp -- a message replayed out of a session file, which is already in the right place
|
||||
* -- it stays where it arrived.
|
||||
* Without a stamp -- a message replayed out of a session file -- it stays where it arrived.
|
||||
*/
|
||||
private fun placePeerNote(
|
||||
items: List<TranscriptItem>,
|
||||
@@ -360,15 +339,14 @@ private fun placePeerNote(
|
||||
* The calls the note now sits in front of, renamed if they were sharing a run with the calls behind
|
||||
* it.
|
||||
*
|
||||
* A run is named from what a call landed next to (see [runIdFor]), and nothing there knows about
|
||||
* turns -- so a turn opening with a tool call, straight after one that ended with one, folds them
|
||||
* into a single run. Left alone, [groupToolRuns] would flush at the note and hand both halves the
|
||||
* same name: two rows with one key, which a keyed list cannot draw at all.
|
||||
* A run is named from what a call landed next to, and nothing there knows about turns -- so a turn
|
||||
* opening with a tool call, straight after one that ended with one, folds them into a single run.
|
||||
* Left alone, [groupToolRuns] would flush at the note and hand both halves the same name: two rows
|
||||
* 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
|
||||
* for the opposite reason. There the two halves were always one run and the newer was already on
|
||||
* screen; here they were never one turn's work, and both halves change appearance at the same
|
||||
* moment the note appears between them.
|
||||
* for the opposite reason: there the two halves were always one run, here they were never one
|
||||
* turn's work.
|
||||
*/
|
||||
private fun splitRun(tail: List<TranscriptItem>, behind: String?): List<TranscriptItem> {
|
||||
val first = tail.firstOrNull() as? TranscriptItem.ToolRun ?: return tail
|
||||
@@ -406,12 +384,10 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
)
|
||||
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
|
||||
is SessionEvent.ToolEnd ->
|
||||
// Created when its start is not here, rather than dropped. A
|
||||
// fold that only ever *updates* loses the whole call when the
|
||||
// start fell outside the loaded window, and a tool call that
|
||||
// renders as nothing is indistinguishable from one that never
|
||||
// happened. The name is unknown from an end alone; loading the
|
||||
// page before this one replaces the row with the real thing.
|
||||
// Created when its start is not here, rather than dropped. A fold that only ever
|
||||
// *updates* loses the whole call when the start fell outside the loaded window, and a
|
||||
// tool call that renders as nothing is indistinguishable from one that never happened.
|
||||
// Loading the page before this one replaces the row with the real thing.
|
||||
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
|
||||
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
|
||||
} else {
|
||||
@@ -419,9 +395,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
TranscriptItem.ToolRun(
|
||||
entry.seq,
|
||||
event.id,
|
||||
// The name is not known from an end alone, so a call that was an ask
|
||||
// cannot be recognised as one here; loading the page before this
|
||||
// replaces the row with the real thing, which is when it splits out.
|
||||
// The name is not known from an end alone, so a call that was an ask cannot
|
||||
// be recognised as one here; the page before this replaces the row.
|
||||
runIdFor(items, event.id, "tool"),
|
||||
"tool",
|
||||
"",
|
||||
@@ -440,9 +415,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
event.multiSelect,
|
||||
emptyList(),
|
||||
)
|
||||
// A question with no tool behind it -- AskUserQuestion, or an ask
|
||||
// whose call fell outside the loaded window -- is a card of its
|
||||
// own, which is what every question was before this.
|
||||
// A question with no tool behind it -- AskUserQuestion, or an ask whose call fell
|
||||
// outside the loaded window -- is a card of its own.
|
||||
if (
|
||||
event.about != null &&
|
||||
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
|
||||
@@ -453,9 +427,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
}
|
||||
}
|
||||
is SessionEvent.Answered ->
|
||||
// Resolved wherever it is drawn: a card of its own, or a tool
|
||||
// row's ask. Missing the second left an Allow/Deny pair live on
|
||||
// a question already answered from another device.
|
||||
// Resolved wherever it is drawn: a card of its own, or a tool row's ask. Missing the
|
||||
// second left an Allow/Deny pair live on a question already answered from another
|
||||
// device.
|
||||
items.map {
|
||||
when {
|
||||
it is TranscriptItem.QuestionCard && it.id == event.id ->
|
||||
@@ -475,20 +449,19 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
|
||||
// Screen-level state, not transcript rows -- see SessionScreen.
|
||||
is SessionEvent.CommandQueued -> items
|
||||
// No row of its own: a message that is still waiting is drawn as a pending bubble below
|
||||
// the transcript, and becomes an ordinary one where the session read it.
|
||||
// No row of its own: a message that is still waiting is drawn as a pending bubble below the
|
||||
// transcript, and becomes an ordinary one where the session read it.
|
||||
is SessionEvent.MessageQueued -> items
|
||||
// The bubble goes away and nothing takes its place: the message was never read, so there
|
||||
// is nothing it belongs above.
|
||||
// The bubble goes away and nothing takes its place: the message was never read, so there is
|
||||
// nothing it belongs above.
|
||||
is SessionEvent.MessageDropped -> items
|
||||
is SessionEvent.Settings -> items
|
||||
is SessionEvent.Status -> settleReply(items, event.state)
|
||||
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
||||
is SessionEvent.Image ->
|
||||
// Under the call that produced it when there is one, and a row of
|
||||
// its own when there is not -- a person's own attachment belongs
|
||||
// to no call, and neither does one whose call fell outside the
|
||||
// loaded window.
|
||||
// Under the call that produced it when there is one, and a row of its own when there is
|
||||
// not -- a person's own attachment belongs to no call, and neither does one whose call
|
||||
// fell outside the loaded window.
|
||||
if (
|
||||
event.about != null &&
|
||||
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
|
||||
@@ -497,6 +470,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
} else {
|
||||
items + TranscriptItem.ImageItem(entry.seq, event.ref)
|
||||
}
|
||||
is SessionEvent.LimitReached -> items + TranscriptItem.LimitNote(entry.seq, event.resetsAt)
|
||||
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
|
||||
is SessionEvent.Compacted ->
|
||||
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
|
||||
@@ -508,9 +482,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.
|
||||
*
|
||||
* See [TranscriptItem.AssistantMsg.settled] for what the mark buys and why it is made here in the
|
||||
* fold. Status changes are transcript events with seqs of their own, so a replayed session settles
|
||||
* its replies the same way a live one does.
|
||||
* See [TranscriptItem.AssistantMsg.settled]. Status changes are transcript events with seqs of
|
||||
* their own, so a replayed session settles its replies the same way a live one does.
|
||||
*/
|
||||
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
|
||||
if (sessionWorking(state)) return items
|
||||
@@ -533,8 +506,7 @@ private fun updateTool(
|
||||
* 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
|
||||
* 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
|
||||
* *start* rather than taking too long once it had.
|
||||
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile.
|
||||
*/
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
|
||||
@@ -543,37 +515,33 @@ private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
|
||||
* 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
|
||||
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
|
||||
* [ParsedReplies].
|
||||
* whole point: the work happens seconds before the reader reaches the rows it was done for.
|
||||
*
|
||||
* 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 --
|
||||
* because a string warmed under a key no row ever looks up is a miss that nothing reports; see
|
||||
* [transcriptUnits], which is the flatten this has to agree with. It reads the same
|
||||
* message -- because a string warmed under a key no row ever looks up is a miss that nothing
|
||||
* reports; see [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
|
||||
* 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
|
||||
* 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
|
||||
* row it was.
|
||||
* had thought about paid its whole parse in the frame it appeared in.
|
||||
*/
|
||||
suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
|
||||
withContext(parsingThreads) {
|
||||
val texts = rows.flatMap { row ->
|
||||
when (row) {
|
||||
is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text }
|
||||
// A message from another agent is markdown too, and it is the longest thing
|
||||
// in a transcript often enough that leaving it out was the whole of why one
|
||||
// cost a fifth of a second to open: it was the only markdown in the app
|
||||
// parsed on the thread that draws.
|
||||
// A message from another agent is markdown too, and it is the longest thing in a
|
||||
// transcript often enough that leaving it out was the whole of why one cost a fifth
|
||||
// of a second to open.
|
||||
is TranscriptItem.PeerNote -> listOf(row.text)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
if (texts.isNotEmpty()) replies.warm(texts)
|
||||
// After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's
|
||||
// licence to draw these as blocks on the composing thread.
|
||||
// After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's licence
|
||||
// to draw these as blocks on the composing thread.
|
||||
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
|
||||
* 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
|
||||
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge. A
|
||||
* conversation shorter than the screen stacks from the bottom, hanging from the composer.
|
||||
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* placed, and an item off screen is not a node at all.
|
||||
* of which was a way to flicker.
|
||||
*
|
||||
* 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
|
||||
* composition costs laying out one paragraph, not parsing a message.
|
||||
* reply, and its parse is already made by [warm] before the fold that introduces it.
|
||||
*
|
||||
* The whole list sits in a [SelectionContainer], which is what makes every word in the transcript
|
||||
* selectable by the platform's own press-and-hold. Here rather than at each place text is drawn: a
|
||||
* transcript is one body of text to a reader, and a container per row would mean a selection could
|
||||
* never cross from a reply into the tool output that follows it -- and would leave whatever was
|
||||
* drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep
|
||||
* their tap handlers: selection is a long press, and the container passes an ordinary click through
|
||||
* to the card under it.
|
||||
* The whole list sits in a [SelectionContainer], which is what makes every word selectable by the
|
||||
* platform's own press-and-hold. Here rather than at each place text is drawn: a transcript is one
|
||||
* body of text to a reader, and a container per row would mean a selection could never cross from a
|
||||
* reply into the tool output that follows it -- and would leave whatever was drawn without one
|
||||
* silently unselectable. Rows keep their tap handlers: selection is a long press.
|
||||
*
|
||||
* [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
|
||||
* job is to put a selection away is not also a tap on the card under it. See the caller's
|
||||
* `expanding`.
|
||||
* rows have to be able to ask whether anything is selected before they act on a tap.
|
||||
*/
|
||||
@Composable
|
||||
fun TranscriptList(
|
||||
@@ -69,8 +62,7 @@ fun TranscriptList(
|
||||
modifier =
|
||||
// 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
|
||||
// half;
|
||||
// see [drawAccounting]. Measure includes composing the items that scrolled in.
|
||||
// half. Measure includes composing the items that scrolled in.
|
||||
modifier
|
||||
.layout { measurable, constraints ->
|
||||
val started = System.nanoTime()
|
||||
@@ -104,8 +96,7 @@ fun TranscriptList(
|
||||
}
|
||||
// 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
|
||||
// it
|
||||
// reports is a fetch in flight rather than an end reached.
|
||||
// it reports is a fetch in flight rather than an end reached.
|
||||
if (moreHistory) {
|
||||
item(key = "history", contentType = "history") {
|
||||
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
|
||||
|
||||
@@ -9,18 +9,16 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
* rest.
|
||||
*
|
||||
* One seam rather than a cache the screen has to remember to consult. Everything it fetched before
|
||||
* -- the opening window, the pages it scrolls back through, the span an anchor restore reaches for
|
||||
* -- 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, which is how the saving is measured.
|
||||
* 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 here: the cache is never
|
||||
* load-bearing. Every read has a network path beside it producing the same result, so a missing,
|
||||
* evicted or damaged cache degrades to exactly what this screen did before it existed.
|
||||
* 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,
|
||||
private val address: TranscriptAddress,
|
||||
val cache: SessionCache,
|
||||
) {
|
||||
private val stream = AtomicReference<EventStream?>(null)
|
||||
@@ -51,24 +49,23 @@ class TranscriptSource(
|
||||
*
|
||||
* 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 directory deleted and the 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. That is the worst
|
||||
* thing this feature can do, and it is caught with one request of a few hundred bytes, in the
|
||||
* slot the opening page's request used to be in.
|
||||
* 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, the failure goes on the stream banner, and the
|
||||
* caller tries again on the stream's own reconnect schedule.
|
||||
* 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, and its caption says so.
|
||||
* 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 answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1)
|
||||
val matches =
|
||||
answer.size == 1 &&
|
||||
try {
|
||||
@@ -86,7 +83,7 @@ class TranscriptSource(
|
||||
*/
|
||||
suspend fun fetchOpening(): List<SeqEvent> {
|
||||
DebugStats.count("transcript page from server")
|
||||
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW)
|
||||
val page = fetchTranscript(settings, address, limit = OPENING_WINDOW)
|
||||
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
|
||||
cache.flush()
|
||||
return page.map { it.second }
|
||||
@@ -100,8 +97,7 @@ class TranscriptSource(
|
||||
* 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, the gap is closed with exactly the bytes it was wide, and the history behind
|
||||
* it is served locally from then on.
|
||||
* there instead.
|
||||
*/
|
||||
suspend fun page(before: Long, limit: Int, coalesce: Boolean): List<SeqEvent> {
|
||||
cache.page(before, limit, rows = coalesce)?.let { lines ->
|
||||
@@ -112,7 +108,7 @@ class TranscriptSource(
|
||||
val page =
|
||||
fetchTranscript(
|
||||
settings,
|
||||
sessionId,
|
||||
address,
|
||||
before = before,
|
||||
limit = limit,
|
||||
coalesce = coalesce,
|
||||
@@ -135,7 +131,7 @@ class TranscriptSource(
|
||||
* well lose.
|
||||
*/
|
||||
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
|
||||
val opened = EventStream(settings, sessionId)
|
||||
val opened = EventStream(settings, address)
|
||||
stream.getAndSet(opened)?.close()
|
||||
try {
|
||||
opened.run(after, onOpen, onReset) { raw, entry ->
|
||||
@@ -169,10 +165,8 @@ private const val OPENING_WINDOW = 80
|
||||
*
|
||||
* 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 -- the sandbox and the real server, or a
|
||||
* re-enrolment -- and a line from one shown against the other is the whole invariant broken. `v1`
|
||||
* is the layout's version: a change to it bumps the segment, and a directory of another version is
|
||||
* deleted the first time this is called.
|
||||
* 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")
|
||||
|
||||
@@ -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
|
||||
* 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
|
||||
* frame is bounded. This is the piece that was missing when a lazy list was last tried here; the
|
||||
* block splitting existed only inside the row, where the list could not see it.
|
||||
* frame is bounded. This is the piece that was missing when a lazy list was last tried here.
|
||||
*
|
||||
* 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.
|
||||
@@ -27,10 +26,9 @@ sealed class TranscriptUnit {
|
||||
abstract val seq: Long
|
||||
|
||||
/**
|
||||
* 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 in this row" to put a reader back where they stopped.
|
||||
* 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
|
||||
* in this row" to put a reader back where they stopped.
|
||||
*/
|
||||
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
|
||||
* 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
|
||||
* while any part of it is on screen. Measured on the emulator, opening a 43KB one took the
|
||||
* transcript's share of the draw phase from 0.81ms a frame to 3.85ms, and the framework's own
|
||||
* per-frame bookkeeping -- which grows with how many nodes are *alive* -- from 0.39ms to
|
||||
* 3.15ms.
|
||||
* same reason. Measured on the emulator, opening a 43KB one took the transcript's share of the
|
||||
* draw phase from 0.81ms a frame to 3.85ms, and the framework's own per-frame bookkeeping from
|
||||
* 0.39ms to 3.15ms.
|
||||
*
|
||||
* 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
|
||||
* owns. See [PeerHeadRow] and [PeerBlockRow].
|
||||
* owns.
|
||||
*/
|
||||
data class PeerHead(
|
||||
override val seq: Long,
|
||||
@@ -84,8 +80,7 @@ sealed class TranscriptUnit {
|
||||
) : TranscriptUnit() {
|
||||
/**
|
||||
* 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
|
||||
* [TranscriptItem.PeerNote].
|
||||
* -- and so two notes stamped with one turn's seq are still two items.
|
||||
*/
|
||||
override val key: Any
|
||||
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
|
||||
* 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
|
||||
* `measure: the whole transcript ... 112.1ms worst` in an otherwise smooth report.
|
||||
* pixels of `Text` whose layout lands in the frame the row scrolls into.
|
||||
*/
|
||||
data class UserChunk(
|
||||
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
|
||||
* 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
|
||||
* only ever cut once), and so is an *opened* peer message -- [openNotes] is which ones those are,
|
||||
* which is why the flatten needs it. A shut one is a single heading and cannot be worth splitting.
|
||||
* The reply still arriving -- the newest row, until the status event that ends its turn marks it
|
||||
* [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, and
|
||||
* 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
|
||||
* Every settled reply is cut into its pieces (via the caches on [replies] so a message is only ever
|
||||
* cut once), and so is an *opened* peer message -- [openNotes] is which ones those are. A shut one
|
||||
* is a single heading and cannot be worth splitting. The reply still arriving stays whole: its text
|
||||
* changes with every delta, and splitting it here would parse the whole message per delta on
|
||||
* whichever thread is composing. 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.
|
||||
*
|
||||
* 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) {
|
||||
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like
|
||||
// the markdown splits so the scan too happens once per message, not once per fold.
|
||||
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like the
|
||||
// markdown splits so the scan happens once per message rather than once per fold.
|
||||
val chunks = replies.chunksOf(item.text)
|
||||
chunks.forEachIndexed { at, chunk ->
|
||||
units +=
|
||||
@@ -252,8 +243,8 @@ fun transcriptUnits(
|
||||
}
|
||||
units.reverse()
|
||||
reportDuplicateKeys(units)
|
||||
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy"
|
||||
// is this number growing, and it was invisible until it was written down.
|
||||
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy" is
|
||||
// this number growing, and it was invisible until it was written down.
|
||||
DebugStats.record("units flattened", System.nanoTime() - started)
|
||||
return units
|
||||
}
|
||||
@@ -261,9 +252,8 @@ fun transcriptUnits(
|
||||
/**
|
||||
* 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
|
||||
* questions are separate because they are answered by different things: this one by the fold, the
|
||||
* other by whether [warm] has run for the text. [unwarmedReplies] is the gap between them.
|
||||
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two are
|
||||
* answered by different things: this one by the fold, the other by whether [warm] has run.
|
||||
*/
|
||||
private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) =
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* ends its split would cost a whole-message parse on the composing thread. The session screen warms
|
||||
* is the reply that just finished streaming -- nothing warms live deltas. The session screen warms
|
||||
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
|
||||
* 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
|
||||
* 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
|
||||
* recorded under two project directories, which is an ordinary state of a machine and not something
|
||||
* the phone did.
|
||||
* recorded under two project directories, which is an ordinary state of a machine.
|
||||
*
|
||||
* 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
|
||||
* rather than a `distinctBy` remembered at each call site.
|
||||
* the hazard and none could rule it out locally. Hence one function they all go through.
|
||||
*
|
||||
* Dropping the repeat is the right answer here because the key is the whole identity: two rows with
|
||||
* one id are two rows every action would treat as the same thing, so there is nothing to show about
|
||||
* the second that the first is not already showing. Where the duplicate means something -- the
|
||||
* import list's did -- the fix belongs at the source, and this is only what stops a data problem
|
||||
* from being a crash. It is counted so the render report says it happened rather than leaving a
|
||||
* silently shorter list.
|
||||
* Dropping the repeat is right here because the key is the whole identity: two rows with one id are
|
||||
* two rows every action would treat as the same thing. Where the duplicate means something, the fix
|
||||
* belongs at the source, and this is only what stops a data problem 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
|
||||
* 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 --
|
||||
* "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
|
||||
* own: there is nothing here to open, so the only thing its Back could ever have meant was "put
|
||||
* this away", which is what dismissing does. The system back gesture dismisses it, since a `Dialog`
|
||||
* handles that itself.
|
||||
* own, so the only thing its Back could ever have meant was "put this away".
|
||||
*/
|
||||
@Composable
|
||||
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
||||
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the
|
||||
// gaps between its title, its content and its buttons at sizes meant for a sentence of prose
|
||||
// and a decision; this is a dense read-out, and those gaps left a band of empty dialog above
|
||||
// Close that was taller than a bar. Everything else here is what AlertDialog would have
|
||||
// drawn -- the same container colour, the same corner -- so nothing about it looks foreign.
|
||||
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
|
||||
// between its title, content and buttons at sizes meant for a sentence of prose and a decision;
|
||||
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was
|
||||
// taller than a bar.
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
@@ -49,11 +46,9 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
// Deliberately not subtitled with the provider this was opened from. These
|
||||
// numbers belong to an account on a particular machine, reported by whichever
|
||||
// paid service answered there -- naming the session's provider here made an
|
||||
// echo session's screen read "echo" above a line reading "claude", which is a
|
||||
// claim about echo that nothing measured. Each machine names itself and the
|
||||
// service it came from, which is the true scope.
|
||||
// numbers belong to an account on a particular machine -- naming the session's
|
||||
// provider here made an echo session's screen read "echo" above a line reading
|
||||
// "claude". Each machine names itself and the service it came from.
|
||||
Text(
|
||||
"Usage",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
@@ -69,10 +64,9 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Scrolls rather than being trimmed: a machine can report any number of windows
|
||||
// and there can be any number of machines, and a dialog is the one place where
|
||||
// running out of room is silent. `fill = false` so a short read-out keeps a short
|
||||
// dialog instead of stretching to the window.
|
||||
// Scrolls rather than being trimmed: a machine can report any number of windows and
|
||||
// there can be any number of machines, and a dialog is the one place where running
|
||||
// out of room is silent. `fill = false` so a short read-out keeps a short dialog.
|
||||
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
|
||||
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.Loaded ->
|
||||
if (current.value.isEmpty()) {
|
||||
// Not an error and not a blank screen: no machine offers a paid service,
|
||||
// so there is genuinely nothing to report and saying so is the answer.
|
||||
// Not an error and not a blank screen: no machine offers a paid service, so
|
||||
// there is genuinely nothing to report and saying so is the answer.
|
||||
Text(
|
||||
"No machine here runs anything with usage limits.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -103,17 +97,16 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
||||
} else {
|
||||
// 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
|
||||
// costing 16dp of padding on every side. What separates one machine from the
|
||||
// next is the line naming it, which is enough for a list this short.
|
||||
// costing 16dp on every side. What separates one machine from the next is the
|
||||
// line naming it.
|
||||
current.value.forEachIndexed { index, snapshot ->
|
||||
if (index > 0) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
// Machine and service on one line: which account these numbers belong to
|
||||
// is decided by both together, and stacked as a heading over a subtitle
|
||||
// they read as a section of their own rather than as the label they are.
|
||||
// Small and quiet, because the numbers below are what somebody opened
|
||||
// this to see.
|
||||
// Machine and service on one line: which account these numbers belong to is
|
||||
// decided by both together, and stacked as a heading over a subtitle they
|
||||
// read as a section of their own. Small and quiet, because the numbers
|
||||
// below are what somebody opened this to see.
|
||||
Text(
|
||||
"${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -121,8 +114,8 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
||||
)
|
||||
SnapshotState(snapshot)
|
||||
snapshot.windows.forEachIndexed { windowIndex, window ->
|
||||
// Between the bars, not after the last one: a trailing gap here is
|
||||
// what put a band of empty dialog above the Close button.
|
||||
// Between the bars, not after the last one: a trailing gap here is what
|
||||
// put a band of empty dialog above the Close button.
|
||||
if (windowIndex > 0) {
|
||||
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
|
||||
* 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
|
||||
* something. Only the two faults are coloured as faults.
|
||||
* the interface nagging about a decision already made. Only the two faults are coloured as faults.
|
||||
*/
|
||||
@Composable
|
||||
private fun SnapshotState(snapshot: UsageSnapshot) {
|
||||
@@ -152,8 +144,8 @@ private fun SnapshotState(snapshot: UsageSnapshot) {
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Reached but refused, versus never reached at all: different things to go and do,
|
||||
// so they say different things rather than sharing one "unavailable".
|
||||
// Reached but refused, versus never reached at all: different things to go and do, so they
|
||||
// say different things rather than sharing one "unavailable".
|
||||
"failed" ->
|
||||
Text(
|
||||
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.
|
||||
*
|
||||
* Null for a window that is not running, which is the case this row has always drawn as nothing and
|
||||
* is right to: there is no end to report. What it used to get wrong is the other missing case, a
|
||||
* timestamp that arrived and could not be read: that was printed raw, so a parse failure appeared
|
||||
* as an ISO string in the middle of a sentence written for a person. Both cases are named in
|
||||
* Null for a window that is not running: there is no end to report. What this used to get wrong is
|
||||
* the other missing case, a timestamp that arrived and could not be read -- printed raw, so a parse
|
||||
* failure appeared as an ISO string in a sentence written for a person. Both are named in
|
||||
* [WindowEnd], and the session bar words them the same way.
|
||||
*/
|
||||
private fun resetLine(window: UsageWindow): String? =
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Overridden by the `bench` build type's resValue (build.gradle.kts) to "AI Sessions bench",
|
||||
so the two are never mistaken for each other in the launcher or in Settings. -->
|
||||
<string name="app_name">AI Sessions</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import java.time.ZoneId
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What the transcript says where a session ran out of quota.
|
||||
*
|
||||
* The pair worth a test is the one that reads the same when it goes wrong: a reset time that
|
||||
* arrived and one that never did. The second must not turn into a plausible-looking time, because a
|
||||
* reader has no way of telling an invented one from a reported one.
|
||||
*/
|
||||
class LimitRowTest {
|
||||
private val utc = ZoneId.of("UTC")
|
||||
|
||||
@Test
|
||||
fun `a reported reset time is shown as a time`() {
|
||||
// 2026-09-05T12:00:00Z. Asserted as a prefix and the clock reading rather than as the
|
||||
// whole string: the platform's own short-time format is what this asks for, and it
|
||||
// differs by JDK and locale down to which space character separates the meridiem.
|
||||
val summary = limitSummary(1_788_609_600.0, utc)
|
||||
assertTrue(summary.startsWith("Usage limit reached • resets "), summary)
|
||||
assertTrue(summary.contains("12:00"), summary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a limit with no reset time says only what is known`() {
|
||||
assertEquals("Usage limit reached", limitSummary(null, utc))
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ class TranscriptCacheTest {
|
||||
|
||||
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
|
||||
|
||||
private fun session(id: String = "s") = cache().session(id)
|
||||
private fun session(id: String = "s") = cache().session(TranscriptAddress(id))
|
||||
|
||||
private fun line(seq: Long, type: String = "toolStart") =
|
||||
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# The P0 benchmark fixture
|
||||
|
||||
`transcript.jsonl` is a synthetic transcript in the app's own event model (the JSON lines
|
||||
`GET /sessions/{id}/transcript` returns; see `Events.kt`'s `parseSeqEvent` and
|
||||
`server/src/session/driver.rs`) -- never a real one. It is what both the Compose `bench` build
|
||||
and iris's bench build open with no server, so the two apps draw exactly the same content and a
|
||||
frame-time comparison is measuring the renderer rather than the data.
|
||||
|
||||
Generated by `./generate.py` (Python stdlib only, seeded -- `SEED = 20260905` -- so re-running it
|
||||
reproduces the same file byte for byte). It writes into `assets/` -- a separate directory from this
|
||||
script and README, because the Compose `bench` build type points its own asset source set straight
|
||||
at `assets/` (`app/androidApp/build.gradle.kts`'s `sourceSets { getByName("bench") }`), and a Python
|
||||
script and a markdown file have no business inside an APK:
|
||||
|
||||
- `transcript.jsonl` -- 3,601 events. The first 3,200 (`BACKLOG_COUNT`) are the scrolled-back
|
||||
history the benchmark opens with: user turns, tool calls with kilobyte-scale input/output,
|
||||
assistant replies built from headings, bold/italic/inline code, a link, fenced code blocks that
|
||||
rotate through rust/kotlin/python/sh/json/toml, a markdown table, two embedded images, and
|
||||
periodic `usageDelta`/`compacted` events. The remaining 400 (`STREAM_COUNT`) are not part of the
|
||||
opening window -- both bench harnesses replay them at a fixed rate (20/s) through the same live
|
||||
fold path a real SSE reply arrives on, which is P0's "streaming phase."
|
||||
- `bench1.png`, `bench2.png` -- tiny (8x8) flat-colour PNGs, base64-free on disk but served the
|
||||
same way a real attachment is (`GET /sessions/{id}/files/{name}`), referenced by the two
|
||||
`"type":"image"` events in the transcript.
|
||||
|
||||
Regenerate after changing the shape (a new event type, a different backlog/stream split) with
|
||||
`./generate.py`, and commit the result -- it is checked in rather than generated at build time so
|
||||
both apps' bench builds embed the identical bytes without needing this script at build time.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 74 B |
Binary file not shown.
|
After Width: | Height: | Size: 74 B |
File diff suppressed because it is too large.
Load diff
Executable
+186
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generates transcript.jsonl -- the synthetic fixture P0's benchmark opens in both apps.
|
||||
|
||||
Deterministic (fixed seed), so a Compose bench APK and an iris bench APK draw byte-identical
|
||||
content: the point of the fixture is a like-for-like comparison, not a realistic one.
|
||||
|
||||
Never a real transcript -- see AGENTS.md's ui-sandbox.sh, which this borrows its vocabulary
|
||||
style from (headings, code fences, a table, a link) rather than reusing its Claude-Code JSONL
|
||||
shape. This file's shape is the *app's own event model* instead: one JSON object per line,
|
||||
matching what GET /sessions/{id}/transcript returns and what Events.kt's parseSeqEvent reads
|
||||
(server/src/session/driver.rs is the source of truth for the field names).
|
||||
|
||||
./generate.py writes transcript.jsonl and bench1.png/bench2.png here
|
||||
|
||||
BACKLOG_COUNT events (seq 1..BACKLOG_COUNT) are the scrolled-back history the benchmark opens
|
||||
with. A further STREAM_COUNT events (seq BACKLOG_COUNT+1..) are not part of the opening window;
|
||||
both bench harnesses replay them at a fixed rate as the "streaming reply" phase, appended through
|
||||
the same live path a real SSE reply arrives on. Keeping both halves in one file means one
|
||||
generator and one seed to keep in sync, rather than two fixtures that can drift apart.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import random
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
SEED = 20260905
|
||||
BACKLOG_COUNT = 3200
|
||||
STREAM_COUNT = 400
|
||||
HERE = Path(__file__).resolve().parent / "assets"
|
||||
|
||||
random.seed(SEED)
|
||||
|
||||
LANGUAGES = ["rust", "kotlin", "python", "sh", "json", "toml"]
|
||||
|
||||
CODE_SNIPPETS = {
|
||||
"rust": '''fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
|
||||
// a comment worth keeping: this is the fold the app's own screen runs
|
||||
let mut out = items;
|
||||
out.push(Item::new(seq));
|
||||
out
|
||||
}''',
|
||||
"kotlin": '''fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> {
|
||||
// mirrors the server's own event model, one item per line
|
||||
return items + TranscriptItem.from(entry)
|
||||
}''',
|
||||
"python": '''def render_report(frames, cpu_ms, rss_kb):
|
||||
# printed for a human to paste back, so every number carries its unit
|
||||
return f"{frames} frames, {cpu_ms}ms cpu, {rss_kb}kb peak rss"''',
|
||||
"sh": '''#!/bin/sh
|
||||
# scripted scroll loop, the shape transcript-bench.sh drives on a phone
|
||||
for i in $(seq 1 24); do
|
||||
ui-trace record --do "swipe 540 700 540 1600 200"
|
||||
done''',
|
||||
"json": '{"seq": 1, "type": "status", "state": "running"}',
|
||||
"toml": '''[package]
|
||||
name = "bench-fixture"
|
||||
version = "0.1.0"''',
|
||||
}
|
||||
|
||||
HEADINGS = [
|
||||
"## Plan",
|
||||
"## What changed",
|
||||
"## Why this approach",
|
||||
"### Open questions",
|
||||
"## Results",
|
||||
]
|
||||
|
||||
WORDS = (
|
||||
"session render report frame budget scroll transcript fold event cache "
|
||||
"cursor probe stream backlog swipe fixture bench compose iris widget layout "
|
||||
"measure place draw tool call token context window anchor"
|
||||
).split()
|
||||
|
||||
|
||||
def paragraph(n=24):
|
||||
words = [random.choice(WORDS) for _ in range(n)]
|
||||
words[0] = words[0].capitalize()
|
||||
text = " ".join(words) + "."
|
||||
# Sprinkle markdown inline spans so the syntax highlighter/markdown parser sees a real mix.
|
||||
text = text.replace(" fold ", " **fold** ", 1)
|
||||
text = text.replace(" cursor ", " *cursor* ", 1)
|
||||
text = text.replace(" cache ", " `cache` ", 1)
|
||||
if "bench" in text:
|
||||
text = text.replace(
|
||||
" bench ", " [bench](https://example.com/bench) ", 1
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def make_png(rgb, size=8):
|
||||
"""A tiny, valid PNG -- flat colour, no external dependency."""
|
||||
|
||||
def chunk(tag, data):
|
||||
c = tag + data
|
||||
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c))
|
||||
|
||||
sig = b"\x89PNG\r\n\x1a\n"
|
||||
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
|
||||
raw = b""
|
||||
for _ in range(size):
|
||||
raw += b"\x00" + bytes(rgb) * size
|
||||
idat = zlib.compress(raw)
|
||||
return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
|
||||
|
||||
|
||||
def main():
|
||||
HERE.mkdir(exist_ok=True)
|
||||
lines = []
|
||||
seq = 1
|
||||
ts = 1_788_000_000.0
|
||||
|
||||
def emit(type_, **fields):
|
||||
nonlocal seq, ts
|
||||
obj = {"seq": seq, "ts": round(ts, 3), "type": type_}
|
||||
obj.update(fields)
|
||||
lines.append(json.dumps(obj, separators=(",", ":")))
|
||||
seq += 1
|
||||
ts += random.uniform(0.05, 2.0)
|
||||
|
||||
emit("status", state="running")
|
||||
emit("settings", model="bench-model", permissionMode="auto")
|
||||
|
||||
image_refs = []
|
||||
turn = 0
|
||||
while seq <= BACKLOG_COUNT:
|
||||
turn += 1
|
||||
emit("userMessage", text=f"Turn {turn}: {paragraph(12)}", id=None, attachments=[])
|
||||
|
||||
# A tool call with kilobyte-scale input/output every few turns.
|
||||
if turn % 3 == 0:
|
||||
tool_id = f"tool-{turn}"
|
||||
big_input = json.dumps({"path": f"/repo/file_{turn}.rs", "content": paragraph(400)})
|
||||
emit("toolStart", id=tool_id, tool="Edit", input=big_input)
|
||||
big_output = "\n".join(paragraph(60) for _ in range(20))
|
||||
emit("toolUpdate", id=tool_id, output=big_output[: len(big_output) // 2])
|
||||
emit("toolEnd", id=tool_id, output=big_output)
|
||||
|
||||
# A reply: a heading, prose, a fenced block in a rotating language, a table, then deltas.
|
||||
emit("assistantText", delta=f"{random.choice(HEADINGS)}\n\n")
|
||||
emit("assistantText", delta=paragraph(30) + "\n\n")
|
||||
lang = LANGUAGES[turn % len(LANGUAGES)]
|
||||
emit("assistantText", delta=f"```{lang}\n{CODE_SNIPPETS[lang]}\n```\n\n")
|
||||
if turn % 5 == 0:
|
||||
emit(
|
||||
"assistantText",
|
||||
delta="| column | value |\n|---|---|\n| a | " + paragraph(3) + " |\n\n",
|
||||
)
|
||||
# A run of small deltas -- the shape a live reply actually streams in.
|
||||
for _ in range(random.randint(3, 8)):
|
||||
emit("assistantText", delta=paragraph(6) + " ")
|
||||
|
||||
# A couple of images, base64 PNGs, the way a real transcript embeds a screenshot.
|
||||
if turn in (10, 40):
|
||||
ref = f"bench{len(image_refs) + 1}.png"
|
||||
image_refs.append(ref)
|
||||
emit("image", ref=ref, about=None)
|
||||
|
||||
emit("usageDelta", tokens=random.randint(200, 4000), context=random.randint(2000, 180000))
|
||||
|
||||
if turn % 15 == 0:
|
||||
emit(
|
||||
"compacted",
|
||||
preTokens=180000,
|
||||
postTokens=20000,
|
||||
trigger="auto",
|
||||
)
|
||||
|
||||
# The streaming-phase tail: one long reply, built entirely from text deltas, the shape a
|
||||
# bench harness replays at a fixed events/sec through the live fold path.
|
||||
emit("userMessage", text="One more, streamed live for the benchmark's timing phase.", id=None, attachments=[])
|
||||
while seq <= BACKLOG_COUNT + STREAM_COUNT:
|
||||
emit("assistantText", delta=paragraph(5) + " ")
|
||||
emit("status", state="idle")
|
||||
|
||||
(HERE / "transcript.jsonl").write_text("\n".join(lines) + "\n")
|
||||
|
||||
(HERE / "bench1.png").write_bytes(make_png((220, 90, 90)))
|
||||
(HERE / "bench2.png").write_bytes(make_png((90, 150, 220)))
|
||||
|
||||
print(f"wrote {len(lines)} events ({BACKLOG_COUNT} backlog + {STREAM_COUNT} stream) to transcript.jsonl")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+9
-3
@@ -4,6 +4,11 @@
|
||||
# ./build-apk.sh the release build, signed (what the phone runs)
|
||||
# ./build-apk.sh debug the debug build, for reproducing something the
|
||||
# emulator scripts would build anyway
|
||||
# ./build-apk.sh bench P0's benchmark build (own app id, "AI Sessions
|
||||
# bench" label, opens straight onto the fixture
|
||||
# session -- see docs/RUST.md's P0 box and
|
||||
# app/bench-fixture/README.md). Signed the same
|
||||
# as release; never touches the CA it pins.
|
||||
#
|
||||
# Dev Updater's `.dev-updater.ron` at the checkout root spells these out as
|
||||
# build modes, one command line each; it passes nothing else, so the word
|
||||
@@ -25,8 +30,9 @@ VARIANT=${1:-release}
|
||||
case "$VARIANT" in
|
||||
release) TASK=assembleRelease ;;
|
||||
debug) TASK=assembleDebug ;;
|
||||
bench) TASK=assembleBench ;;
|
||||
*)
|
||||
echo "build-apk.sh: unknown variant '$VARIANT' (release, debug)" >&2
|
||||
echo "build-apk.sh: unknown variant '$VARIANT' (release, debug, bench)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -81,7 +87,7 @@ fi
|
||||
# uninstalling it first: the signatures differ, and Android refuses to
|
||||
# update across them.
|
||||
KEYSTORE="${AI_APP_KEYSTORE:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/release.jks}"
|
||||
if [ "$VARIANT" = release ] && [ ! -f "$KEYSTORE" ]; then
|
||||
if { [ "$VARIANT" = release ] || [ "$VARIANT" = bench ]; } && [ ! -f "$KEYSTORE" ]; then
|
||||
KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/keytool}"
|
||||
KEYTOOL="${KEYTOOL:-keytool}"
|
||||
if ! command -v "$KEYTOOL" >/dev/null 2>&1; then
|
||||
@@ -97,7 +103,7 @@ if [ "$VARIANT" = release ] && [ ! -f "$KEYSTORE" ]; then
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-storepass "$PASSWORD" -keypass "$PASSWORD" -dname "CN=ai-app" >/dev/null 2>&1)
|
||||
fi
|
||||
if [ "$VARIANT" = release ]; then
|
||||
if [ "$VARIANT" = release ] || [ "$VARIANT" = bench ]; then
|
||||
AI_APP_KEYSTORE="$KEYSTORE"
|
||||
AI_APP_KEYSTORE_PASSWORD=$(cat "$KEYSTORE.password")
|
||||
export AI_APP_KEYSTORE AI_APP_KEYSTORE_PASSWORD
|
||||
|
||||
Loaded 100 of 311 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user