Compare commits
114
Commits
643daf5637
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53fc59a946 | ||
|
|
3dbf04f5ec | ||
|
|
5626a7d595 | ||
|
|
1aac22bfc9 | ||
|
|
4b5ed6e398 | ||
|
|
7278a58387 | ||
|
|
386c1c4def | ||
|
|
849c3b599f | ||
|
|
66b3403a71 | ||
|
|
049780fda6 | ||
|
|
78f2fe3b79 | ||
|
|
6ed896f1e1 | ||
|
|
7e7910083c | ||
|
|
df48a334f7 | ||
|
|
cedb18e8c1 | ||
|
|
3b309766d7 | ||
|
|
bd9596d782 | ||
|
|
b7fd18b195 | ||
|
|
942edd6b31 | ||
|
|
c8bfc958ad | ||
|
|
ef788b0405 | ||
|
|
c3c6ab0ecf | ||
|
|
81c30dcda1 | ||
|
|
8c323fc7a9 | ||
|
|
74cda485e5 | ||
|
|
369b8f7e52 | ||
|
|
b660905098 | ||
|
|
bb5ac1a242 | ||
|
|
45f249ae91 | ||
|
|
81ab564a09 | ||
|
|
ac476ab0c9 | ||
|
|
392cc5413d | ||
|
|
03376af446 | ||
|
|
0a2f0eed5f | ||
|
|
cd0229bed6 | ||
|
|
84f978f16d | ||
|
|
ee5bef3686 | ||
|
|
914985b8b2 | ||
|
|
581e07624f | ||
|
|
1b38579b97 | ||
|
|
b86a5dc37a | ||
|
|
463acb28fa | ||
|
|
827a30768c | ||
|
|
cbae7ee8c0 | ||
|
|
a9cfea89e5 | ||
|
|
947ea8ecf2 | ||
|
|
f00a178cf0 | ||
|
|
9bcf0f1a48 | ||
|
|
33b130b6bb | ||
|
|
3d1b1e304d | ||
|
|
06bf1c8f81 | ||
|
|
3f94eeb6d6 | ||
|
|
1c60e78b55 | ||
|
|
8262ceb786 | ||
|
|
9fd21af4e8 | ||
|
|
f0661919bb | ||
|
|
0be15adbee | ||
|
|
1e52b2910c | ||
|
|
036eb375aa | ||
|
|
b9b777acaf | ||
|
|
46831520e3 | ||
|
|
3af2502982 | ||
|
|
59ebd75b46 | ||
|
|
579689cbb8 | ||
|
|
fe25108c51 | ||
|
|
898e6b92d0 | ||
|
|
cad0cbcfbe | ||
|
|
83b113ef0f | ||
|
|
7d9df5d572 | ||
|
|
e9a0f1b9da | ||
|
|
6d765ff6e4 | ||
|
|
559e6c9226 | ||
|
|
76895bc644 | ||
|
|
0b4da64062 | ||
|
|
62cb6c91d5 | ||
|
|
2ff0b13950 | ||
|
|
57e1cec09c | ||
|
|
6226a1cb43 | ||
|
|
22f263ccce | ||
|
|
59965d314f | ||
|
|
e3cca97cdd | ||
|
|
3c6e6778fd | ||
|
|
3c19b5a9bb | ||
|
|
f9c8f640ce | ||
|
|
4c15150338 | ||
|
|
cbdd8493ed | ||
|
|
26fe9895e7 | ||
|
|
b00e89795e | ||
|
|
10ce1a216b | ||
|
|
b507656abd | ||
|
|
4dc3e3d784 | ||
|
|
14dd520719 | ||
|
|
8c88a7e991 | ||
|
|
00538cc19b | ||
|
|
7ee88dfd9c | ||
|
|
6a0202b1b5 | ||
|
|
0862b47f76 | ||
|
|
fd71d876e1 | ||
|
|
9cc52beb09 | ||
|
|
1bbb642973 | ||
|
|
ef1aad8776 | ||
|
|
5711c2568a | ||
|
|
74c07d687a | ||
|
|
13d2d11c2d | ||
|
|
cf10b17c5b | ||
|
|
9fa09b0af1 | ||
|
|
eff5c8b0c0 | ||
|
|
7b63330aaa | ||
|
|
6bdec6e785 | ||
|
|
4821a02bd3 | ||
|
|
1ff662c7c3 | ||
|
|
e4f0935f98 | ||
|
|
3c0214ece8 | ||
|
|
127b25e60a |
No files matched your search
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/ai-app-rigs
|
||||
@@ -0,0 +1,380 @@
|
||||
---
|
||||
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 script that ordinarily runs
|
||||
`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*. The sandbox's
|
||||
version also handles `auth login`: it prints an inert Anthropic-shaped URL,
|
||||
rejects any code except `sandbox-code`, and exits successfully for that one.
|
||||
- **`/think [seconds]` in an echo session puts up a thinking card**, long
|
||||
enough to watch it spin before it closes with the span it actually took.
|
||||
The rest of the turn is the ordinary echo reply, so it is also the rig for
|
||||
a block and a reply meeting.
|
||||
- **`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** and need nothing typed. The prebuilt llama.cpp lives
|
||||
outside the repo at `~/.local/opt/llama.cpp-vk` — a **Vulkan** build as of
|
||||
2026-09-19, replacing the CPU one that was there before — and is symlinked as
|
||||
both `~/.local/bin/llama-server` and `/usr/local/bin/llama-server`. The second
|
||||
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.
|
||||
|
||||
Two models are downloaded under `~/.local/share/ai-app/models`:
|
||||
|
||||
- `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`, 639 MB, loads in ~4s. It
|
||||
calls tools correctly and is the right rig for the driver's shape. Do not
|
||||
judge *answers* by it — asked for the second line of a file it read from
|
||||
line 2 and then named the third.
|
||||
- `ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF/Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp.gguf`,
|
||||
12 GB, ~20s to load, and the only one here with a multi-token-prediction
|
||||
head. It is the rig for anything about `loading` being a state of its own,
|
||||
since 20s is long enough to send into.
|
||||
|
||||
- `ggml-org/SmolVLM-256M-Instruct-GGUF/SmolVLM-256M-Instruct-Q8_0.gguf`,
|
||||
175 MB, plus the `mmproj-…` beside it, downloaded 2026-09-20 as the rig for
|
||||
**vision**: it is the only model here that reads pictures, it loads in
|
||||
seconds on the CPU, and it described a red circle correctly. The pair is
|
||||
also what exercises the projector being found beside the weights, and the
|
||||
projector being kept out of the models a provider offers. Qwen3-0.6B beside
|
||||
it is the other half of that rig -- the model that answers `refused`.
|
||||
|
||||
**A second llama.cpp is installed here, and it is the rig for a custom
|
||||
build.** `~/.local/share/ai-app/llama/prism/` is Prism ML's fork
|
||||
(`prism` branch, `~/repos/llama.cpp-prism`, Vulkan, `cmake --install
|
||||
--prefix`), so discovery finds it as a provider called `llama-cpp-prism`
|
||||
beside the ordinary `llama-cpp`. It is what exercises that mechanism at all,
|
||||
and it serves `prism-ml/Ternary-Bonsai-2-27B-gguf` -- ternary packings stock
|
||||
llama.cpp rejects as unknown types. Rebuild it with
|
||||
`cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_VULKAN=ON
|
||||
-DCMAKE_INSTALL_LIBDIR=lib -DCMAKE_INSTALL_RPATH='$ORIGIN/../lib'`, about six
|
||||
minutes at `-j8`.
|
||||
|
||||
**Both of those install flags are load-bearing, and the failure is a session
|
||||
that never becomes ready.** `llama-server` is a 16 KB launcher against
|
||||
`libllama-server-impl.so`, so a build whose libraries it cannot find dies at
|
||||
`exec` with `error while loading shared libraries` -- which reaches the phone
|
||||
as the model never answering. The runpath has to be set, *and* the libraries
|
||||
have to be where it points: `GNUInstallDirs` chooses `lib64` on some
|
||||
distributions (Gentoo's amd64 profiles among them) while the runpath above
|
||||
says `lib`. `readelf -d bin/llama-server | grep RUNPATH` and
|
||||
`ldd bin/llama-server | grep 'not found'` are the two-second check after any
|
||||
install here.
|
||||
|
||||
**Which Bonsai packing runs on the GPU is the backend's question, not the
|
||||
model's.** Measured 2026-09-21 with `llama-bench -p 512 -n 64 -r 2 -fa 1
|
||||
-ngl 99` on the free card:
|
||||
|
||||
| packing | backend | pp512 | tg64 |
|
||||
| --- | --- | ---: | ---: |
|
||||
| `PTQ1_0`, 5.53 GiB | Vulkan | 519 t/s | 7.5 t/s |
|
||||
| `PQ2_0`, 7.21 GiB | **CPU**, 8 cores | unfinished after 9 min | -- |
|
||||
|
||||
The fork's Vulkan port covers `PTQ1_0` only -- shaders, a `mul_mat_vec` and
|
||||
the FWHT included -- so `PQ2_0` has no kernel there and every matmul falls
|
||||
back to the CPU. That reads exactly like a stuck load: the process sits at
|
||||
700% CPU for minutes with the card idle. On CUDA and HIP it is the other way
|
||||
round, since `mmq.cu` guards `PTQ1_0` out of the HIP build (it wants Turing
|
||||
MMA) and leaves `PQ2_0` in. **ROCm cannot be tested in this VM**: there is no
|
||||
`/dev/kfd`, because the GPU here is virtio-gpu rather than a passed-through
|
||||
card.
|
||||
|
||||
7.5 tok/s is the honest speed of that Vulkan kernel, against 42 for the
|
||||
IQ3_S 27B beside it -- smaller weights, slower decode. Nothing is
|
||||
misconfigured; the fork's fast kernels are CUDA and Metal.
|
||||
|
||||
**Do not test with a 2-bit quant**: the IQ2_XXS of the 0.6B 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.
|
||||
|
||||
**The GPU is shared and llama-server dies loudly when it runs out.** A second
|
||||
server loading a model while the 27B holds VRAM fails with `radv/amdgpu:
|
||||
Failed to allocate a buffer` / `MESA: error: buffer allocation failed` and
|
||||
exits mid-request. `-ngl 0` runs it on the 8 cores instead, which is the way
|
||||
to test the driver while something else holds the card -- through the app, that
|
||||
is the model's "Layers on the GPU" set to 0 in the machines tab's provider
|
||||
view, and `--models-max` above 1 is how two models come to be loaded at once
|
||||
in the first place.
|
||||
|
||||
**Testing tools and MCP without the app**: `llama-server --tools all` publishes
|
||||
its built-in tools at `GET /tools` and runs one at `POST /tools` with
|
||||
`{"tool": …, "params": …}` and an `x-tool-cwd` header — so a whole agent loop
|
||||
is drivable with `curl` and no model at all. The Exa MCP server at
|
||||
`https://mcp.exa.ai/mcp` answers **without an API key** and needs a
|
||||
`User-Agent` header (Cloudflare answers 403 without one, which reads as a
|
||||
refusal rather than a missing header).
|
||||
|
||||
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
|
||||
machine 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 machine 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`, currently Claude Code or Codex). 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
|
||||
|
||||
- **`-np 1` is what makes the MTP draft head pay.** Taken 2026-09-19 on the
|
||||
27B above, decode speed for a 300-token reply, from `llama-server`'s own
|
||||
timings rather than the clock:
|
||||
|
||||
| flags | tok/s |
|
||||
| --- | --- |
|
||||
| plain, any `-np` | 41.5 |
|
||||
| `--spec-type draft-mtp -np 1` | 61.4 |
|
||||
| `--spec-type draft-mtp -np 2` (n-max 2) | 65.9 |
|
||||
| `--spec-type draft-mtp`, default `-np` (4 slots) | 28 |
|
||||
|
||||
Draft acceptance is 0.53–0.73 in every case, so the head is working in all
|
||||
of them: what changes is that speculating against a KV cache split four ways
|
||||
is slower than not speculating. A model's preset gets `parallel = 1` unless
|
||||
its settings say otherwise (the machines tab's provider view, since
|
||||
2026-09-19), so this is recorded for whoever next sees MTP look broken or
|
||||
next raises the slot count to answer two sessions at once. `--spec-draft-n-max 2` was
|
||||
worth another 7% in a single sample and is deliberately *not* passed — one
|
||||
sample on a virtualised GPU is not a number to hardcode.
|
||||
|
||||
- **Prompt processing is the expensive part of a llama turn here, and decode
|
||||
speed falls only slowly with context.** Taken 2026-09-19 on a free GPU, the
|
||||
27B with `--spec-type draft-mtp -np 1`, generating 160 tokens each time:
|
||||
|
||||
| context | decode | prefill of that prompt |
|
||||
| --- | --- | --- |
|
||||
| 88 | 43.4 tok/s (cold) | 21s |
|
||||
| 1,569 | 55.5 tok/s | (model still warming) |
|
||||
| 6,068 | 53.2 tok/s | 9.5s |
|
||||
| 14,068 | 50.3 tok/s | 22s |
|
||||
|
||||
So a turn on a long conversation spends tens of seconds before the first
|
||||
token, and that is what `SessionStatus::Reading` exists to say. The same
|
||||
sweep on the 0.6B **on the CPU** falls much harder -- 30.1 tok/s at 44
|
||||
tokens of context to 11.5 at 6,024 -- which is the shape somebody means by
|
||||
"it gets slower as the conversation goes on". The figure the app draws is
|
||||
`timings.predicted_per_second`, decode only, so prefill is never mixed into
|
||||
it.
|
||||
|
||||
- **A busy GPU is a model that will not load at all**, not a slow one:
|
||||
`radv/amdgpu: Failed to allocate a buffer` and `failed to load model` while
|
||||
something else holds VRAM. A 0.6B that had been decoding at 149 tok/s ran at
|
||||
16.7 in that window before its server died, so a tok/s figure taken while
|
||||
the card is shared says nothing about the model.
|
||||
|
||||
- **Asking for the head when the file has none is fatal**, not ignored:
|
||||
`context type MTP requested but model doesn't contain MTP layers` and the
|
||||
server exits. Without the flag the same file logs `unused tensor
|
||||
blk.N.nextn.* — ignoring` and runs normally, which is the state to look for
|
||||
when MTP is silently not happening.
|
||||
|
||||
- **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.
|
||||
@@ -1,6 +1,6 @@
|
||||
# ai-app
|
||||
|
||||
A phone interface to AI coding sessions (Claude Code and llama.cpp),
|
||||
A phone interface to AI coding sessions (Codex, Claude Code and llama.cpp),
|
||||
replacing the Claude app for daily use. Rust/Axum backend on the desktop,
|
||||
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
||||
between them.
|
||||
@@ -9,7 +9,15 @@ between them.
|
||||
its rationale, and what was rejected. Read it before changing anything
|
||||
structural, and update it in place when a decision changes rather than
|
||||
letting this file and the plan become two versions of the truth. This file is
|
||||
the working notes layer: layout, commands, rigs, and things that have bitten.
|
||||
the working notes layer: layout, commands, and things that have bitten.
|
||||
|
||||
**The rigs are the `ai-app-rigs` skill** — the sandbox and bench scripts, the
|
||||
rule that no UI-driving script may tap a coordinate, how to test llama.cpp and
|
||||
ssh here, how importing behaves, and the measurements not worth re-taking.
|
||||
They moved there on 2026-09-04 because they are 12 KB that only matter once
|
||||
you are actually running one, and this file is sent with every request. Read
|
||||
it before writing or running a benchmark, driving the UI from a script, or
|
||||
touching the import screen.
|
||||
|
||||
The central design point, worth not undoing by accident: **a session is a
|
||||
child process, translated into one common event model.** A new session type
|
||||
@@ -26,10 +34,188 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
||||
|
||||
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
||||
comment is the HTTP table and the surface's source of truth.
|
||||
**A machine's models are served by one shared `llama-server`** (2026-09-19,
|
||||
`session/llama/router.rs`): started with no `-m`, which makes it a
|
||||
**router** — it reads a preset file naming models and their flags, starts a
|
||||
child server per model asked for, and routes by the `model` field in each
|
||||
request. So a session has no process of its own, two sessions on one model
|
||||
share one copy of it in memory, and a backend restart adopts one process
|
||||
rather than one per session. Four things fall out of it and are easy to get
|
||||
wrong again — a session records the router's pid in its own directory as
|
||||
`process::Detail::Shared`, and `process::stop` refuses to signal a `Shared`
|
||||
record, which is what keeps one session ending from unloading everybody's
|
||||
model; **nothing stops a router on its own**, and the only thing that does
|
||||
is the machine's provider view (`POST /machines/{id}/providers/{p}/stop`);
|
||||
how a model is *loaded* is per model on its machine
|
||||
(`ProviderConfig::model_settings`, `LLAMA_MODEL_PARAMS`) rather than per
|
||||
session, and saving those settings rewrites the preset, which **unloads**
|
||||
that model; and the preset is read back before every edit, because a router
|
||||
adopted from an earlier run is serving sections this process has never seen
|
||||
and rewriting without them unloads those.
|
||||
**A machine can have more than one llama.cpp** (2026-09-21,
|
||||
`machines.rs`): anything at `~/.local/share/ai-app/llama/<name>/llama-server`
|
||||
or `.../<name>/bin/llama-server` is discovered beside the one on PATH and
|
||||
becomes a provider called `llama-cpp-<name>`, with its own router, preset
|
||||
and model settings. That is how a model whose kernels are not upstream is
|
||||
served -- Prism ML's ternary Bonsai is the one here, built from the
|
||||
`prism` branch into `~/.local/share/ai-app/llama/prism` -- without the
|
||||
phone ever naming a command, which is the property this module exists for.
|
||||
Both providers offer the machine's whole models directory, since which
|
||||
build reads which packing is not answerable from the file.
|
||||
**A llama.cpp session runs on its configured machine** (built
|
||||
2026-09-04, the last of phase 5): `Transport::reserve_port` returns the
|
||||
port the server binds *there* and the port that reaches it *here*, and
|
||||
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
|
||||
the command. Three things fell out of it and are easy to get wrong again —
|
||||
a forwarded launch gets a pty (`-tt`) and every other one keeps `-T`,
|
||||
because `llama-server` never reads the stdin whose closing ends a CLI and
|
||||
the same kill left it loaded on the far machine; the model is looked for on
|
||||
the machine that will serve it, so the spawn screen offers
|
||||
`GET /machines/{id}/providers/{p}/models` rather than any list of this
|
||||
backend's own; and the
|
||||
readiness poll watches the process as well as the port, since a model that
|
||||
will not load exits in a second and was being reported as "gave up after
|
||||
300s". See PLAN.md's "Transport" and "llama-server management".
|
||||
**A llama session has tools and runs the loop itself** (2026-09-19):
|
||||
`--tools all` gives the router `llama-server`'s built-in set, which it also
|
||||
*runs* (`GET /tools` for the definitions, `POST /tools` to call one), while
|
||||
web search comes from an MCP server this backend connects to directly
|
||||
(`session/llama/mcp.rs`, Exa preset in a discovered provider's
|
||||
`mcpServers`). Driving the loop is what makes the permission gate ours:
|
||||
`manual` asks before every call and remembers a tool you answer
|
||||
"Always allow …" to, `bypassPermissions` never asks, and the allowances are
|
||||
folded back out of the transcript. Which tools a *session* offers is a
|
||||
filter applied to those definitions here, not a flag over there: one shared
|
||||
server has one set, and the filter costs no reload (2,181 tokens of prompt
|
||||
with all seven, 698 with none). Three more things fall out of it and are
|
||||
easy to get wrong again — a model change **asks for another model** and
|
||||
stops nothing, since the one being left may be another session's;
|
||||
`parallel = 1` unless that model's settings say otherwise, and it is what
|
||||
decides whether the MTP draft head is a 50% speed-up or a 33% loss; and
|
||||
`spec-type = draft-mtp` is conditional on the file actually having a head,
|
||||
because asking for one that is not there makes `llama-server` **exit**.
|
||||
**A llama session takes a picture only where the model natively reads one**
|
||||
(2026-09-20): a multimodal model is loaded with the `mmproj` found beside
|
||||
its weights (overridable per model, `off` included), an attachment rides in
|
||||
the request as an `image_url` data URI, and nothing at all is done for a
|
||||
model without a projector. Four things fall out of it and are easy to get
|
||||
wrong again -- whether a session takes pictures is `/props`'s
|
||||
`modalities.vision` from the loaded server and never a guess from this side,
|
||||
with three states because a loading model has not answered yet
|
||||
(`Images::Unknown` is *offered*, since a control withheld because nobody
|
||||
could ask is missing from sessions that would have taken it); a message
|
||||
carrying an image a model cannot read is **stopped rather than stripped**,
|
||||
refused at the door, at the queue and at the steering boundary, because
|
||||
`llama-server` refuses the whole request over one part and a message sent
|
||||
without its picture is a different message; an earlier turn's image folds
|
||||
into a line of words for a model without vision, so switching models does
|
||||
not end the conversation; and a projector is filtered out of the models a
|
||||
provider *offers*, while staying in the machine's own model list.
|
||||
**A llama session's thinking is drawn** (2026-09-19): `reasoning_content`
|
||||
becomes `Event::Thinking` deltas closed by an `Event::ThinkingDone` carrying
|
||||
the span the *driver* measured, and the phone draws a card that spins while
|
||||
the block is open and says "Thought for 12.4s" once it is not. The reasoning
|
||||
is deliberately not part of the next prompt (`conversation` ignores it), and
|
||||
`timings.predicted_per_second` and `timings.prompt_ms` off the same stream
|
||||
become `UsageDelta`'s `tokensPerSecond` and `prefillMs`, which is the
|
||||
"read 9.5s · 50.3 tok/s · 3:00 PM" under a finished reply — nothing else here
|
||||
measures either, so every other driver sends `None`, and the clock is last so
|
||||
that it does not move when a provider reports fewer of them.
|
||||
**A wait that can be measured says how far along it is** (2026-09-21):
|
||||
`GET /sessions/{id}/progress` answers `{of, fraction, stage?}` and `null`
|
||||
for a session that is not in one -- runtime state, asked for twice a second
|
||||
by the session screen while it is drawing a wait, and deliberately never an
|
||||
event, since a load reports five times a second and every event is a
|
||||
transcript line for ever. The two sources are the router's `/models/sse`
|
||||
stream, which is the **only** place a model's load progress appears (`GET
|
||||
/models` says "loading" and no more), and `prompt_progress` chunks that
|
||||
`"return_progress": true` adds to the generation stream. The sample says
|
||||
which status it measures so it cannot be drawn under another one, and
|
||||
`/loading [seconds] [stages]` / `/reading [seconds]` in an echo session are
|
||||
the rig for the phone's half. **Zero is never reported**, being the absence
|
||||
of a sample rather than a measurement -- which matters because
|
||||
`llama-server` reports a model's load as 0 and then 1 with nothing between
|
||||
(measured 2026-09-21 on both the 0.6B and the 27B), and reports prompt
|
||||
processing once a batch; so the bar usually draws for a long prompt and not
|
||||
for a load.
|
||||
**A turn's wait has two halves and says which** (2026-09-19):
|
||||
`SessionStatus::Loading` is the model coming off disk and
|
||||
`SessionStatus::Reading` is `llama-server` processing the prompt -- emitted
|
||||
when the request goes out and cleared by the first thing the model says, of
|
||||
any kind. Prefill is the expensive half here (~10s at 6k tokens, ~22s at
|
||||
14k), and as `running` it looked exactly like thinking. The phone draws
|
||||
both with the working spinner and its own words, "loading model" and
|
||||
"reading prompt".
|
||||
**Thinking effort is a param, and which levels exist is the model's answer**
|
||||
(2026-09-19): the `thinking` param rides on the request as a chat-template
|
||||
argument (`reasoning_effort`, or `enable_thinking: false` for `off`), so it
|
||||
needs no restart -- and the driver asks the loaded server which levels its
|
||||
template actually takes rather than trusting the offered list, because the
|
||||
27B raises on `high` and answers to `xhigh`. A level it cannot take is
|
||||
dropped and said in the transcript, naming the ones it can.
|
||||
**Every one of those is a default rather than a constant** (2026-09-19):
|
||||
`DriverKind::params` declares what a provider takes — key, label, shape,
|
||||
and whether a change waits for a restart — and the phone renders whatever
|
||||
arrives, on the spawn form and in the session settings dialog. Adding a
|
||||
setting to a driver is one entry in that table and no app change. `tools`
|
||||
is in there too, because the seven built-in definitions are ~1,500 tokens
|
||||
of every prompt, which on a small window is the difference between a usable
|
||||
session and one that overruns. `DriverKind::model_params` is the same table
|
||||
for a provider's **models**, drawn in the machines tab's provider view —
|
||||
the settings that decide how a model is loaded, which belong to the machine
|
||||
because one loaded copy answers every session using it.
|
||||
**A model is downloaded onto the machine that will serve it** (2026-09-19,
|
||||
replacing the fetch this backend used to do onto its own disk, and the
|
||||
Models tab that went with it). `models.rs` writes a script and a detached
|
||||
`curl` runs it *there*; the state of a run is a file beside the partial
|
||||
(`x.gguf.download`), so nothing about it is held in this process — it
|
||||
survives the phone closing, this backend restarting and a second device
|
||||
watching, and `kill -0` at each listing is what stops a machine that was
|
||||
rebooted from leaving a download claiming to be running. The progress is
|
||||
`wc -c` of the partial against the size HuggingFace published, the sha256
|
||||
it publishes is what makes a resume safe, and a finished download is not a
|
||||
state: it is a model, in the list beside the one still going.
|
||||
Codex is one persistent `codex app-server --stdio` process per session; its
|
||||
driver uses native turn steering and interruption, persists the protocol
|
||||
state and thread id, and reads subscription limits through the same CLI
|
||||
protocol.
|
||||
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
||||
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
||||
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
||||
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
||||
**Every text field is `LabelledField`** (`Field.kt`): the label is a line
|
||||
above the box rather than a thing floating inside it, and the padding is one
|
||||
line's worth. Material's outlined field spends the height of three lines to
|
||||
hold one, which on a form of a dozen settings is a screen and a half of
|
||||
scrolling. What is *not* shrunk is the value -- the framing is what was
|
||||
expensive. A field's `hint` is what leaving it blank means, drawn inside the
|
||||
empty box: **a setting is a title and a control and nothing else**, so no
|
||||
explanatory line sits between them and no paragraph sits under them.
|
||||
**What a setting costs is asked rather than written down** -- `RestartDialog`
|
||||
is that question wherever it comes up (a model's settings, the shared
|
||||
server's, moving a session's directory, changing its thinking level), because
|
||||
each of them ends something that is running, and a sentence beside the control
|
||||
is read after the decision if at all.
|
||||
**One kind of information gets one control.** A choice is `PickerRow` in a
|
||||
list of settings and `ChipGroup` on a form being filled in, and which of the
|
||||
two is the screen's to say (`ProviderParamFields`' `choices`) rather than the
|
||||
provider's -- a llama session's thinking level and a Claude session's have to
|
||||
look the same.
|
||||
**Session settings is a screen with two tabs** (`SessionSettingsScreen.kt`),
|
||||
drawn over the session like the file explorer so the session stays composed.
|
||||
The second tab is `ProviderScreen` itself -- the same composable the machines
|
||||
tab opens -- so a provider's settings have two ways in and one
|
||||
implementation; it takes `onBack = null` there, since the screen around it
|
||||
has one.
|
||||
`AppRoot.kt` is the navigation `when`; `SidePanels.kt` the one drag that
|
||||
slides the whole main screen over a session from the left (`MainPanel.kt`)
|
||||
and what it has running beside the turn -- its background tasks over its
|
||||
subagents (`BackgroundTasks.kt`, `SubagentPanel.kt`) -- from the right, both keeping
|
||||
the session composed underneath; `MainScreen.kt` the root's three tabs
|
||||
(sessions, import, machines); `Reorder.kt` the drag that moves a row of a
|
||||
lazy list, used by the session list's handles -- **the order of that list is
|
||||
the reader's own and nothing sorts it** (`POST /sessions/order`);
|
||||
`MachineModels.kt` the models on one machine
|
||||
and the downloads putting them there, drawn inside `ProviderScreen.kt` for a
|
||||
provider that serves files off that machine's disk; `Api.kt`/`EventStream.kt`
|
||||
the REST + SSE clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
||||
the Keystore-sealed token.
|
||||
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
||||
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
||||
@@ -39,7 +225,17 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
||||
build without it, since it is a path dependency, which is what keeps the two
|
||||
projects version-locked to the commit this repo pins. What deliberately did
|
||||
**not** move is the API surface and the config *schema*: routes, drivers,
|
||||
sessions and setups are what makes this project itself.
|
||||
sessions and machines are what makes this project itself.
|
||||
- `SUBAGENTS.md` — a session's subagents as transcripts of their own
|
||||
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
|
||||
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
|
||||
choices made there that are still awaiting review.
|
||||
**The transcript file itself is readable from the session settings dialog**
|
||||
(2026-09-21): *View raw* opens the file explorer on it, from
|
||||
`transcriptFile` on `GET /sessions/{id}` -- which names the machine *this
|
||||
backend* runs on, not the session's. It is the explorer's third caller and
|
||||
needed no new screen; a transcript past `FILE_LIMIT` (1 MiB) is refused the
|
||||
way any other large file is.
|
||||
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
|
||||
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
||||
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
|
||||
@@ -138,153 +334,6 @@ two icon buttons the same width without either being given one — and why
|
||||
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
||||
involved. That is how to verify the wg0-only posture.
|
||||
|
||||
## 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.
|
||||
- **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
|
||||
|
||||
The prebuilt CPU llama.cpp lives outside the repo at
|
||||
`~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It needs its
|
||||
own directory on `LD_LIBRARY_PATH`, so start the server as
|
||||
`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's
|
||||
`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 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**: generate a
|
||||
throwaway key, append the public half to `~/.ssh/authorized_keys`, and
|
||||
configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus
|
||||
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches
|
||||
nothing real. 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. **Take the
|
||||
key back out afterwards.** 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.
|
||||
|
||||
## Where things run (host vs this VM)
|
||||
|
||||
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
||||
@@ -293,7 +342,7 @@ means here:
|
||||
|
||||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
||||
address the phone can reach is, and where WireGuard terminates.
|
||||
`wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
||||
`wg-machine-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
||||
there with `sudo WG_ENDPOINT=<ddns name>`.
|
||||
- **The tunnel and the real phone can never terminate in the VM**, because
|
||||
nothing outside can open a connection into it. Phone bring-up is host work.
|
||||
@@ -326,47 +375,99 @@ day to day:
|
||||
keep what a development server spawns. The flag decides only what **new**
|
||||
sessions are marked as; what happens on the way out is decided by the
|
||||
**mark**.
|
||||
- **A llama.cpp router is not cleaned up by any of that**, throwaway sessions
|
||||
included: it belongs to the machine rather than to a session, and a
|
||||
development server that has loaded a model leaves it loaded — gigabytes of
|
||||
VRAM — after `pkill ai-server`. Stop it from the machines tab's provider
|
||||
view, or `pkill -f "[l]lama-server"` when testing.
|
||||
- Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and
|
||||
`stderr.log`. `stdout.log` is the driver's input, read from the byte offset
|
||||
in `process.json`; removing either by hand while the session is live loses
|
||||
output or replays it.
|
||||
|
||||
## Importing
|
||||
## Auto-resume
|
||||
|
||||
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.
|
||||
**A session switched to it sends itself a message once the account's usage
|
||||
limit lifts** — off by default, per session, in the session settings dialog.
|
||||
PLAN.md's "Auto-resume" is the design; day to day:
|
||||
|
||||
**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.
|
||||
- **The schedule is a plan to ask.** `resume.rs` wakes at the scheduled time,
|
||||
asks `GET /usage`'s meter for that machine and provider, and only sends when
|
||||
it answers `ok` with nothing at 100%. Anything else — still spent, logged
|
||||
out, unreachable — is a longer wait, and a still-spent window reschedules to
|
||||
the reset time the *meter* now gives.
|
||||
- **Test it with echo, never with a real account.** `/limit [minutes]` reports
|
||||
the same `limitReached` event a real driver does, and `/usage 100 5` sets
|
||||
what the meter answers. They are deliberately separate: the two disagreeing
|
||||
is the case the design exists for. `/usage 20` is the limit lifting.
|
||||
- The wait is on the session in `config.ron` (`resume`), so it survives a
|
||||
backend restart. A day after the limit was hit it gives up and says so in
|
||||
the transcript.
|
||||
|
||||
**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.
|
||||
## A session waiting on its own work
|
||||
|
||||
**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.
|
||||
Since 2026-09-06 a session whose turn ended with a **backgrounded subagent or
|
||||
command still running** reports `waiting` rather than `idle` — its own status,
|
||||
drawn as the word "waiting" in `waitingColor` on both screens. `idle` means
|
||||
"waiting for a person" and this means the opposite, so it also suppresses the
|
||||
"finished" notification, which used to arrive at the one moment it was untrue.
|
||||
Two things fall out of it and are easy to get wrong again: the queue and the
|
||||
held-command boundary release on **either** end-of-turn status, so a message
|
||||
sent while a subagent runs is not held until the subagent finishes; and
|
||||
`sessionWorking("waiting")` is deliberately **false** — nothing is being
|
||||
written, and the fold uses that same predicate to decide a reply is settled.
|
||||
|
||||
- **Nothing subagent-specific goes in the main agent's transcript** unless a
|
||||
subagent sends it a real message that wakes it — which is the peer path, and
|
||||
already has a row. A row per finished background task was tried and was a
|
||||
screenful of dividers about work nobody was asking after, one of them a whole
|
||||
shell command. A subagent's report is its own transcript's closing text and
|
||||
is read in the subcard.
|
||||
- **A backgrounded command has no subagent, so its report lands in the tool
|
||||
card that launched it** — a `ToolUpdate` against the call's own id, replacing
|
||||
the launch result that says it is still running. `/background [seconds]` in
|
||||
an echo session is that shape end to end.
|
||||
- **Two replies that meet are separated by a `TurnBreak`** — a hairline, no
|
||||
words. The reply that follows a turn boundary is a **new** message: the fold
|
||||
refuses to grow a settled reply, and without that the two ran together
|
||||
mid-sentence. `./ui-sandbox.sh` plus `/subagent 3` or `/background 5` in an
|
||||
echo session is the whole rig; the helpers stagger a second apart so each
|
||||
reply is its own.
|
||||
- **Claude's background-task level is authority; two edge sources are the
|
||||
fallback.** Since Claude Code 2.1.261,
|
||||
`background_tasks_changed { tasks: [...] }` replaces the live set and repairs
|
||||
a missed ending edge. Its array size is also the measured `backgroundTasks`
|
||||
count exposed on the session row and event stream; the phone draws a nonzero
|
||||
count beside the status rather than deriving one from `waiting` or from the
|
||||
subagent directory. **What those tasks are is `GET /sessions/{id}/background`**,
|
||||
listed in the session's right panel above the subagents: runtime state, so it
|
||||
is never persisted and `null` -- not an empty list -- is what a session with
|
||||
no process answers. **A task is drawn as the command it ran, and tapping it
|
||||
goes to the call that started it** -- both from the transcript rather than
|
||||
from the provider: a driver reports which tool call its task belongs to and
|
||||
`Session::background_tasks` resolves that id into a seq and, for a provider
|
||||
that says nothing (Codex names a terminal by a process id), the command on
|
||||
the call. The phone travels there with `travelTo`, the same journey a
|
||||
reopened session makes to put a reader back where they stopped. An `ambient` task is dropped from both the list and the
|
||||
count, on the CLI's own instruction: a live-update watcher is not activity,
|
||||
and counting one leaves a session `waiting` for ever. An adopted CLI is sent a repeated `initialize` to ask
|
||||
for the current set. Reconcile only between turns or at a result boundary:
|
||||
a foreground agent is legitimately absent from a background-only snapshot.
|
||||
Older CLIs still need both edge sources: `open_tasks` knows about a
|
||||
backgrounded command, while `Subagents::any_open` finds a subagent whose
|
||||
`task_started` is behind an adopted stdout offset.
|
||||
- **A usage limit a subagent hits reaches the session**, not just the
|
||||
subagent's own transcript; auto-resume can only schedule against a session.
|
||||
That is the case where the main agent is idle and a background Task is
|
||||
still burning quota.
|
||||
- **Codex's count is two id sets added together.** Open child thread ids come
|
||||
from the subagent registry; live background command process ids come from
|
||||
app-server's experimental `thread/backgroundTerminals/list`. The command set
|
||||
is runtime state, refreshed at lifecycle edges and once a second while
|
||||
nonempty. Never decrement it from an unmatched completion.
|
||||
- **The status word and its colour are `sessionStatusWord` /
|
||||
`sessionStatusColour`**, shared by the list and the session screen. They
|
||||
were two `when`s, and the second one silently missed `waiting`.
|
||||
|
||||
## Shared appearance
|
||||
|
||||
@@ -379,10 +480,136 @@ where it was instead of half-deleted.
|
||||
swallowed the drag along with the tap, so a list could not be scrolled
|
||||
while anything in it was busy.
|
||||
|
||||
- **A rate-limit bar belongs to a session's provider, not to its machine.**
|
||||
One machine offers echo, the Claude CLI and a local model at once and only
|
||||
the CLI spends anything, so a session says which meter reports on it
|
||||
(`usageProvider`, from `DriverKind::usage_provider`, which
|
||||
`usage::providers_for` reads too so the two lists cannot disagree) and the
|
||||
phone matches a snapshot on machine *and* provider. Nothing meters a llama
|
||||
or echo session, and the phone draws **nothing** for one — not a zero, and
|
||||
not "unknown". Nothing while the first fetch is out either: "checking"
|
||||
under a session that turns out to meter nothing is a row the screen then
|
||||
has to withdraw.
|
||||
|
||||
## Things that have bitten
|
||||
|
||||
Project-specific only — a lesson that would bite any project on this machine
|
||||
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
||||
- **A server started with no `--tools` answers 403 at `GET /tools`, not an
|
||||
empty list.** The route is off rather than empty, so reading that as a
|
||||
failure made "no tools" — the one setting whose entire purpose is to have
|
||||
none — a session that never started. The router is always given
|
||||
`--tools all` now and the choice is a filter here, so this is a trap for
|
||||
whoever next changes how the server is started.
|
||||
|
||||
- **`POST /models/load` answers 400 for a model that is already loaded**, and
|
||||
that is the *ordinary* case once one server is shared: a second session
|
||||
naming a model somebody else loaded. The router driver asks what is loaded
|
||||
first and treats "it is there" as the answer whatever the request said.
|
||||
|
||||
- **Starting a process from a blocking thread needs the runtime.** Loading a
|
||||
model is minutes of disk, so it runs on a `std::thread` — and tokio's
|
||||
`Command::spawn` registers the child with the reactor, so calling it with no
|
||||
runtime context panics. The panic kills only that thread: the session said
|
||||
`loading` for ever and nothing appeared in the log. `Routers` holds a
|
||||
`tokio::runtime::Handle` and enters it around the spawn.
|
||||
|
||||
- **A llama session reports `loading`, and a message sent into it queues.**
|
||||
Before 2026-09-19 the session showed `running` from the moment the process
|
||||
started, so a minute of reading a model off disk was indistinguishable from
|
||||
a minute of thinking -- and anything sent in that window came back as an
|
||||
error, because `llama-server` refuses everything until the model is in
|
||||
memory. `SessionStatus::Loading` is the state. A driver that reports
|
||||
`Loading` owes the holding as well as the word, and **the queue is where it
|
||||
holds**: held inside the turn instead (until 2026-09-20) the message was
|
||||
recorded as read on arrival, so the phone drew it as sent while nothing was
|
||||
reading it, and the turn then folded it out of the transcript *and*
|
||||
appended it, sending it to the model twice. `Shared::await_ready` is now
|
||||
only for a turn whose model was changed under it.
|
||||
|
||||
- **A llama turn that says nothing said something that was thrown away.** Two
|
||||
silent endings were found on 2026-09-20 and both looked, on the phone, like
|
||||
a message that was sent and never answered: an `{"error": ...}` chunk
|
||||
arriving mid-stream on an otherwise successful response (a GPU that ran out
|
||||
of memory mid-decode), and a stream that simply stops without its `[DONE]`
|
||||
(the model unloaded under the session). Neither is an ordinary end, and
|
||||
`generate` now fails the turn for both -- a reply that stops early is not a
|
||||
reply, and the transcript keeps whatever arrived before it.
|
||||
|
||||
- **`<__media__>` in a llama prompt is a picture, wherever it came from.**
|
||||
llama.cpp takes an image out of the request and leaves that marker in the
|
||||
rendered text, then pairs each marker with a decoded image at tokenize time
|
||||
-- so one in words nobody attached a picture to fails the turn with `number
|
||||
of media markers in text (1) exceeds number of bitmaps (0)`, which reaches
|
||||
the phone as "Failed to tokenize prompt". A model saying it back is enough,
|
||||
and then *every* later message fails too, since the conversation is folded
|
||||
out of a transcript that now holds it. `Message::new` and
|
||||
`Message::from_user` take it out of anything that is text (`without_marker`),
|
||||
which is every message with words in it.
|
||||
|
||||
- **A cancel flag is only as prompt as the next place somebody looks.** A
|
||||
llama turn waits on three things that look nowhere at all: a permission
|
||||
question, a tool call `llama-server` is running (a shell command there runs
|
||||
to its own timeout, up to a minute), and the completion itself, which says
|
||||
nothing for as long as the prompt takes to read -- tens of seconds on a long
|
||||
conversation. Setting `cancel` left the turn exactly where it was until
|
||||
whichever it was came back, so Pause did nothing on screen for all of it.
|
||||
**The wait is what ends, not the work**: `awaiting` runs each of those on a
|
||||
thread of its own and `Shared::abandon_turn` answers the wait, so the turn
|
||||
ends in milliseconds (measured 43ms in every state) and the abandoned thread
|
||||
finishes into a channel nobody is reading. Nothing here can stop a shell
|
||||
command or a model mid-reply, and pretending otherwise is what the old code
|
||||
did.
|
||||
**Which is why cancellation is a token per turn** (`Cancel`), not a flag on
|
||||
the session: the abandoned thread wakes up some time later, and a flag the
|
||||
next turn had reset would let it write into a conversation it is no longer
|
||||
part of. Its own token stays set for ever, so it says nothing -- and the
|
||||
open thinking block is closed by `Shared::abandon_turn` rather than by that
|
||||
thread, since the one that knows is not the one that ends the turn.
|
||||
|
||||
- **Stop ends a llama session and takes the model with it, if nobody else
|
||||
wants it** (2026-09-21). Until then Stop did *nothing at all* to one:
|
||||
`stop_session` signals the session's recorded process, and `process::stop`
|
||||
refuses a `Shared` record -- so the session sat at `idle` with no sign
|
||||
anything had happened. A `Shared` record now routes to `Driver::stop`,
|
||||
because what stopping means for a session that borrows somebody else's
|
||||
process is the driver's to say. The llama driver ends the turn, says
|
||||
`exited` itself (nothing else will -- there is no process of its own to
|
||||
die), and asks `Router::release`: each live session claims the model it is
|
||||
on, and the model comes out of memory only when the last claim goes. A model
|
||||
another session is using stays.
|
||||
|
||||
- **A path is stored as it was typed, and `~` is expanded where it is used.**
|
||||
`~/repos/x` and `/home/someone/repos/x` are a path and a snapshot of where it
|
||||
pointed, and the snapshot is what breaks when an account is renamed or the
|
||||
value is read on another machine -- so nothing at the boundary rewrites one
|
||||
in either direction (`machines::tidy` used to expand and `shorten_home` used
|
||||
to contract; both are gone). Expansion belongs to the machine the path is on:
|
||||
`ssh::quote_path` and `files::PATH_PRELUDE` for a remote one,
|
||||
`ssh::expand_home` for one here. The exception that proves it is
|
||||
**`llama-server`'s tools**, which take the working directory as an
|
||||
`x-tool-cwd` header and `chdir` to it with no shell in the way: a `~` arrives
|
||||
there as a directory of that name and *every* tool using one answers "failed
|
||||
to spawn process\n[exit code: -1]", which on the phone looks like a session
|
||||
whose tools are all broken. `files::resolve_blocking` is what the llama
|
||||
driver resolves it with at launch, on the machine that will serve the
|
||||
session.
|
||||
|
||||
- **A transcript outlives the enum.** Removing `Event::TaskNote` hours after
|
||||
adding it made every transcript that had recorded one unreadable, so
|
||||
`launch` failed for those sessions and `SessionManager::new` skipped them —
|
||||
no status, nothing sendable, no new messages, for every live session that
|
||||
had run a background task. **The set of kinds a transcript can hold only
|
||||
ever grows**: a line may come from a newer server or from an older one that
|
||||
wrote a kind since dropped, and one unfamiliar word must never be able to
|
||||
end the file. `Indexed::parse_at` degrades a line it cannot read to
|
||||
`Event::Unreadable { kind }`, keeping its seq — which is what everything
|
||||
downstream is addressed by — and the phone draws it as a placeholder saying
|
||||
which kind. Never delete a variant instead of retiring it; `Event::TaskNote`
|
||||
is what retiring looks like, and the phone folds it to no row.
|
||||
|
||||
Project-specific only. A lesson that would bite any project on this machine
|
||||
belongs in `~/.claude/MACHINE.md` or the `this-machine-*` skill for its
|
||||
subject; one that would bite any project anywhere belongs in the
|
||||
`code-lessons` skill, under the admission test at its end.
|
||||
|
||||
- **tracing caches callsite interest process-wide.** A test that hits a
|
||||
`tracing::warn!` with no subscriber installed can poison the interest cache
|
||||
@@ -476,6 +703,19 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
||||
the reader hit the end of what was loaded on every swipe and stood there
|
||||
for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what
|
||||
is actually on screen.
|
||||
- **A page landing while the history observer was fetching it must trigger its
|
||||
own successor.** The observer once collected only `LazyListState.layoutInfo`;
|
||||
while its collector was suspended in `loadOlderPage`, a compact page could
|
||||
be composed and laid out without leaving another change to observe afterward.
|
||||
Keying the effect on `oldestSeq` still missed the opening prefetch: that key
|
||||
changed while `loadingHistory` was true, so the restarted effect declined to
|
||||
overlap it and never noticed the flag returning to false. Codex exposes both
|
||||
failures because a page full of calls collapses into one tool group: loading
|
||||
stopped until expanding that group forced a layout. The observer now collects
|
||||
the cursor, loading, restoring and failure state with the layout, so returning
|
||||
to not-loading always rechecks the settled height. A failed page turns the
|
||||
history boundary into a Try again control rather than retrying in a loop or
|
||||
requiring another scroll.
|
||||
- **Only `fetchTranscript` was off the main thread; the fold was not.**
|
||||
`foldEvent` returns a new list per event, so a page is that many copies of
|
||||
a growing list — fine at 80 events and about 300,000 element copies at 800,
|
||||
@@ -483,38 +723,12 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
||||
shape: the `markdownIn` scan that decides *what* to parse ran before the
|
||||
hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
|
||||
that wraps the *fetch* and leaves the work done with the result outside it.
|
||||
|
||||
## 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.
|
||||
- **A transcript snapshot cannot survive a suspension and then be assigned.**
|
||||
`loadOlderPage` joined its page to `items`, suspended while `warm` parsed
|
||||
markdown, and then assigned the joined snapshot. An SSE event arriving in
|
||||
that gap appeared and vanished; reopening brought it back because the
|
||||
transcript and cache had it all along. Warm against a candidate if needed,
|
||||
then join against the current `items` and assign without another suspension.
|
||||
Also keep the page's original `oldestSeq`: a stream reset while the fetch or
|
||||
warm is suspended makes the page stale, and it must be discarded rather
|
||||
than joined into the reset window.
|
||||
@@ -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.
|
||||
+48
-19
@@ -14,7 +14,7 @@ AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` /
|
||||
## 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
|
||||
belongs to a **machine** (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
|
||||
@@ -25,18 +25,30 @@ message. The phone draws what came back.
|
||||
|
||||
### 1. Keyed on the machine, opened from the session
|
||||
|
||||
Routes live under `/setups/{id}/…`, beside `importable`, because a filesystem
|
||||
Routes live under `/machines/{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`; a session with no `cwd`
|
||||
explorer with the session's machine and its `cwd`; 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
|
||||
explorer knows what a session is, so a later entry point from the machines 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 else would need a
|
||||
find the machine, and "browse this machine" from anywhere else would need a
|
||||
session to exist first.
|
||||
|
||||
The third caller arrived 2026-09-21 and cost no code here, which is the
|
||||
property this decision was made for: **View raw** in the session settings
|
||||
dialog opens the explorer on the session's own transcript file
|
||||
(`fileTarget`), so the record can be read as it is on disk rather than only
|
||||
as the conversation drawn from it. The session says where the file is
|
||||
(`transcriptFile` on `GET /sessions/{id}`) because only the backend knows --
|
||||
and it names **this backend's** machine rather than the session's, which for
|
||||
a remote session are two different filesystems. Back from the file lands in
|
||||
the session's own directory, where the log and the process record are.
|
||||
A transcript past `FILE_LIMIT` is refused the same way any other large file
|
||||
is, which is the known limit of this as a debugging tool.
|
||||
|
||||
### 2. One shell script per operation, over `Transport`, on both transports
|
||||
|
||||
Each operation is a small POSIX script handed to `sh -c script sh "$path" …`
|
||||
@@ -68,7 +80,7 @@ Elsewhere the phone picks an **id** and the server resolves which file it
|
||||
names, so an enrolled token cannot become "read me an arbitrary file". The
|
||||
explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's
|
||||
Security section in these terms: the token already gates spawning a
|
||||
bypass-permissions agent in any directory on any machine a setup names, and
|
||||
bypass-permissions agent in any directory on any configured machine, 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 rule stands where it is, because there a path was unnecessary and
|
||||
@@ -82,13 +94,14 @@ writing are fixed scripts; the phone chooses only the path and the bytes.
|
||||
Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where
|
||||
a relative path would be depends on something the reader cannot see. Every
|
||||
listing answers with `pwd -P` of the directory it listed, so the phone
|
||||
navigates on a resolved absolute path — the parent 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.
|
||||
navigates on a resolved absolute path. The phone also resolves `~` through the
|
||||
same route, then shortens that directory and every path beneath it back to
|
||||
tilde notation for display; it never guesses where a local or ssh user's home
|
||||
is. 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` (content, size, mtime,
|
||||
`GET /machines/{id}/file` answers with one of `text` (content, size, mtime,
|
||||
sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over
|
||||
`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
|
||||
at), or the machine's own error.
|
||||
@@ -101,7 +114,7 @@ what it is.
|
||||
|
||||
### 6. A write is conditional on what the reader saw
|
||||
|
||||
`PUT /setups/{id}/file` carries the sha256 the read reported. The script
|
||||
`PUT /machines/{id}/file` carries the sha256 the read reported. The script
|
||||
compares it against the file as it is now and exits distinctly if it differs;
|
||||
the server answers **409**. Agents edit files while people read them; this is
|
||||
the common case, not the exotic one, and silently overwriting an agent's edit
|
||||
@@ -122,9 +135,9 @@ precondition is fresh without a second read.
|
||||
|
||||
### 7. Create refuses to overwrite
|
||||
|
||||
`POST /setups/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a
|
||||
`POST /machines/{id}/file` 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` is `mkdir --` with the same
|
||||
somebody's file; `POST /machines/{id}/dir` 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.
|
||||
@@ -208,17 +221,21 @@ absence the signal. Back with unsaved changes asks, and says the edits will
|
||||
be lost. The explorer draws over the session, which deliberately has no
|
||||
`imePadding`, so the explorer's own box adds it.
|
||||
|
||||
### 10. The explorer draws over the session, and back closes it first
|
||||
### 10. The explorer draws over the session, and back follows what is open
|
||||
|
||||
`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 set and goes to the list otherwise. Inside the explorer the same back
|
||||
steps one level: editor → viewer (with the unsaved question) → listing →
|
||||
parent directory, and only from the starting directory does it close. "Back
|
||||
returns; it does not exit."
|
||||
costs nothing. From an open file, both the header's back button and Android back
|
||||
return to its containing directory. From a directory, the header's back button
|
||||
clears `files` and returns to the session. Android back instead walks toward the
|
||||
session's project directory: upward to the common ancestor, then down one path
|
||||
segment per press, and at the project it returns to the session. This makes
|
||||
Back from `/etc` visibly travel through `/`, `/home`, and onward to a project
|
||||
under `~/repos`, rather than leading away from it. The `..` row remains explicit
|
||||
parent navigation. An editor with unsaved changes asks before either route
|
||||
discards them. "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
|
||||
@@ -267,6 +284,18 @@ The speedometer went; the report is a "Copy render timings" row in
|
||||
already are. **Moving it is where the no-coordinate-taps rule got enforced**
|
||||
(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
|
||||
|
||||
### 14. File links in a session open in the explorer
|
||||
|
||||
A markdown destination that is an absolute path or a local `file:` URI opens that document in the
|
||||
session's explorer, on the session's machine. A trailing editor line and optional column are removed;
|
||||
the viewer opens the file but does not yet scroll to a line. Web links, relative links and `file:`
|
||||
URIs naming another host keep their ordinary external behaviour. The distinction is deliberately
|
||||
narrow: a relative link might be a web reference, and the phone must not silently reinterpret it as
|
||||
a path on another machine.
|
||||
|
||||
The markdown link handler is provided around the session rather than taught about machines. That
|
||||
keeps the renderer reusable and makes the explorer's existing machine target the one navigation path.
|
||||
|
||||
## HTTP surface
|
||||
|
||||
In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields`
|
||||
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
# Subagents
|
||||
|
||||
A session's subagents -- helpers started by Claude Code's Task tool or Codex's
|
||||
collaboration tools -- each get a transcript of their own, listed in a panel
|
||||
over the open session and readable in the same transcript view the session has.
|
||||
Designed 2026-09-05; extended to Codex's multiplexed app-server threads on
|
||||
2026-09-13. 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 machine, 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.
|
||||
|
||||
Claude 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.
|
||||
|
||||
Codex app-server multiplexes every thread in the session tree onto the root
|
||||
process's stdout. Its notifications carry `threadId`; `subAgentActivity`
|
||||
items name the child thread and its lifecycle, and `collabAgentToolCall`
|
||||
items carry the spawn prompt. The Codex translator routes a non-root
|
||||
`threadId` exactly as Claude routes a `parent_tool_use_id`. The child thread
|
||||
id is the subagent id on disk. An asynchronously delivered `agentMessage` is
|
||||
a `PeerMessage`, not assistant text from the recipient. Its delta notification
|
||||
does not repeat the completed item's `delivery` field, so the translator
|
||||
remembers that field from `item/started` and suppresses those deltas. Letting
|
||||
one into the recipient's provisional assistant row makes its next completed
|
||||
message replace the combined row, visibly erasing text that Codex still has.
|
||||
The parent draws the initial `spawnAgent` as its ordinary `Task` card and
|
||||
closes it when the matching `subAgentActivity.started` arrives. The remaining
|
||||
collaboration calls remain visible as coordination -- waiting, messaging,
|
||||
listing and lifecycle controls -- rather than being mistaken for generic task
|
||||
output. Null optional fields and a bare `completed` status carry no information
|
||||
and are omitted; their useful result is the child transcript, status or peer
|
||||
message beside them.
|
||||
|
||||
## Storage
|
||||
|
||||
Under the session directory:
|
||||
|
||||
```
|
||||
<session>/subagents/<subagent_id>/meta.json {title, created}
|
||||
<session>/subagents/<subagent_id>/transcript.jsonl same SeqEvent lines as the session's
|
||||
```
|
||||
|
||||
The id is Claude's Task tool_use id (`toolu_…`) or Codex's child thread id.
|
||||
Both are unique, stable across a backend restart, and already the key their
|
||||
parent-side lifecycle 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,
|
||||
and `POST /sessions/{id}/subagents/delete` removes finished ones on their own
|
||||
-- all or nothing, and refused while any named one is still running, since its
|
||||
transcript is still being written to and its process is the session's to stop.
|
||||
|
||||
## Lifecycle, as events in the subagent's transcript
|
||||
|
||||
1. Created when the parent Task/Agent call is seen. A current Claude CLI's
|
||||
`task_started` with `task_type: local_agent` is a recovery source when an
|
||||
adopted stream begins after that call. A bare `parent_tool_use_id` is not
|
||||
enough: other operations can also parent nested lines, and treating one as
|
||||
proof created false subagents named after their first subcommand.
|
||||
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. **What ends a subagent is the CLI's own task lifecycle**, on top-level
|
||||
`system` lines that carry no `parent_tool_use_id`: `task_started`
|
||||
(`task_id`, `tool_use_id`, `task_type`, `is_backgrounded`, the prompt),
|
||||
`task_progress` repeatedly, then `task_updated` (`patch.status`, naming the
|
||||
*task* only) and `task_notification` (`tool_use_id`, `status`, and `summary`
|
||||
-- the agent's own report). `translate_task` keeps the
|
||||
`task_id -> tool_use_id` mapping from the first so the update can be
|
||||
attributed, records the summary as the subagent's closing text, and writes
|
||||
`Status Exited`. A
|
||||
`completed` update is deliberately not the end: its notification carries
|
||||
the summary and would otherwise land after the ending. Any other terminal
|
||||
status ends it from the update, since the failure to avoid is a subagent
|
||||
nothing ever finishes.
|
||||
|
||||
Since Claude Code 2.1.261, `background_tasks_changed { tasks: [...] }` is
|
||||
the authoritative level beside those edges: its set replaces the previous
|
||||
set, so a missed terminal edge cannot leave a subagent running forever. Its
|
||||
ids are deliberately not correlated with the edge stream; what is read off
|
||||
each entry is its own description and kind, and what is read off the set is
|
||||
whether it is empty and how large. The session API and stream expose that
|
||||
size as `backgroundTasks`, which the phone draws beside the status, and
|
||||
`GET /sessions/{id}/background` serves the entries themselves -- listed in
|
||||
the session's panel *above* the subagents and never as subagent cards. An
|
||||
`ambient` entry is excluded from both, on the CLI's own instruction: a
|
||||
live-update watcher is not activity. A backgrounded subagent is legitimately
|
||||
in both lists, since it is both running and a transcript. The edges still
|
||||
carry mapping, outcome and closing summary. On adoption the driver sends a repeated `initialize`,
|
||||
which makes a current CLI send the full set; an older CLI accepts it and sends no level,
|
||||
leaving the edge-based path unchanged. A snapshot is reconciled immediately
|
||||
when the persisted parent status proves it is between turns, and otherwise
|
||||
at the next `result` boundary -- while a turn is open, a foreground agent is
|
||||
legitimately absent from the background set. Reconciliation writes
|
||||
`Status Exited`, which is also what makes a formerly stale row deletable;
|
||||
a task notification ordered after the level can still add its summary.
|
||||
|
||||
The two rules this replaces were both wrong, in opposite directions. The
|
||||
parent's `tool_result` is not it: a backgrounded Task's arrives at launch
|
||||
("Async agent launched..."), so ending there truncated a running agent's
|
||||
transcript at the moment it started. Nor is the subagent's own
|
||||
`end_turn`: measured against 2.1.237 on 2026-09-06, **a subagent's lines
|
||||
carry no `stream_event` at all** -- they are whole `user`/`assistant`
|
||||
lines with a null `stop_reason`, no `result` line is sent for one, and the
|
||||
sub's final report never appears as a child line -- so that rule could
|
||||
never fire and every subagent stayed `running` for ever. `ends_a_turn` is
|
||||
kept as a second detector for a dialect that does say either, and must
|
||||
never be the only one again.
|
||||
|
||||
`Status Exited` either way; the subagent's vocabulary has no `Idle` or
|
||||
`Waiting`, so the end-of-turn status `dispatch` produces for an ordinary
|
||||
session is dropped rather than written.
|
||||
|
||||
**The ending reaches the parent's transcript as nothing at all**
|
||||
(2026-09-06). It was tried, and a row per finished subagent is a screenful
|
||||
of dividers about work the reader was not asking after; the closing report
|
||||
is *this* transcript's last line and here is where somebody reads it. What
|
||||
the parent gets a row for is a message a subagent genuinely sends it, which
|
||||
arrives by the peer path. A backgrounded *command* is the other half of
|
||||
this and goes the other way: it has no transcript of its own, so its report
|
||||
updates the tool card that launched it, which was still saying the command
|
||||
was running. The two lifecycle shapes are still handled once:
|
||||
whichever gets there first is the one that finds the task still open, and
|
||||
`finish` below closes it. See PLAN.md's "Two turns must never be drawn as
|
||||
one".
|
||||
|
||||
**While any task is outstanding the session's turn ends in
|
||||
`Status Waiting` rather than `Idle`.** `Idle` means "waiting for a person",
|
||||
and a session with a backgrounded subagent is not doing that. The edge
|
||||
fallback has two sources: the translator's `open_tasks`, and
|
||||
`Subagents::any_open` -- which covers a subagent launched before a backend
|
||||
restart adopted the session, whose `task_started` is behind the durable
|
||||
stdout offset. On current Claude versions the replace-semantics level above
|
||||
reconciles both at a safe turn boundary.
|
||||
|
||||
**A limit the account hits inside a subagent is hoisted to the session**
|
||||
as well as recorded here, because `resume.rs` can only schedule against a
|
||||
session, and a background subagent outliving its parent's turn is the
|
||||
ordinary case -- see PLAN.md's "A limit a subagent hits is the session's".
|
||||
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. Read from the directory rather than from the
|
||||
live map, because one left `Running` by a previous run of the server is
|
||||
precisely the one nothing in this process has touched -- and it would
|
||||
otherwise read `running` again every time its session was started.
|
||||
|
||||
For Codex the same lifecycle is expressed by app-server rather than Claude's
|
||||
task notices: `subAgentActivity.started` creates the child,
|
||||
`subAgentActivity.interacted` reopens it, and `completed` or `interrupted`
|
||||
finishes it. A child's own `turn/completed` is not its end; it remains running
|
||||
until that activity edge. The root's `turn/completed` reports `waiting` while
|
||||
the registry contains an open child, and the last activity completion reports
|
||||
`idle` if the root is between turns. Because the child thread id is also the
|
||||
on-disk id, an adopted driver can route and finish a child whose spawn record
|
||||
is already behind the durable stdout offset. The registry's open count is also
|
||||
Codex's `backgroundTasks` measurement: lifecycle changes send it through the
|
||||
same event and session-summary fields as Claude's provider snapshot. The other
|
||||
part of that measurement is app-server's runtime
|
||||
`thread/backgroundTerminals/list` set. Its process ids are held only in memory
|
||||
and added to the open-child count; the driver refreshes the set at terminal
|
||||
boundaries and while it remains nonempty, rather than decrementing for an
|
||||
unmatched ending edge.
|
||||
|
||||
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: for Claude, the Task call's `description` input, then
|
||||
` (<subagent_type>)` when one is given; falling back to `Task` when the
|
||||
description is absent. An adopted current CLI can recover the same fields from
|
||||
its `local_agent` lifecycle record. For Codex, the first lifecycle record uses
|
||||
the spawned thread's name or the last segment of `agentPath`, with underscores
|
||||
shown as spaces, then falls back to `subagent`.
|
||||
|
||||
## 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, and
|
||||
`delete(ids)` -- its path out. 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/codex/translate.rs` -- routes multiplexed app-server notifications
|
||||
by thread id, remembers collaboration prompts, and translates activity
|
||||
edges into the same registry lifecycle.
|
||||
- `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` -- four 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
|
||||
POST /sessions/{id}/subagents/delete {subagents} -> 204; refused whole if one is running
|
||||
```
|
||||
|
||||
The delete is a batch rather than a `DELETE` per id for the reason the import
|
||||
list's is: the phone deletes what a reader selected, and one request per row
|
||||
means a batch can half-arrive, leaving the rows that were missed looking
|
||||
exactly like rows nobody picked. Unlike an import delete it is local file
|
||||
removal, so it is done by the time the reply is sent and there is no per-row
|
||||
state to follow afterwards. What decides "running" is
|
||||
`Subagents::list`'s own rule, shared through `routes::has_a_process` so the
|
||||
list and the delete cannot disagree about it.
|
||||
|
||||
`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. A
|
||||
subagent never reports `waiting`: that is a session's word for having
|
||||
outstanding work of its own, and a subagent has none. 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
|
||||
|
||||
- The subcards are ordered **still running first, then most recently
|
||||
active** -- a display decision made on the phone (`subagentOrder`), over the
|
||||
server's stable oldest-first answer. Two keys rather than activity alone
|
||||
because a subagent that is thinking reports nothing meanwhile and would sink
|
||||
below one that just finished.
|
||||
- **Holding a subcard selects it, and several at a time**, exactly as the
|
||||
import list works, with the selection bar drawn inside the panel rather than
|
||||
at the bottom of the session: this selection belongs to the subagent list,
|
||||
and a bar under the composer would read as acting on the conversation.
|
||||
Delete is
|
||||
*disabled*, with the reason in words, while anything selected is still
|
||||
running. Deleting confirms first, dims the rows it is acting on
|
||||
(`BusyItem`), and on success takes them out of the panel without refetching
|
||||
anything else. The phone's cached copy of
|
||||
a deleted subagent's transcript is purged with it.
|
||||
- The main session list does not expand or count subagents. Swiping left over
|
||||
an open session pulls an 88%-wide panel in from the right and fetches
|
||||
`/sessions/{id}/subagents`; it draws one `OutlinedCard` per subagent: title,
|
||||
then the status word and a relative time. The transcript remains composed
|
||||
under the panel, so its event stream, draft and scroll position stay live.
|
||||
Horizontal scrollers inside the transcript win the gesture. Collapsing one,
|
||||
or starting over any ordinary part of the session, gives the gesture back to
|
||||
the panel; Android keeps its own edge Back gesture. Swiping right on the
|
||||
panel, tapping outside it, or Back closes it.
|
||||
- Tapping a subcard opens a `SessionScreen` layer 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.
|
||||
It is another layer over the still-composed session and its panel; Back
|
||||
returns to the panel.
|
||||
- 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.
|
||||
@@ -5,6 +5,10 @@ one in place when it turns out to need a decision.
|
||||
|
||||
## App — transcript
|
||||
|
||||
- [ ] Decide how running background tasks can be inspected. For now the session
|
||||
status shows only the provider-reported count; command details stay in
|
||||
their existing tool cards and must not become subagent cards.
|
||||
|
||||
- [ ] Messages received from other agents are inconsistent — sometimes they
|
||||
appear, sometimes they don't. **Needs a rig.** Read the code rather than
|
||||
measured: a live Claude session only learns of a peer message from the
|
||||
@@ -33,3 +37,21 @@ one in place when it turns out to need a decision.
|
||||
that would work today, for Claude sessions, and it is the option that was
|
||||
not chosen.
|
||||
|
||||
|
||||
## A session the server could not load
|
||||
|
||||
- [ ] **A session whose transcript will not parse is skipped with nothing but a
|
||||
log line, and from the phone it looks exactly like an idle unresponsive
|
||||
one.** `SessionManager::new` catches a failing `launch` and logs
|
||||
"couldn't relaunch session <id>", so the session has no pump and no
|
||||
driver: no status, no history, nothing sendable. That is what the
|
||||
`taskNote` incident (fd71d87) looked like from Bryan's phone, and why it
|
||||
needed a report from him rather than being visible in the app.
|
||||
`Event::Unreadable` removes the cause that time, but not the class — an
|
||||
unreadable `process.json`, a provider edited away and an unreachable host
|
||||
all reach the same place.
|
||||
This is the "design the unknown state first" rule: a session the server
|
||||
could not load is not a session with nothing to say, and only the phone
|
||||
can show the difference. It needs a status the wire can carry for it —
|
||||
the failure with its reason, reported on the session itself — rather than
|
||||
the reader having to tell it apart from silence.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -9,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
@@ -19,6 +22,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.wgapplink.localNetworkAllowed
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -29,25 +33,39 @@ 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 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.
|
||||
* Import, models and machines 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()
|
||||
|
||||
/**
|
||||
* One session, with the file explorer over it when [files] is set.
|
||||
* One session, with the file explorer or a subagent transcript over it when 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.
|
||||
* Both are layers on this screen rather than screens of their own, so the session under them
|
||||
* stays composed: its event stream keeps flowing, its scroll position and draft stay put, and
|
||||
* coming back costs nothing. As sibling `Screen`s they would dispose and recreate it on every
|
||||
* return, refetching the transcript over the tunnel.
|
||||
*/
|
||||
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
|
||||
data class Session(
|
||||
val summary: SessionSummary,
|
||||
val files: FilesTarget? = null,
|
||||
val subagent: SubagentSummary? = null,
|
||||
) : Screen()
|
||||
|
||||
data object Spawn : Screen()
|
||||
|
||||
/**
|
||||
* One provider on one machine: its settings, and what its shared server is holding.
|
||||
*
|
||||
* A step down from the machines tab rather than a tab of its own, because it is about one
|
||||
* machine rather than about the backend. Addressed by ids and names rather than by the
|
||||
* [Provider] it was tapped from: what it shows is fetched, and a stale copy of a card would be
|
||||
* a second version of the same truth.
|
||||
*/
|
||||
data class ProviderSettings(val machineId: String, val provider: String) : Screen()
|
||||
|
||||
data object Settings : Screen()
|
||||
}
|
||||
|
||||
@@ -178,7 +196,7 @@ fun AppRoot(
|
||||
// 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 ->
|
||||
Screen.Main ->
|
||||
Box(Modifier.imePadding()) {
|
||||
MainScreen(
|
||||
settings = current,
|
||||
@@ -191,6 +209,9 @@ fun AppRoot(
|
||||
screen = Screen.Session(imported)
|
||||
},
|
||||
onSettings = { screen = Screen.Settings },
|
||||
onProvider = { machineId, provider ->
|
||||
screen = Screen.ProviderSettings(machineId, provider)
|
||||
},
|
||||
)
|
||||
}
|
||||
is Screen.Session ->
|
||||
@@ -204,14 +225,107 @@ fun AppRoot(
|
||||
// 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,
|
||||
summary = here.summary,
|
||||
onBack = goToMain,
|
||||
onFiles = { screen = here.copy(files = it) },
|
||||
share = share,
|
||||
onShareTaken = { share = null },
|
||||
)
|
||||
val fileLinkHandler = rememberFileLinkHandler { path ->
|
||||
screen = here.copy(files = here.summary.filesTarget(path))
|
||||
}
|
||||
Box(
|
||||
Modifier.then(
|
||||
if (here.subagent != null || here.files != null)
|
||||
Modifier.clearAndSetSemantics {}
|
||||
else Modifier
|
||||
)
|
||||
) {
|
||||
// How much background work the session has, from the one subscription
|
||||
// to its events the screen below holds. Here because the panel and that
|
||||
// screen both draw it, and must draw the same number.
|
||||
var backgroundTasks by
|
||||
remember(here.summary.id) {
|
||||
mutableIntStateOf(here.summary.backgroundTasks)
|
||||
}
|
||||
// Where the panel has asked the session screen to put the reader: the
|
||||
// call a background task was started by. Held here rather than inside
|
||||
// either, because the two are siblings -- the panel is the one being
|
||||
// tapped and the transcript is the one that can travel.
|
||||
var goTo by remember(here.summary.id) { mutableStateOf<CallSite?>(null) }
|
||||
// The two panels this session can be pulled aside for: its subagents
|
||||
// from the right, and the whole main screen from the left. Both are here
|
||||
// rather than screens of their own for the same reason the explorer is --
|
||||
// the session under them stays composed. The main panel exists only
|
||||
// inside a session, which is what makes it unswipeable until one has been
|
||||
// opened.
|
||||
SidePanels(
|
||||
left = { active, close ->
|
||||
MainPanel(
|
||||
settings = current,
|
||||
sessionId = here.summary.id,
|
||||
active = active,
|
||||
onOpen = { screen = Screen.Session(it) },
|
||||
onSpawn = { screen = Screen.Spawn },
|
||||
onImported = { imported ->
|
||||
reloadToken++
|
||||
screen = Screen.Session(imported)
|
||||
},
|
||||
onSettings = { screen = Screen.Settings },
|
||||
onProvider = { machineId, provider ->
|
||||
screen = Screen.ProviderSettings(machineId, provider)
|
||||
},
|
||||
onClose = close,
|
||||
onGone = goToMain,
|
||||
)
|
||||
},
|
||||
// The whole width: it stands in for the screen Back would have shown,
|
||||
// rather than sitting over the session the way the subagents do.
|
||||
leftFraction = 1f,
|
||||
right = { active, close ->
|
||||
SubagentPanel(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
active = active,
|
||||
backgroundTasks = backgroundTasks,
|
||||
onOpenSubagent = { screen = here.copy(subagent = it) },
|
||||
// Closed with it: what the reader asked to see is under this
|
||||
// panel, and a panel left open over the answer is the one
|
||||
// thing the tap cannot have meant.
|
||||
onOpenCall = {
|
||||
goTo = it
|
||||
close()
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalFileLinkHandler provides fileLinkHandler
|
||||
) {
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
onBack = goToMain,
|
||||
onFiles = { screen = here.copy(files = it) },
|
||||
share = share,
|
||||
onShareTaken = { share = null },
|
||||
onBackgroundTasks = { backgroundTasks = it },
|
||||
goTo = goTo,
|
||||
onGoToTaken = { goTo = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
here.subagent?.let { subagent ->
|
||||
BackHandler { screen = here.copy(subagent = null) }
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
key(subagent.id) {
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
onBack = { screen = here.copy(subagent = null) },
|
||||
onFiles = {},
|
||||
subagent = subagent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Its own back handler is registered after this screen's, so it is the one the
|
||||
// platform asks first, and it steps back inside itself before closing.
|
||||
here.files?.let { target ->
|
||||
@@ -223,6 +337,15 @@ fun AppRoot(
|
||||
}
|
||||
}
|
||||
}
|
||||
is Screen.ProviderSettings ->
|
||||
Box(Modifier.imePadding()) {
|
||||
ProviderScreen(
|
||||
settings = current,
|
||||
machineId = here.machineId,
|
||||
provider = here.provider,
|
||||
onBack = goToMain,
|
||||
)
|
||||
}
|
||||
is Screen.Spawn ->
|
||||
Box(Modifier.imePadding()) {
|
||||
SpawnScreen(
|
||||
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -335,12 +334,11 @@ private fun OtherAnswer(text: String, onText: (String) -> Unit) {
|
||||
// No Send of its own: this is one more way to answer the question, and the card's Submit is
|
||||
// what sends it. A second send button beside the field made the shorter half of the card look
|
||||
// like the one that finishes it.
|
||||
OutlinedTextField(
|
||||
LabelledField(
|
||||
label = "Other",
|
||||
value = text,
|
||||
onValueChange = onText,
|
||||
label = { Text("Other") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,30 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Whether a session can be sent a picture, as the server answers it.
|
||||
*
|
||||
* Three states rather than a switch, because for a local model the answer belongs to the server
|
||||
* that loaded it: one still coming off disk genuinely has not said. [UNKNOWN] is offered -- a
|
||||
* control withheld because nobody could ask is a photo button missing from a session that would
|
||||
* have read the photo perfectly well, and the send path says so if the guess was wrong.
|
||||
*/
|
||||
enum class ImageSupport {
|
||||
ACCEPTED,
|
||||
REFUSED,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
/**
|
||||
* What the server called it; anything else -- an older server, a newer word -- is not an answer.
|
||||
*/
|
||||
fun imageSupport(word: String): ImageSupport =
|
||||
when (word) {
|
||||
"accepted" -> ImageSupport.ACCEPTED
|
||||
"refused" -> ImageSupport.REFUSED
|
||||
else -> ImageSupport.UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [ref] names an image the server stored as one -- `<hex>.<extension>`, with an extension
|
||||
* from the list it writes -- rather than a file kept under its own name. Mirrors the server's
|
||||
|
||||
@@ -38,6 +38,11 @@ suspend fun uploadPickedImage(
|
||||
* Uploads whatever [uri] names, the way its kind needs. An image goes through [uploadPickedImage]
|
||||
* and is shrunk; anything else goes whole, under the name the other app or the file chooser gave
|
||||
* it, because the session is told that name rather than shown the bytes.
|
||||
*
|
||||
* A picture is refused here, before anything is read or sent, when [images] says this session's
|
||||
* model cannot read one. Here rather than beside the photo button because this is where every way
|
||||
* of attaching meets: the picker, the file chooser, and another app's share sheet -- and only the
|
||||
* first of those has a button to disable.
|
||||
*/
|
||||
suspend fun uploadPicked(
|
||||
context: Context,
|
||||
@@ -45,10 +50,16 @@ suspend fun uploadPicked(
|
||||
sessionId: String,
|
||||
uri: Uri,
|
||||
maxEdge: Int?,
|
||||
images: ImageSupport,
|
||||
): String {
|
||||
val resolver = context.contentResolver
|
||||
val mime = resolver.getType(uri)
|
||||
if (mime != null && mime.startsWith("image/")) {
|
||||
if (images == ImageSupport.REFUSED) {
|
||||
throw ApiException(
|
||||
"this session's model can't read pictures, so that one wasn't attached"
|
||||
)
|
||||
}
|
||||
return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
|
||||
}
|
||||
// Opened before the request starts, so a provider that refuses says so here and not from inside
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.LazyListScope
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The background work a session has going, above its subagents in the panel [SidePanels] slides
|
||||
* over it from the right.
|
||||
*
|
||||
* Collapsed to its one-line count by default, the way everything else this app adds to a screen
|
||||
* arrives: what a reader came to the panel for is the subagents, and a run of cards about work
|
||||
* nobody asked after would push them off it. Expanding pushes them down instead of covering them,
|
||||
* so the two are read together.
|
||||
*
|
||||
* The count is drawn even when it is zero, in the same words. A section that appeared only once
|
||||
* something was running made its own presence the answer, and no heading at all draws "nothing is
|
||||
* running" and "nobody has asked yet" identically. There is then nothing to expand, so the heading
|
||||
* carries no chevron either: it is a statement rather than a control.
|
||||
*/
|
||||
fun LazyListScope.backgroundTaskSection(
|
||||
count: Int,
|
||||
tasks: LoadState<List<BackgroundTaskSummary>?>,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onOpenCall: (CallSite) -> Unit,
|
||||
) {
|
||||
item(key = "background-heading") {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.then(if (count == 0) Modifier else Modifier.clickable(onClick = onToggle)),
|
||||
) {
|
||||
Text(
|
||||
"${backgroundTaskLabel(count)} running",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (count > 0) Chevron(if (expanded) Pointing.Up else Pointing.Down)
|
||||
}
|
||||
}
|
||||
// The count as well as the switch: [tasks] is the last answer anybody got, so a section left
|
||||
// expanded as the work finished would draw cards for tasks that have ended.
|
||||
if (count == 0 || !expanded) return
|
||||
when (tasks) {
|
||||
is LoadState.Loading ->
|
||||
item(key = "background-loading") {
|
||||
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
|
||||
}
|
||||
is LoadState.Error ->
|
||||
item(key = "background-error") {
|
||||
Column {
|
||||
Text(
|
||||
tasks.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
TextButton(onClick = onRetry) { Text("Try again") }
|
||||
}
|
||||
}
|
||||
// Null is the provider declining to say, which a session whose process has gone answers.
|
||||
// Said in words: the count above came from somewhere, and an empty space under it would
|
||||
// read as the tasks having finished rather than as nobody being left to ask.
|
||||
is LoadState.Loaded ->
|
||||
when (val rows = tasks.value) {
|
||||
null ->
|
||||
item(key = "background-unknown") {
|
||||
Text(
|
||||
"This session isn't saying what these are.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
else ->
|
||||
uniqueItems(rows, key = { "background-${it.id}" }) { task ->
|
||||
BackgroundTaskCard(
|
||||
task,
|
||||
onOpen = task.call?.let { call -> { onOpenCall(call) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One background task: what it is doing, drawn as one line that says what kind it is by how it
|
||||
* looks.
|
||||
*
|
||||
* The kind used to be a second line under the words, which on a list of backgrounded commands was
|
||||
* "background command" repeated down the panel -- and for a provider that names a task by a process
|
||||
* id it was the *whole* card, so every row said the same two words. A mark carries the same
|
||||
* difference in a width the text does not have to make room for, and it is the [Glyph]'s
|
||||
* description that keeps the words for anybody who cannot see it.
|
||||
*
|
||||
* A command needs no mark: drawn the way every other verbatim thing here is -- highlighted,
|
||||
* monospace, on [rawSurface] -- it says "this is a command" in the same appearance the tool card it
|
||||
* came from uses, and a mark beside that would be the same fact twice.
|
||||
*
|
||||
* [onOpen] is where the call that started this is in the transcript, for the readers who tap it:
|
||||
* null where the provider never said which call it was, or where that call is no longer in the
|
||||
* transcript, and the card is then a statement rather than a control. The chevron is what says
|
||||
* which of the two this is, since a card that quietly does nothing when pressed is worse than one
|
||||
* that never invited the press.
|
||||
*/
|
||||
@Composable
|
||||
private fun BackgroundTaskCard(task: BackgroundTaskSummary, onOpen: (() -> Unit)?) {
|
||||
val look = backgroundTaskLook(task.kind)
|
||||
// Null where a provider named the task by a process id and nothing resolved a command out of
|
||||
// it: there is no code to draw, so the row takes the mark and the words instead.
|
||||
val command = task.description?.takeIf { look.code }
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.then(
|
||||
if (onOpen == null) Modifier
|
||||
else
|
||||
Modifier.clickable(
|
||||
onClickLabel = "Show where this started",
|
||||
onClick = onOpen,
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
if (command == null) {
|
||||
Glyph(
|
||||
look.glyph,
|
||||
colour = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.semantics { contentDescription = look.words },
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
// The kind stands in as the words where the provider gave no description, rather
|
||||
// than the id it named the task by: Codex reports a process number, which says
|
||||
// nothing to the person reading and would look like a name somebody chose.
|
||||
Text(
|
||||
task.description ?: look.words,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
if (task.description == null) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else LocalContentColor.current,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
// Cut at its tail: what identifies a command is the program at its head, and the
|
||||
// long ones are exactly the ones being read closely.
|
||||
Text(
|
||||
// Not cached: one command line lexes in microseconds -- the cache exists for a
|
||||
// fence with two hundred lines in it.
|
||||
remember(command) { highlight(command, Language.SHELL) },
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier.weight(1f)
|
||||
// Smaller than the card's own radius, for the reason [RawBlock] rounds
|
||||
// its corners that way: this sits inside one.
|
||||
.clip(MaterialTheme.shapes.extraSmall)
|
||||
.background(rawSurface)
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||
// The fill says "command" to everybody else; this says it to a reader
|
||||
// who cannot see the fill.
|
||||
.semantics { contentDescription = "${look.words} $command" },
|
||||
)
|
||||
}
|
||||
if (onOpen != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Chevron(Pointing.Right)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How one kind of background task is drawn: see [backgroundTaskLook].
|
||||
*
|
||||
* [code] is the kind whose description is verbatim text rather than prose, which is drawn as code
|
||||
* and takes no [glyph]; the glyph is still what a task of that kind falls back to when nothing said
|
||||
* what it ran.
|
||||
*/
|
||||
private data class TaskLook(val glyph: String, val words: String, val code: Boolean)
|
||||
|
||||
/**
|
||||
* Everything a [BackgroundTaskSummary.kind] decides, answered by one `when`.
|
||||
*
|
||||
* One rather than three, which is the rule this screen already learned once with the status word
|
||||
* and its colour: three `when`s over one set is two of them waiting to miss a member.
|
||||
*
|
||||
* A kind this build has not heard of takes the question mark and is named by what every one of them
|
||||
* has in common. The nearest word or mark we do know -- a robot, a terminal -- would be this screen
|
||||
* deciding what the server meant by a word it invented after this build shipped.
|
||||
*/
|
||||
private fun backgroundTaskLook(kind: String) =
|
||||
when (kind) {
|
||||
// A command is drawn in the face a command is drawn in everywhere else here.
|
||||
"command" -> TaskLook(COMMAND_GLYPH, "background command", code = true)
|
||||
"agent" -> TaskLook(AGENT_GLYPH, "subagent", code = false)
|
||||
"workflow" -> TaskLook(WORKFLOW_GLYPH, "workflow", code = false)
|
||||
else -> TaskLook(UNKNOWN_GLYPH, "background task", code = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* The heading over one group in the panel, so neither list is a run of cards with no name.
|
||||
*
|
||||
* The same band as the background section's own heading row above, rather than a gap chosen to look
|
||||
* right here: what separates a heading from the cards above it is that both headings sit in a row
|
||||
* of one height.
|
||||
*/
|
||||
@Composable
|
||||
fun PanelSectionHeading(text: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 48.dp)) {
|
||||
Text(text, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@@ -49,3 +54,49 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50)
|
||||
* ends that tall would bow its sides.
|
||||
*/
|
||||
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
|
||||
|
||||
/**
|
||||
* A round button sized to the mark it draws.
|
||||
*
|
||||
* The composer's three actions -- attach, stop, send -- are single glyphs, and a pill's word-shaped
|
||||
* padding around one glyph was width taken from the pickers beside it: with a long model name on
|
||||
* the row, the permission mode ended up too small to hit. One diameter for all three, and it is the
|
||||
* platform's minimum touch target rather than a button's shorter default height.
|
||||
*
|
||||
* [fill] null draws the outlined form, for the one of the three that does not act on the session.
|
||||
*/
|
||||
@Composable
|
||||
fun CircleButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
fill: Color? = null,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val sized = modifier.size(CircleButtonSize)
|
||||
if (fill == null) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
shape = CircleShape,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = sized,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
shape = CircleShape,
|
||||
colors = actionButtonColors(fill),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = sized,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How wide and tall one of those is; see [CircleButton]. */
|
||||
val CircleButtonSize = 48.dp
|
||||
@@ -160,6 +160,7 @@ private val FENCE_LANGUAGES: Map<String, Language> =
|
||||
"shell" to Language.SHELL,
|
||||
"zsh" to Language.SHELL,
|
||||
"console" to Language.SHELL,
|
||||
"diff" to Language.DIFF,
|
||||
"python" to Language.PYTHON,
|
||||
"py" to Language.PYTHON,
|
||||
"javascript" to Language.JAVASCRIPT,
|
||||
|
||||
@@ -60,3 +60,23 @@ fun compactingLabel(seconds: Long?): String =
|
||||
seconds < 60 -> "compacting ${seconds}s"
|
||||
else -> "compacting ${seconds / 60}m ${seconds % 60}s"
|
||||
}
|
||||
|
||||
/**
|
||||
* How full the session is, as the status row says it.
|
||||
*
|
||||
* Three states, not two, and the third is the one that needed the words: a session whose occupancy
|
||||
* is known and whose ceiling is not. That one keeps the bare figure, and a session with a ceiling
|
||||
* gets both — the reader can see which they are looking at. What must not happen is a missing
|
||||
* ceiling drawn as a number, or as a proportion of some assumed window, which would be this screen
|
||||
* inventing the very fact it does not have.
|
||||
*
|
||||
* A llama.cpp session always has one, since the window is a flag its own server was started with. A
|
||||
* coding CLI's is the vendor's business and neither control protocol states it, so those keep the
|
||||
* bare figure they have always had.
|
||||
*/
|
||||
fun contextLabel(held: Long?, limit: Long?): String =
|
||||
when {
|
||||
held == null -> "context unknown"
|
||||
limit == null -> "context ${tokens(held)}"
|
||||
else -> "context ${tokens(held)} / ${tokens(limit)}"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -36,6 +40,28 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule between two replies that met with nothing said in between -- see
|
||||
* [TranscriptItem.TurnBreak].
|
||||
*
|
||||
* No words and no colour. Every other divider here reports something that happened and is worth
|
||||
* finding by scanning; this one only says "these are two", and it appears once per turn that
|
||||
* started without anybody typing. Saying more was a screenful of announcements about background
|
||||
* work the reader was not asking after -- one of them a whole shell command, drawn as centred prose
|
||||
* because the words came from somewhere that had no reason to keep them short.
|
||||
*
|
||||
* The outline colour is the scheme's one for structure rather than for meaning, which is what this
|
||||
* is. Inset from both edges so it reads as a separator between two rows rather than as the top edge
|
||||
* of the one under it.
|
||||
*/
|
||||
@Composable
|
||||
fun TurnBreakRow(modifier: Modifier = Modifier) {
|
||||
HorizontalDivider(
|
||||
modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 6.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The mark a clear leaves.
|
||||
*
|
||||
@@ -47,3 +73,40 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
|
||||
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"
|
||||
}
|
||||
@@ -14,7 +14,7 @@ private const val RESET_EVENT = "reset"
|
||||
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
|
||||
* 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()
|
||||
@@ -35,7 +35,7 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
|
||||
// one and the screen folds the other, and they have to be the same line.
|
||||
onEvent: (raw: String, event: SeqEvent) -> Unit,
|
||||
) {
|
||||
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
|
||||
stream.run("/${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))
|
||||
|
||||
@@ -61,6 +61,24 @@ sealed class SessionEvent {
|
||||
|
||||
data class AssistantText(val delta: String) : SessionEvent()
|
||||
|
||||
/** The durable value of the open assistant message, replacing its provisional deltas. */
|
||||
data class AssistantTextFinal(val text: String) : SessionEvent()
|
||||
|
||||
/**
|
||||
* The model's working, streamed the way its reply is: its own card, and deliberately not part
|
||||
* of what the session said. Only a provider that actually streams its reasoning sends it.
|
||||
*/
|
||||
data class Thinking(val delta: String) : SessionEvent()
|
||||
|
||||
/**
|
||||
* The thinking above this finished, having taken [ms].
|
||||
*
|
||||
* Measured by the driver, because only it can see when the model stopped: this app knows when
|
||||
* an event *arrived*, and the last fragment of a block followed by a slow tool call looks
|
||||
* exactly like thinking that went on that long.
|
||||
*/
|
||||
data class ThinkingDone(val ms: Long) : SessionEvent()
|
||||
|
||||
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
|
||||
|
||||
data class ToolUpdate(val id: String, val output: String) : SessionEvent()
|
||||
@@ -108,6 +126,26 @@ sealed class SessionEvent {
|
||||
val turnStart: Long? = null,
|
||||
) : SessionEvent()
|
||||
|
||||
/**
|
||||
* A line in the transcript this build cannot read: a kind a newer server wrote, or one an older
|
||||
* server wrote that has since been dropped.
|
||||
*
|
||||
* [kind] is the word the line called itself, so the row can say what is missing rather than
|
||||
* that something is. The server makes these when reading; no driver sends one.
|
||||
*/
|
||||
data class Unreadable(val kind: String) : SessionEvent()
|
||||
|
||||
/**
|
||||
* Retired on 2026-09-06, hours after it was added: a background task finishing, which turned
|
||||
* out to be a screenful of notices about work nobody was asking after.
|
||||
*
|
||||
* Kept because a transcript is append-only -- the sessions that ran a background task in that
|
||||
* window have these lines for ever. It draws no row, which is the whole reason it is still
|
||||
* named here rather than left to fall through to [Unknown]: that would draw a placeholder per
|
||||
* background task, which is the same wall the row was removed for.
|
||||
*/
|
||||
object RetiredTaskNote : 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.
|
||||
@@ -117,6 +155,9 @@ sealed class SessionEvent {
|
||||
/** The same command, handed to the session. */
|
||||
data class CommandSent(val id: String, val text: String) : SessionEvent()
|
||||
|
||||
/** Provider-reported number of background tasks alive now. */
|
||||
data class BackgroundTasks(val count: Int) : SessionEvent()
|
||||
|
||||
data class Status(val state: String) : SessionEvent()
|
||||
|
||||
/**
|
||||
@@ -127,6 +168,16 @@ sealed class SessionEvent {
|
||||
*/
|
||||
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
|
||||
|
||||
/**
|
||||
* Whether a picture can be sent to this session now, as the thing serving its model answered.
|
||||
*
|
||||
* Only a llama.cpp session says this, and it says it twice per model: unknown the moment the
|
||||
* old one is left, then the loaded server's answer. It carries no row -- it is what the
|
||||
* composer's photo button is drawn from, and a line in the transcript about a control is not
|
||||
* something anybody asked after.
|
||||
*/
|
||||
data class Images(val images: ImageSupport) : SessionEvent()
|
||||
|
||||
/**
|
||||
* What a turn cost, and how much the model was holding when it ended.
|
||||
*
|
||||
@@ -135,7 +186,25 @@ sealed class SessionEvent {
|
||||
* 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()
|
||||
data class UsageDelta(
|
||||
val tokens: Long,
|
||||
val context: Long?,
|
||||
/**
|
||||
* How fast the reply came out, where the provider measured it -- null everywhere else,
|
||||
* which is most of them. Never worked out here: the time this app watched a reply arrive
|
||||
* over includes the network and whatever the server was doing between tokens.
|
||||
*/
|
||||
val tokensPerSecond: Double? = null,
|
||||
/**
|
||||
* How long the provider spent reading the prompt before it began answering; null where
|
||||
* nothing measured it. The same rule as [tokensPerSecond]: the provider's own figure, or
|
||||
* nothing at all.
|
||||
*/
|
||||
val prefillMs: Long? = null,
|
||||
) : SessionEvent()
|
||||
|
||||
/** How much context this session's model has, which is what [UsageDelta.context] is out of. */
|
||||
data class ContextWindow(val tokens: Long) : SessionEvent()
|
||||
|
||||
/**
|
||||
* A compaction that finished, and how much context it recovered.
|
||||
@@ -157,6 +226,20 @@ sealed class SessionEvent {
|
||||
*/
|
||||
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 AuthenticationRequired(val message: String) : SessionEvent()
|
||||
|
||||
data class Error(val message: String) : SessionEvent()
|
||||
|
||||
/**
|
||||
@@ -193,6 +276,9 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
)
|
||||
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
|
||||
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
|
||||
"assistantTextFinal" -> SessionEvent.AssistantTextFinal(body.getString("text"))
|
||||
"thinking" -> SessionEvent.Thinking(body.getString("delta"))
|
||||
"thinkingDone" -> SessionEvent.ThinkingDone(body.getLong("ms"))
|
||||
"toolStart" ->
|
||||
SessionEvent.ToolStart(
|
||||
id = body.getString("id"),
|
||||
@@ -240,19 +326,26 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
body.getString("text"),
|
||||
if (body.has("turnStart")) body.getLong("turnStart") else null,
|
||||
)
|
||||
"unreadable" -> SessionEvent.Unreadable(body.getString("kind"))
|
||||
"taskNote" -> SessionEvent.RetiredTaskNote
|
||||
"commandQueued" ->
|
||||
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
|
||||
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
|
||||
"backgroundTasks" -> SessionEvent.BackgroundTasks(body.getInt("count"))
|
||||
"status" -> SessionEvent.Status(body.getString("state"))
|
||||
"settings" ->
|
||||
SessionEvent.Settings(
|
||||
model = body.optString("model").ifEmpty { null },
|
||||
permissionMode = body.optString("permissionMode").ifEmpty { null },
|
||||
)
|
||||
"images" -> SessionEvent.Images(imageSupport(body.optString("images")))
|
||||
"contextWindow" -> SessionEvent.ContextWindow(body.getLong("tokens"))
|
||||
"usageDelta" ->
|
||||
SessionEvent.UsageDelta(
|
||||
body.getLong("tokens"),
|
||||
if (body.has("context")) body.getLong("context") else null,
|
||||
if (body.has("tokensPerSecond")) body.getDouble("tokensPerSecond") else null,
|
||||
if (body.has("prefillMs")) body.getLong("prefillMs") else null,
|
||||
)
|
||||
"compacted" ->
|
||||
SessionEvent.Compacted(
|
||||
@@ -261,6 +354,12 @@ 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
|
||||
)
|
||||
"authenticationRequired" ->
|
||||
SessionEvent.AuthenticationRequired(body.getString("message"))
|
||||
"error" -> SessionEvent.Error(body.getString("message"))
|
||||
else -> SessionEvent.Unknown(type)
|
||||
}
|
||||
@@ -275,7 +374,21 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
* 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 sessionWorking(state: String): Boolean =
|
||||
state == "running" || state == "compacting" || state == "loading" || state == "reading"
|
||||
|
||||
/** Whether the latest events still say this session needs an explicit provider login. */
|
||||
internal fun authenticationPromptAfter(open: Boolean, event: SessionEvent): Boolean =
|
||||
when (event) {
|
||||
is SessionEvent.AuthenticationRequired -> true
|
||||
// A later provider response proves an older authentication failure in a replayed page is
|
||||
// no longer current. Without this, one old failure reopened sign-in after every later
|
||||
// successful turn.
|
||||
is SessionEvent.AssistantText,
|
||||
is SessionEvent.AssistantTextFinal,
|
||||
is SessionEvent.ToolStart -> false
|
||||
else -> open
|
||||
}
|
||||
|
||||
/**
|
||||
* The context after [event], given what it was before.
|
||||
@@ -299,3 +412,18 @@ fun contextAfter(current: Long?, event: SessionEvent): Long? =
|
||||
is SessionEvent.Cleared -> null
|
||||
else -> current
|
||||
}
|
||||
|
||||
/**
|
||||
* The context window after [event], mirroring the server's `context_limit_after` for the same
|
||||
* reason [contextAfter] mirrors its neighbour: the screen has to keep up between page loads.
|
||||
*
|
||||
* A window belongs to the process, so a session whose process has exited has none — left standing,
|
||||
* a session restarted on a different model would draw its occupancy against the old model's
|
||||
* ceiling.
|
||||
*/
|
||||
fun contextLimitAfter(current: Long?, event: SessionEvent): Long? =
|
||||
when (event) {
|
||||
is SessionEvent.ContextWindow -> event.tokens
|
||||
is SessionEvent.Status -> if (event.state == "exited") null else current
|
||||
else -> current
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A text field whose label is a line above it rather than a thing floating inside it.
|
||||
*
|
||||
* Every field in this app goes through here, and the reason is vertical space. Material's outlined
|
||||
* field reserves room for a label that animates into its own border and pads the value by half a
|
||||
* line top and bottom, so one setting costs the height of three lines of text to hold one. A form
|
||||
* of ten settings is then a screen and a half of scrolling to read ten short answers.
|
||||
*
|
||||
* What is *not* shrunk is the value itself: it stays at body size, because what is expensive here
|
||||
* is the framing rather than the text, and a field whose contents are smaller than the text beside
|
||||
* it is a field the reader has to lean in to check. See UI_RULES on never shrinking text to fit.
|
||||
*
|
||||
* [hint] is what leaving it blank means, drawn inside the empty box. It had a grey line of its own
|
||||
* above the box until 2026-09-21: a form of a dozen settings was then mostly explanation, and a
|
||||
* setting should be a title and a box to type in. Inside, it costs no height and is gone the moment
|
||||
* anybody types -- which is the trade, since that is also when somebody might look back at it.
|
||||
*/
|
||||
@Composable
|
||||
fun LabelledField(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
hint: String? = null,
|
||||
enabled: Boolean = true,
|
||||
/**
|
||||
* How many lines the box is, at rest. One for a value; several for prose, where the reader is
|
||||
* writing rather than filling in -- see `ParamKind::Prose`.
|
||||
*/
|
||||
lines: Int = 1,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
/** What the keyboard's own action key does, which is usually what the button beside it does. */
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
) {
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
// Body size in the ordinary text colour, which is what a setting's label is where the
|
||||
// control beside it is a switch or a picker. Smaller and greyer on the ones that are
|
||||
// fields reads as two ranks of setting where there is one.
|
||||
Text(label, modifier = Modifier.padding(bottom = 2.dp))
|
||||
FieldBox(value, onValueChange, enabled, lines, keyboardOptions, keyboardActions, hint)
|
||||
}
|
||||
}
|
||||
|
||||
/** The box itself: the border, the padding, and the text. Shared so the two fields agree. */
|
||||
@Composable
|
||||
private fun FieldBox(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
enabled: Boolean,
|
||||
lines: Int,
|
||||
keyboardOptions: KeyboardOptions,
|
||||
keyboardActions: KeyboardActions,
|
||||
hint: String?,
|
||||
) {
|
||||
val interactions = remember { MutableInteractionSource() }
|
||||
val focused by interactions.collectIsFocusedAsState()
|
||||
// The focused border is the accent at the same width as the resting one. Growing it instead
|
||||
// would move the text inside by a pixel on every focus, which is a whole form twitching as the
|
||||
// reader moves down it.
|
||||
val edge =
|
||||
when {
|
||||
!enabled -> MaterialTheme.colorScheme.outlineVariant
|
||||
focused -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.outline
|
||||
}
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
val style =
|
||||
LocalTextStyle.current.merge(
|
||||
TextStyle(
|
||||
color =
|
||||
if (enabled) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
enabled = enabled,
|
||||
singleLine = lines == 1,
|
||||
minLines = lines,
|
||||
textStyle = style,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
interactionSource = interactions,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest, shape)
|
||||
.border(1.dp, edge, shape)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
decorationBox = { field ->
|
||||
Box {
|
||||
// Under the text rather than beside it: the value is what the box is for, and a
|
||||
// hint that pushed it sideways would move every character as somebody typed.
|
||||
if (value.isEmpty() && hint != null) {
|
||||
Text(hint, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
field()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -44,26 +43,60 @@ 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 -- one more
|
||||
* A **machine**, 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 machines tab -- one more
|
||||
* caller rather than any new code here.
|
||||
*/
|
||||
data class FilesTarget(val setup: String, val setupName: String, val start: String)
|
||||
data class FilesTarget(
|
||||
val machine: String,
|
||||
val machineName: String,
|
||||
val start: String,
|
||||
/** A document to open immediately; [start] remains the fallback directory. */
|
||||
val file: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The explorer opened on one file, wherever that file is.
|
||||
*
|
||||
* Its directory is what the reader lands in on the way back, which for a session's transcript is
|
||||
* that session's own directory -- the log, the process record and the rest of what it wrote.
|
||||
*/
|
||||
fun fileTarget(file: FileOnMachine) =
|
||||
FilesTarget(
|
||||
machine = file.machine,
|
||||
machineName = file.machineName,
|
||||
start = parentOf(file.path) ?: "/",
|
||||
file = file.path,
|
||||
)
|
||||
|
||||
/** The explorer target for this session's machine, optionally opened on [file]. */
|
||||
fun SessionSummary.filesTarget(file: String? = null) =
|
||||
FilesTarget(
|
||||
machine = machine,
|
||||
machineName = machineName,
|
||||
start = cwd?.takeIf { it.isNotBlank() } ?: "~",
|
||||
file = file,
|
||||
)
|
||||
|
||||
/** Where the explorer is: in a directory, or in one file. */
|
||||
private sealed class Spot(val path: String) {
|
||||
class Dir(path: String) : Spot(path)
|
||||
|
||||
class Doc(path: String) : Spot(path)
|
||||
class Doc(path: String, val directory: Dir) : Spot(path)
|
||||
}
|
||||
|
||||
private enum class UnsavedDestination {
|
||||
Directory,
|
||||
Session,
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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.
|
||||
* flowing and coming back from a file costs nothing. Both back controls return from a file to its
|
||||
* directory. In a directory, Android back walks toward the session's project directory and closes
|
||||
* the explorer once it gets there; the header's back button closes it immediately.
|
||||
*
|
||||
* 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
|
||||
@@ -72,51 +105,82 @@ private sealed class Spot(val path: String) {
|
||||
@Composable
|
||||
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
|
||||
val initialDirectory =
|
||||
target.file?.let(::parentOf)?.let { Spot.Dir(it) } ?: Spot.Dir(target.start)
|
||||
var here by
|
||||
remember(target) {
|
||||
mutableStateOf<Spot>(
|
||||
target.file?.let { Spot.Doc(it, initialDirectory) } ?: initialDirectory
|
||||
)
|
||||
}
|
||||
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
||||
// because they are what back has to know about -- and back arrives from two places, the arrow
|
||||
// and the platform's own gesture, which must mean the same thing.
|
||||
// because both ways out have to ask before discarding it.
|
||||
var editing by remember { mutableStateOf(false) }
|
||||
var dirty by remember { mutableStateOf(false) }
|
||||
var askUnsaved by remember { mutableStateOf(false) }
|
||||
|
||||
val here = stack.last()
|
||||
var unsavedDestination by remember { mutableStateOf<UnsavedDestination?>(null) }
|
||||
|
||||
fun go(spot: Spot) {
|
||||
editing = false
|
||||
dirty = false
|
||||
stack = stack + spot
|
||||
here = spot
|
||||
}
|
||||
|
||||
fun back() {
|
||||
when {
|
||||
editing && dirty -> askUnsaved = true
|
||||
editing -> editing = false
|
||||
stack.size > 1 -> {
|
||||
stack = stack.dropLast(1)
|
||||
editing = false
|
||||
dirty = false
|
||||
}
|
||||
else -> onClose()
|
||||
fun leave(destination: UnsavedDestination) {
|
||||
if (editing && dirty) {
|
||||
unsavedDestination = destination
|
||||
} else if (destination == UnsavedDestination.Directory) {
|
||||
go((here as Spot.Doc).directory)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun load(path: String, again: Boolean) {
|
||||
if (!again && listings[path] is LoadState.Loaded) return
|
||||
val existing = listings[path]
|
||||
if (!again && (existing is LoadState.Loaded || existing is LoadState.Loading)) return
|
||||
listings[path] = LoadState.Loading
|
||||
listings[path] =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
LoadState.Loaded(fetchDir(settings, target.setup, path))
|
||||
LoadState.Loaded(fetchDir(settings, target.machine, path))
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(onBack = ::back)
|
||||
val projectDirectory = (listings[target.start] as? LoadState.Loaded)?.value?.path
|
||||
val homeDirectory =
|
||||
if (target.start == "~") projectDirectory
|
||||
else (listings["~"] as? LoadState.Loaded)?.value?.path
|
||||
|
||||
fun systemBack() {
|
||||
when (val spot = here) {
|
||||
is Spot.Doc -> leave(UnsavedDestination.Directory)
|
||||
is Spot.Dir -> {
|
||||
val path = (listings[spot.path] as? LoadState.Loaded)?.value?.path ?: spot.path
|
||||
when {
|
||||
path == projectDirectory || path == target.start -> onClose()
|
||||
projectDirectory != null ->
|
||||
nextDirectoryToward(path, projectDirectory)?.let { go(Spot.Dir(it)) }
|
||||
?: onClose()
|
||||
else -> parentOf(path)?.let { go(Spot.Dir(it)) } ?: onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A file link can open without visiting the project first, but Back still needs to know where
|
||||
// the project is. Home is likewise resolved by the machine rather than guessed on the phone;
|
||||
// it is what lets every path beneath it be displayed with `~`, including over ssh.
|
||||
LaunchedEffect(target.machine, target.start) {
|
||||
if (target.file != null) load(target.start, again = false)
|
||||
if (target.start != "~") load("~", again = false)
|
||||
}
|
||||
|
||||
BackHandler(onBack = ::systemBack)
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
@@ -129,14 +193,15 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
||||
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.
|
||||
// Navigate with the resolved path, but name anything under the machine's home
|
||||
// the way somebody working there would write it.
|
||||
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
|
||||
val shownAt = tildePath(at, homeDirectory)
|
||||
FilesHeader(
|
||||
title = baseName(at),
|
||||
path = at,
|
||||
machine = target.setupName,
|
||||
onBack = ::back,
|
||||
title = baseName(shownAt),
|
||||
path = shownAt,
|
||||
machine = target.machineName,
|
||||
onBack = { leave(UnsavedDestination.Session) },
|
||||
) {
|
||||
GlyphButton(
|
||||
REFRESH_GLYPH,
|
||||
@@ -152,7 +217,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
||||
)
|
||||
}
|
||||
LaunchedEffect(spot.path) { load(spot.path, again = false) }
|
||||
DirectoryBody(state, onOpen = ::go)
|
||||
DirectoryBody(state, directory = spot, onOpen = ::go)
|
||||
}
|
||||
is Spot.Doc ->
|
||||
DocPane(
|
||||
@@ -161,22 +226,26 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
||||
path = spot.path,
|
||||
name = baseName(spot.path),
|
||||
editing = editing,
|
||||
homeDirectory = homeDirectory,
|
||||
onEditing = { editing = it },
|
||||
onDirty = { dirty = it },
|
||||
onBack = ::back,
|
||||
onBack = { leave(UnsavedDestination.Directory) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (askUnsaved) {
|
||||
unsavedDestination?.let { destination ->
|
||||
UnsavedDialog(
|
||||
onDiscard = {
|
||||
askUnsaved = false
|
||||
editing = false
|
||||
dirty = false
|
||||
unsavedDestination = null
|
||||
if (destination == UnsavedDestination.Directory) {
|
||||
go((here as Spot.Doc).directory)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
onCancel = { askUnsaved = false },
|
||||
onCancel = { unsavedDestination = null },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -185,7 +254,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
||||
if (creating && dir != null && listing != null) {
|
||||
CreateDialog(
|
||||
settings = settings,
|
||||
setup = target.setup,
|
||||
machine = target.machine,
|
||||
directory = listing.path,
|
||||
onDismiss = { creating = false },
|
||||
onCreated = { path, isDirectory ->
|
||||
@@ -196,7 +265,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
||||
load(dir.path, again = true)
|
||||
// A new file has nothing to look at, so it opens where it can be filled in.
|
||||
if (!isDirectory) {
|
||||
go(Spot.Doc(path))
|
||||
go(Spot.Doc(path, dir))
|
||||
editing = true
|
||||
}
|
||||
}
|
||||
@@ -248,7 +317,11 @@ private fun FilesHeader(
|
||||
* looks like a right one.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
|
||||
private fun ColumnScope.DirectoryBody(
|
||||
state: LoadState<Listing>,
|
||||
directory: Spot.Dir,
|
||||
onOpen: (Spot) -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
|
||||
is LoadState.Error ->
|
||||
@@ -289,7 +362,9 @@ private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot)
|
||||
name = entry.name,
|
||||
trailing = trailingOf(entry),
|
||||
onClick = {
|
||||
onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
|
||||
onOpen(
|
||||
if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path, directory)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -357,6 +432,7 @@ private fun ColumnScope.DocPane(
|
||||
path: String,
|
||||
name: String,
|
||||
editing: Boolean,
|
||||
homeDirectory: String?,
|
||||
onEditing: (Boolean) -> Unit,
|
||||
onDirty: (Boolean) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
@@ -381,7 +457,7 @@ private fun ColumnScope.DocPane(
|
||||
state = LoadState.Loading
|
||||
state =
|
||||
try {
|
||||
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
|
||||
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.machine, path) }
|
||||
if (got is FileContent.Text) draft = TextFieldValue(got.content)
|
||||
LoadState.Loaded(got)
|
||||
} catch (e: ApiException) {
|
||||
@@ -404,7 +480,7 @@ private fun ColumnScope.DocPane(
|
||||
try {
|
||||
val written =
|
||||
withContext(Dispatchers.IO) {
|
||||
writeFile(settings, target.setup, path, draft.text, against)
|
||||
writeFile(settings, target.machine, path, draft.text, against)
|
||||
}
|
||||
state =
|
||||
LoadState.Loaded(
|
||||
@@ -430,7 +506,12 @@ private fun ColumnScope.DocPane(
|
||||
}
|
||||
}
|
||||
|
||||
FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
|
||||
FilesHeader(
|
||||
title = name,
|
||||
path = tildePath(path, homeDirectory),
|
||||
machine = target.machineName,
|
||||
onBack = onBack,
|
||||
) {
|
||||
if (editing) {
|
||||
if (saving) {
|
||||
GlyphSpinner("Saving")
|
||||
@@ -527,7 +608,9 @@ private fun ColumnScope.DocPane(
|
||||
scope.launch {
|
||||
val fresh =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchFile(settings, target.machine, path)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
saveError = e.message
|
||||
conflict = null
|
||||
@@ -571,7 +654,7 @@ private fun Note(text: String) {
|
||||
@Composable
|
||||
private fun CreateDialog(
|
||||
settings: ServerSettings,
|
||||
setup: String,
|
||||
machine: String,
|
||||
directory: String,
|
||||
onDismiss: () -> Unit,
|
||||
onCreated: (String, Boolean) -> Unit,
|
||||
@@ -591,8 +674,8 @@ private fun CreateDialog(
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (isDirectory) createDir(settings, setup, path)
|
||||
else createFile(settings, setup, path)
|
||||
if (isDirectory) createDir(settings, machine, path)
|
||||
else createFile(settings, machine, path)
|
||||
}
|
||||
onCreated(path, isDirectory)
|
||||
} catch (e: ApiException) {
|
||||
@@ -609,13 +692,11 @@ private fun CreateDialog(
|
||||
title = { Text("Create in ${baseName(directory)}") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
LabelledField(
|
||||
label = "Name",
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -678,6 +759,35 @@ internal fun parentOf(path: String): String? {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The next directory on the filesystem path from [current] to [destination], or null when there.
|
||||
*
|
||||
* Moving between two branches first walks upward to their common ancestor. Once [current] is that
|
||||
* ancestor, the next press walks one segment down toward [destination]. Both paths are answers from
|
||||
* the machine, so they are absolute and have no symlinks or `..` left to resolve here.
|
||||
*/
|
||||
internal fun nextDirectoryToward(current: String, destination: String): String? {
|
||||
val here = current.trimEnd('/').ifEmpty { "/" }
|
||||
val there = destination.trimEnd('/').ifEmpty { "/" }
|
||||
if (here == there) return null
|
||||
val beneathHere = if (here == "/") there.startsWith('/') else there.startsWith("$here/")
|
||||
if (!beneathHere) return parentOf(here)
|
||||
val next = there.removePrefix(here).trimStart('/').substringBefore('/')
|
||||
return join(here, next)
|
||||
}
|
||||
|
||||
/** A path as somebody on [home] writes it, leaving paths outside that home unchanged. */
|
||||
internal fun tildePath(path: String, home: String?): String {
|
||||
val at = path.trimEnd('/').ifEmpty { "/" }
|
||||
val resolvedHome = home?.trimEnd('/')?.ifEmpty { "/" } ?: return at
|
||||
return when {
|
||||
at == resolvedHome -> "~"
|
||||
resolvedHome != "/" && at.startsWith("$resolvedHome/") ->
|
||||
"~${at.removePrefix(resolvedHome)}"
|
||||
else -> at
|
||||
}
|
||||
}
|
||||
|
||||
/** What a path names: its last segment, with `/` naming itself. */
|
||||
internal fun baseName(path: String): String {
|
||||
val trimmed = path.trimEnd('/')
|
||||
|
||||
@@ -7,6 +7,8 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||
|
||||
/** What a span of code is, in the terms the palette has a colour for. */
|
||||
enum class Kind {
|
||||
ADDITION,
|
||||
DELETION,
|
||||
KEYWORD,
|
||||
STRING,
|
||||
LITERAL,
|
||||
@@ -24,6 +26,8 @@ data class Span(val start: Int, val end: Int, val kind: Kind)
|
||||
* one instance and lives with the rest of the palette.
|
||||
*/
|
||||
data class SyntaxPalette(
|
||||
val addition: Color,
|
||||
val deletion: Color,
|
||||
val keyword: Color,
|
||||
val string: Color,
|
||||
val literal: Color,
|
||||
@@ -34,6 +38,8 @@ data class SyntaxPalette(
|
||||
) {
|
||||
fun of(kind: Kind): Color =
|
||||
when (kind) {
|
||||
Kind.ADDITION -> addition
|
||||
Kind.DELETION -> deletion
|
||||
Kind.KEYWORD -> keyword
|
||||
Kind.STRING -> string
|
||||
Kind.LITERAL -> literal
|
||||
@@ -44,6 +50,26 @@ data class SyntaxPalette(
|
||||
}
|
||||
}
|
||||
|
||||
/** A unified diff is line-oriented: colour the changed lines and leave context untouched. */
|
||||
fun scanDiff(code: String): List<Span> {
|
||||
val spans = ArrayList<Span>()
|
||||
var start = 0
|
||||
while (start < code.length) {
|
||||
val end = code.indexOf('\n', start).let { if (it == -1) code.length else it }
|
||||
val kind =
|
||||
when {
|
||||
code.startsWith("+++", start) || code.startsWith("---", start) -> Kind.METADATA
|
||||
code.startsWith("+", start) -> Kind.ADDITION
|
||||
code.startsWith("-", start) -> Kind.DELETION
|
||||
code.startsWith("@@", start) -> Kind.METADATA
|
||||
else -> null
|
||||
}
|
||||
if (kind != null) spans.add(Span(start, end, kind))
|
||||
start = if (end == code.length) end else end + 1
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
/**
|
||||
* [code] with its keywords, strings and comments coloured, or plain if there is no language for it.
|
||||
*
|
||||
|
||||
@@ -82,8 +82,8 @@ private const val SETTLE_MS = 500L
|
||||
@Composable
|
||||
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
||||
var chosen by remember { mutableStateOf<Setup?>(null) }
|
||||
var machines by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
|
||||
var chosen by remember { mutableStateOf<Machine?>(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. A map keyed by id rather
|
||||
@@ -100,9 +100,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
// 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: a phone is the wrong place to answer "allow Bash?" forty
|
||||
// times.
|
||||
var permissionMode by remember { mutableStateOf("auto") }
|
||||
// Set from the selected Claude provider rather than repeated in the app.
|
||||
var permissionMode by remember { mutableStateOf("") }
|
||||
// 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>() }
|
||||
@@ -114,9 +113,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
* 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 phone started.
|
||||
*/
|
||||
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
|
||||
suspend fun fetchInto(machine: Machine): LoadState<List<Importable>> =
|
||||
try {
|
||||
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) }
|
||||
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, machine.id) }
|
||||
running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
|
||||
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
|
||||
LoadState.Loaded(rows)
|
||||
@@ -124,10 +123,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
LoadState.Error(err.message ?: "Couldn't list sessions")
|
||||
}
|
||||
|
||||
fun loadSessions(setup: Setup) {
|
||||
fun loadSessions(machine: Machine) {
|
||||
sessions = LoadState.Loading
|
||||
selected = emptySet()
|
||||
scope.launch { sessions = fetchInto(setup) }
|
||||
scope.launch { sessions = fetchInto(machine) }
|
||||
}
|
||||
|
||||
/** Takes a row out of the list, once the machine no longer has it to offer. */
|
||||
@@ -141,9 +140,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
}
|
||||
|
||||
LaunchedEffect(reloadToken) {
|
||||
setups =
|
||||
machines =
|
||||
try {
|
||||
val found = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
||||
val found = withContext(Dispatchers.IO) { fetchMachines(settings) }
|
||||
found.firstOrNull()?.let {
|
||||
chosen = it
|
||||
loadSessions(it)
|
||||
@@ -171,7 +170,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
selected = emptySet()
|
||||
running = running + targets.associate { it.id to WAITING }
|
||||
rowErrors = rowErrors - targets.map { it.id }.toSet()
|
||||
val setup = chosen
|
||||
val machine = chosen
|
||||
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
|
||||
@@ -198,25 +197,28 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
// 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) }) {
|
||||
if (machine != 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.
|
||||
sessions = fetchInto(setup)
|
||||
sessions = fetchInto(machine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
|
||||
LaunchedEffect(chosen?.id, provider?.name) {
|
||||
permissionMode = provider?.defaultPermissionMode.orEmpty()
|
||||
}
|
||||
|
||||
/** Continues [targets] in the background, leaving the screen where it is. */
|
||||
fun importAll(targets: List<Importable>) {
|
||||
val setup = chosen ?: return
|
||||
val machine = chosen ?: return
|
||||
val useProvider = provider ?: return
|
||||
handOver(targets) { ids ->
|
||||
startImport(
|
||||
settings,
|
||||
setup = setup.id,
|
||||
machine = machine.id,
|
||||
sessionIds = ids,
|
||||
provider = useProvider.name,
|
||||
permissionMode = permissionMode,
|
||||
@@ -232,7 +234,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
* it, which is the case where waiting is the right thing anyway.
|
||||
*/
|
||||
fun importAndOpen(target: Importable) {
|
||||
val setup = chosen ?: return
|
||||
val machine = chosen ?: return
|
||||
val useProvider = provider ?: return
|
||||
running = running + (target.id to IMPORTING)
|
||||
rowErrors = rowErrors - target.id
|
||||
@@ -242,7 +244,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
withContext(Dispatchers.IO) {
|
||||
spawnSession(
|
||||
settings,
|
||||
setup = setup.id,
|
||||
machine = machine.id,
|
||||
provider = useProvider.name,
|
||||
// Nothing to say: the server titles it from the session it continues.
|
||||
title = "",
|
||||
@@ -270,10 +272,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
|
||||
}
|
||||
LaunchedEffect(chosen?.id) {
|
||||
val setup = chosen?.id ?: return@LaunchedEffect
|
||||
val machine = chosen?.id ?: return@LaunchedEffect
|
||||
try {
|
||||
while (true) {
|
||||
val stream = ImportableStream(settings, setup)
|
||||
val stream = ImportableStream(settings, machine)
|
||||
liveChanges.set(stream)
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -341,24 +343,24 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
when (val loaded = setups) {
|
||||
when (val loaded = machines) {
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
|
||||
is LoadState.Loaded -> {
|
||||
// Only worth choosing when there is a choice.
|
||||
if (loaded.value.size > 1) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
loaded.value.forEach { setup ->
|
||||
loaded.value.forEach { machine ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
chosen = setup
|
||||
loadSessions(setup)
|
||||
chosen = machine
|
||||
loadSessions(machine)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
setup.name,
|
||||
machine.name,
|
||||
color =
|
||||
if (setup.id == chosen?.id)
|
||||
if (machine.id == chosen?.id)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -375,7 +377,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
} else {
|
||||
ChipGroup(
|
||||
label = "Permissions",
|
||||
options = PERMISSION_MODES,
|
||||
options = provider?.permissionModes.orEmpty(),
|
||||
selected = permissionMode,
|
||||
onSelect = { permissionMode = it },
|
||||
)
|
||||
@@ -439,9 +441,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val setup = chosen ?: return@TextButton
|
||||
val machine = chosen ?: return@TextButton
|
||||
confirming = null
|
||||
handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) }
|
||||
handOver(targets) { ids -> deleteImportable(settings, machine.id, ids) }
|
||||
}
|
||||
) {
|
||||
// Coloured by consequence: this takes something away, wherever it appears.
|
||||
|
||||
@@ -12,13 +12,13 @@ package com.example.aiapp
|
||||
* the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in
|
||||
* the next listing.
|
||||
*/
|
||||
class ImportableStream(settings: ServerSettings, private val setup: String) {
|
||||
class ImportableStream(settings: ServerSettings, private val machine: String) {
|
||||
private val stream = Sse(settings)
|
||||
|
||||
fun close() = stream.close()
|
||||
|
||||
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
|
||||
stream.run("/setups/$setup/importable/events", onOpen) { _, data ->
|
||||
stream.run("/machines/$machine/importable/events", onOpen) { _, data ->
|
||||
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ enum class Language {
|
||||
CPP,
|
||||
CSHARP,
|
||||
DART,
|
||||
DIFF,
|
||||
FISH,
|
||||
GO,
|
||||
JAVA,
|
||||
@@ -100,7 +101,7 @@ fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(la
|
||||
// Lazy for the same reason [RULES] is, since it reads it.
|
||||
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
|
||||
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
|
||||
mapOf(Language.MARKDOWN to ::scanMarkdown)
|
||||
mapOf(Language.DIFF to ::scanDiff, Language.MARKDOWN to ::scanMarkdown)
|
||||
}
|
||||
|
||||
private val C_STYLE = BlockComment("/*", "*/", nests = false)
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The models on one machine, the downloads putting more there, and HuggingFace to find them in.
|
||||
*
|
||||
* This was a tab of its own, about the backend's own disk. It moved under the machine's llama.cpp
|
||||
* provider on 2026-09-19, when a download came to run on the machine that will serve the file:
|
||||
* there is no such thing as "the models", only this machine's, and the screen that decides how a
|
||||
* model is loaded is the screen that should be able to fetch one.
|
||||
*
|
||||
* Everything here is the machine's state rather than this screen's. A download is a process on that
|
||||
* machine with its progress written beside the partial file, so closing the app, locking the phone
|
||||
* or restarting the backend does not touch it, and a second device watching sees the same numbers.
|
||||
*/
|
||||
@Stable
|
||||
class MachineModelsState(
|
||||
private val settings: ServerSettings,
|
||||
private val machineId: String,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
var state by mutableStateOf<LoadState<Models>>(LoadState.Loading)
|
||||
private set
|
||||
|
||||
var query by mutableStateOf("")
|
||||
|
||||
var results by mutableStateOf<LoadState<List<RemoteRepo>>?>(null)
|
||||
private set
|
||||
|
||||
var openRepo by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
var repoFiles by mutableStateOf<LoadState<List<RemoteFile>>?>(null)
|
||||
private set
|
||||
|
||||
/** What the last action said went wrong, shown above the list that action was taken in. */
|
||||
var actionError by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
val models: Models?
|
||||
get() = (state as? LoadState.Loaded)?.value
|
||||
|
||||
val downloads: List<Download>
|
||||
get() = models?.downloads.orEmpty()
|
||||
|
||||
/** How big each downloaded model is, by key, for the cards the provider screen draws. */
|
||||
val sizes: Map<String, Long>
|
||||
get() = models?.local.orEmpty().associate { it.key to it.bytes }
|
||||
|
||||
suspend fun reload() {
|
||||
state =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
LoadState.Loaded(fetchMachineModels(settings, machineId))
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs [action], says what it said if it failed, and asks the machine again either way. */
|
||||
private fun act(action: suspend () -> Unit) {
|
||||
scope.launch {
|
||||
actionError =
|
||||
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun search() {
|
||||
openRepo = null
|
||||
results = LoadState.Loading
|
||||
scope.launch {
|
||||
results =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { LoadState.Loaded(searchModels(settings, query)) }
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRepo(repo: String) {
|
||||
if (openRepo == repo) {
|
||||
openRepo = null
|
||||
return
|
||||
}
|
||||
openRepo = repo
|
||||
repoFiles = LoadState.Loading
|
||||
scope.launch {
|
||||
repoFiles =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
LoadState.Loaded(fetchRepoFiles(settings, machineId, repo))
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun download(repo: String, file: String) = act {
|
||||
startDownload(settings, machineId, repo, file)
|
||||
}
|
||||
|
||||
fun cancel(key: String) = act { cancelDownload(settings, machineId, key) }
|
||||
|
||||
fun remove(key: String) = act { deleteModel(settings, machineId, key) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One machine's models, asked for again while this screen is open.
|
||||
*
|
||||
* Polled rather than pushed: a download belongs to a machine, not to any session, so it has no
|
||||
* event stream of its own. Faster while something is downloading, because that is the only thing
|
||||
* here that changes by itself -- each ask is a round trip to that machine, and once a minute would
|
||||
* be a progress bar that moved in jumps.
|
||||
*
|
||||
* [onLocalChange] fires when the set of models on the machine changes, which is how the screen
|
||||
* around this learns that a download has become a model it must now draw settings for.
|
||||
*
|
||||
* [enabled] is false for a provider that holds no files of its own -- the Claude CLI names its
|
||||
* models rather than storing them -- and then nothing is asked of the machine at all. Taken as a
|
||||
* parameter rather than decided by the caller's `if`, so that this is composed unconditionally and
|
||||
* keeps its search results across the moment the provider's kind arrives.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberMachineModels(
|
||||
settings: ServerSettings,
|
||||
machineId: String,
|
||||
enabled: Boolean,
|
||||
onLocalChange: () -> Unit,
|
||||
): MachineModelsState {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state = remember(settings, machineId) { MachineModelsState(settings, machineId, scope) }
|
||||
LaunchedEffect(state, enabled) {
|
||||
if (!enabled) return@LaunchedEffect
|
||||
var known: List<String>? = null
|
||||
while (true) {
|
||||
state.reload()
|
||||
val local = state.models?.local?.map { it.key }
|
||||
if (local != null) {
|
||||
if (known != null && known != local) onLocalChange()
|
||||
known = local
|
||||
}
|
||||
delay(if (state.downloads.any { it.state == "running" }) 1500 else 5000)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/** What is being fetched onto this machine, above the models it already has. */
|
||||
fun LazyListScope.downloadCards(state: MachineModelsState) {
|
||||
uniqueItems(state.downloads, key = { "download:" + it.key }) { download ->
|
||||
DownloadCard(
|
||||
download = download,
|
||||
onCancel = { state.cancel(download.key) },
|
||||
onResume = { state.download(download.repo, download.file) },
|
||||
onRemove = { state.remove(download.key) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finding a model to fetch: a search, and what it found.
|
||||
*
|
||||
* Below the models this machine has rather than above them, because what is here is what the reader
|
||||
* came for and getting another is the rarer errand.
|
||||
*/
|
||||
fun LazyListScope.modelSearch(state: MachineModelsState) {
|
||||
item("search") {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Get another model", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"Downloaded onto this machine, which is where llama.cpp reads it from.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
LabelledField(
|
||||
label = "Search HuggingFace",
|
||||
value = state.query,
|
||||
onValueChange = { state.query = it },
|
||||
// The keyboard's own key searches, and puts itself away to show what it found. The
|
||||
// button below this is under the keyboard while it is up, so without this the only
|
||||
// way to press it is to dismiss the keyboard first -- which nothing on screen says.
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onSearch = {
|
||||
keyboard?.hide()
|
||||
state.search()
|
||||
}
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
TextButton(
|
||||
enabled = state.query.isNotBlank(),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
state.search()
|
||||
},
|
||||
) {
|
||||
Text("Search")
|
||||
}
|
||||
}
|
||||
when (val found = state.results) {
|
||||
null -> {}
|
||||
is LoadState.Loading -> item("searching") { CircularProgressIndicator() }
|
||||
is LoadState.Error ->
|
||||
item("search-failed") { Text(found.message, color = MaterialTheme.colorScheme.error) }
|
||||
is LoadState.Loaded ->
|
||||
uniqueItems(found.value, key = { "repo:" + it.id }) { repo ->
|
||||
val open = state.openRepo == repo.id
|
||||
RepoRow(repo, expanded = open) { state.toggleRepo(repo.id) }
|
||||
// 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 = state.repoFiles) {
|
||||
null -> {}
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
is LoadState.Error ->
|
||||
Text(files.message, color = MaterialTheme.colorScheme.error)
|
||||
is LoadState.Loaded ->
|
||||
Column {
|
||||
val busy = state.downloads.map { it.key }.toSet()
|
||||
files.value.forEach { file ->
|
||||
RepoFileRow(
|
||||
file,
|
||||
downloading = "${repo.id}/${file.path}" in busy,
|
||||
) {
|
||||
state.download(repo.id, file.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadCard(
|
||||
download: Download,
|
||||
onCancel: () -> Unit,
|
||||
onResume: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val running = download.state == "running" || download.state == "verifying"
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(download.file, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
download.repo,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// A determinate bar only when the size is known. HuggingFace sends no size 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.
|
||||
color = progressColor,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
} else if (running) {
|
||||
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
|
||||
Text(
|
||||
"${gigabytes(download.done)} so far, total size unknown",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
download.error?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
download.state,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (running) {
|
||||
TextButton(onClick = onCancel) { Text("Cancel") }
|
||||
} else {
|
||||
// A stopped download kept its partial file, so carrying on is the cheap
|
||||
// answer and starting again is not the only one offered.
|
||||
TextButton(onClick = onResume) { Text("Resume") }
|
||||
TextButton(onClick = onRemove) { Text("Remove") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
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.
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
)
|
||||
Text(
|
||||
"${repo.downloads} downloads · ${repo.likes} likes",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(file.path, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
gigabytes(file.bytes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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.
|
||||
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
|
||||
Text(
|
||||
when {
|
||||
file.have -> "Downloaded"
|
||||
downloading -> "Downloading"
|
||||
else -> "Download"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun gigabytes(bytes: Long): String =
|
||||
if (bytes >= 1_000_000_000) {
|
||||
"%.2f GB".format(bytes / 1_000_000_000.0)
|
||||
} else {
|
||||
"%.0f MB".format(bytes / 1_000_000.0)
|
||||
}
|
||||
+146
-64
@@ -1,5 +1,7 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -10,9 +12,9 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -24,6 +26,11 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -37,19 +44,25 @@ import kotlinx.coroutines.withContext
|
||||
* which is what keeps the enrolled token from being able to introduce commands.
|
||||
*/
|
||||
@Composable
|
||||
fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
fun MachinesScreen(
|
||||
settings: ServerSettings,
|
||||
reloadToken: Int,
|
||||
/** Opens one provider on one machine -- its settings, and what its server is holding. */
|
||||
onProvider: (String, String) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
||||
var state by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var renaming by remember { mutableStateOf<Setup?>(null) }
|
||||
var confirmingDelete by remember { mutableStateOf<Setup?>(null) }
|
||||
var renaming by remember { mutableStateOf<Machine?>(null) }
|
||||
var confirmingDelete by remember { mutableStateOf<Machine?>(null) }
|
||||
var signingIn by remember { mutableStateOf<Pair<Machine, Provider>?>(null) }
|
||||
var busy by remember { mutableStateOf<String?>(null) }
|
||||
var actionError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
suspend fun reload() {
|
||||
state =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) }
|
||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchMachines(settings)) }
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
@@ -82,19 +95,19 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
||||
is LoadState.Loaded ->
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
uniqueItems(current.value, key = { it.id }) { setup ->
|
||||
SetupCard(
|
||||
setup = setup,
|
||||
onRename = { renaming = setup },
|
||||
uniqueItems(current.value, key = { it.id }) { machine ->
|
||||
MachineCard(
|
||||
machine = machine,
|
||||
onRename = { renaming = machine },
|
||||
onRediscover = {
|
||||
scope.launch {
|
||||
busy = "Asking ${setup.name} what it has…"
|
||||
busy = "Asking ${machine.name} what it has…"
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
updateSetup(
|
||||
updateMachine(
|
||||
settings,
|
||||
setup.id,
|
||||
machine.id,
|
||||
rediscover = true,
|
||||
)
|
||||
}
|
||||
@@ -105,7 +118,9 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
reload()
|
||||
}
|
||||
},
|
||||
onDelete = { confirmingDelete = setup },
|
||||
onDelete = { confirmingDelete = machine },
|
||||
onSignIn = { provider -> signingIn = machine to provider },
|
||||
onProvider = { provider -> onProvider(machine.id, provider.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +128,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
}
|
||||
|
||||
if (adding) {
|
||||
AddSetupDialog(
|
||||
AddMachineDialog(
|
||||
onDismiss = { adding = false },
|
||||
onAdd = { name, ssh ->
|
||||
adding = false
|
||||
@@ -121,7 +136,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
busy = "Asking $name what it has…"
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { addSetup(settings, name, ssh) }
|
||||
withContext(Dispatchers.IO) { addMachine(settings, name, ssh) }
|
||||
}
|
||||
.exceptionOrNull()
|
||||
?.message
|
||||
@@ -129,13 +144,13 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
reload()
|
||||
}
|
||||
},
|
||||
onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } },
|
||||
onTest = { ssh -> withContext(Dispatchers.IO) { probeMachine(settings, ssh) } },
|
||||
)
|
||||
}
|
||||
|
||||
renaming?.let { setup ->
|
||||
renaming?.let { machine ->
|
||||
RenameDialog(
|
||||
setup = setup,
|
||||
machine = machine,
|
||||
onDismiss = { renaming = null },
|
||||
onRename = { name ->
|
||||
renaming = null
|
||||
@@ -143,7 +158,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
updateSetup(settings, setup.id, name = name)
|
||||
updateMachine(settings, machine.id, name = name)
|
||||
}
|
||||
}
|
||||
.exceptionOrNull()
|
||||
@@ -154,10 +169,10 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
)
|
||||
}
|
||||
|
||||
confirmingDelete?.let { setup ->
|
||||
confirmingDelete?.let { machine ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmingDelete = null },
|
||||
title = { Text("Remove \"${setup.name}\"?") },
|
||||
title = { Text("Remove \"${machine.name}\"?") },
|
||||
text = {
|
||||
Text(
|
||||
"The machine is left alone -- this only stops this app offering it. " +
|
||||
@@ -171,7 +186,9 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
scope.launch {
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) }
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteMachine(settings, machine.id)
|
||||
}
|
||||
}
|
||||
.exceptionOrNull()
|
||||
?.message
|
||||
@@ -187,34 +204,99 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
signingIn?.let { (machine, provider) ->
|
||||
ProviderLoginDialog(
|
||||
settings = settings,
|
||||
machineId = machine.id,
|
||||
machineName = machine.name,
|
||||
provider = provider.name,
|
||||
onDismiss = { signingIn = null },
|
||||
onSignedIn = {
|
||||
signingIn = null
|
||||
scope.launch { reload() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupCard(
|
||||
setup: Setup,
|
||||
private fun MachineCard(
|
||||
machine: Machine,
|
||||
onRename: () -> Unit,
|
||||
onRediscover: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onSignIn: (Provider) -> Unit,
|
||||
onProvider: (Provider) -> Unit,
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(setup.name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(machine.name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
// Not "this machine": the seeded setup is *called* that, and the card read "this
|
||||
// Not "this machine": the seeded machine is *called* that, and the card read "this
|
||||
// machine / this machine".
|
||||
setup.address ?: "runs where the backend does",
|
||||
machine.address ?: "runs where the backend does",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
if (setup.providers.isEmpty()) {
|
||||
"Nothing found on it. Install something and rediscover."
|
||||
} else {
|
||||
setup.providers.joinToString(" · ") { it.name }
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (machine.providers.isEmpty()) {
|
||||
Text(
|
||||
"Nothing found on it. Install something and rediscover.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
} else {
|
||||
machine.providers.forEach { provider ->
|
||||
// A card of its own rather than a line of text: a provider is where the
|
||||
// settings that belong to *this machine* live -- how each of its models is
|
||||
// loaded, the models themselves, and the server holding them -- and those had
|
||||
// nowhere to be until one llama-server came to serve every session on a
|
||||
// machine. Sized by its own padding rather than by whatever control happened
|
||||
// to be on its row, like the tool call cards it is built after.
|
||||
Card(
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clickable { onProvider(provider) }
|
||||
.semantics { contentDescription = "Open ${provider.name}" },
|
||||
// A border, and the machine card's own surface kept underneath it.
|
||||
// The tint that was here before is one step along the surface ladder
|
||||
// from the card it sits in, and two adjacent surfaces render as one flat
|
||||
// block: these read as lines of text in a box rather than as things to
|
||||
// open. One cue, and a visible one.
|
||||
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(provider.name, style = MaterialTheme.typography.titleSmall)
|
||||
// What was actually found, which is the honest second line and
|
||||
// the one thing here nobody can change. No arrow: a card that
|
||||
// lifts off the one behind it already reads as something to open,
|
||||
// and the chevron was the only thing making these look like rows
|
||||
// of a list.
|
||||
provider.command?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
// A program is identified by its name, which is the tail
|
||||
// of its path.
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (provider.kind == "claude_cli") {
|
||||
TextButton(onClick = { onSignIn(provider) }) { Text("Sign in") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onRename) { Text("Rename") }
|
||||
TextButton(onClick = onRediscover) { Text("Rediscover") }
|
||||
@@ -226,7 +308,7 @@ private fun SetupCard(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddSetupDialog(
|
||||
private fun AddMachineDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (String, SshDetails?) -> Unit,
|
||||
onTest: suspend (SshDetails?) -> List<Provider>,
|
||||
@@ -236,6 +318,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) }
|
||||
|
||||
@@ -250,6 +333,7 @@ private fun AddSetupDialog(
|
||||
port = typedPort,
|
||||
identityFile = identity.trim().ifEmpty { null },
|
||||
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
|
||||
modelsDir = modelsDir.trim().ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -265,33 +349,36 @@ private fun AddSetupDialog(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = address,
|
||||
onValueChange = { address = it },
|
||||
LabelledField(label = "Name", value = name, onValueChange = { name = it })
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LabelledField(
|
||||
// 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,
|
||||
label = "user@host[:port]",
|
||||
value = address,
|
||||
onValueChange = { address = it },
|
||||
)
|
||||
OutlinedTextField(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LabelledField(
|
||||
label = "Key path on the backend",
|
||||
value = identity,
|
||||
onValueChange = { identity = it },
|
||||
label = { Text("Key path on the backend") },
|
||||
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.
|
||||
OutlinedTextField(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LabelledField(
|
||||
// Where a file attached from the phone lands on that machine.
|
||||
label = "Folder for attached files",
|
||||
value = attachmentsDir,
|
||||
onValueChange = { attachmentsDir = it },
|
||||
label = { Text("Folder for attached files (optional)") },
|
||||
singleLine = true,
|
||||
hint = "the session's own directory",
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LabelledField(
|
||||
// Where that machine's GGUFs are, for a llama.cpp session on it.
|
||||
label = "Folder for models",
|
||||
value = modelsDir,
|
||||
onValueChange = { modelsDir = it },
|
||||
hint = "the same place this backend keeps its own downloads",
|
||||
)
|
||||
tested?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -339,19 +426,14 @@ private fun AddSetupDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
||||
var name by remember { mutableStateOf(setup.name) }
|
||||
private fun RenameDialog(machine: Machine, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
||||
var name by remember { mutableStateOf(machine.name) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
)
|
||||
LabelledField(label = "Name", value = name, onValueChange = { name = it })
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Sessions already running on it keep working -- they refer to the machine, " +
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* The app's root screen, in the full-width panel [SidePanels] slides over a session from the left.
|
||||
*
|
||||
* Not a list of its own but [MainScreen] itself, and the whole width of the screen: what a right
|
||||
* swipe gets is the screen Back would have got, moved over the session instead of replacing it. The
|
||||
* session stays composed underneath, with its stream open and its draft and scroll position where
|
||||
* they were, so swiping the panel back off returns to it for nothing -- where Back and a tap costs
|
||||
* the whole transcript over the tunnel again.
|
||||
*
|
||||
* Tapping the session already open is that same swipe back rather than a fresh screen: reopening it
|
||||
* would hand [SessionScreen] a new summary for the conversation it is already showing.
|
||||
*
|
||||
* [onGone] is the one thing the list can do that this panel cannot survive -- deleting the very
|
||||
* session it is drawn over. There is nothing left to swipe back into, so that closes the screen.
|
||||
*/
|
||||
@Composable
|
||||
fun MainPanel(
|
||||
settings: ServerSettings,
|
||||
sessionId: String,
|
||||
active: Boolean,
|
||||
onOpen: (SessionSummary) -> Unit,
|
||||
onSpawn: () -> Unit,
|
||||
onImported: (SessionSummary) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
onProvider: (String, String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onGone: () -> Unit,
|
||||
) {
|
||||
// Asked again each time the panel opens: who is working and who is waiting on an answer is
|
||||
// exactly what changed while the session underneath was being read.
|
||||
var reloadToken by remember(sessionId) { mutableIntStateOf(0) }
|
||||
LaunchedEffect(active) { if (active) reloadToken++ }
|
||||
|
||||
MainScreen(
|
||||
settings = settings,
|
||||
reloadToken = reloadToken,
|
||||
onOpen = { if (it.id == sessionId) onClose() else onOpen(it) },
|
||||
onSpawn = onSpawn,
|
||||
onImported = onImported,
|
||||
onSettings = onSettings,
|
||||
onProvider = onProvider,
|
||||
onDeleted = { if (it == sessionId) onGone() },
|
||||
)
|
||||
}
|
||||
@@ -26,19 +26,23 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
|
||||
/**
|
||||
* The app's root: one title, and four views of the backend behind it.
|
||||
* The app's root: one title, and three 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. 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.
|
||||
* These were screens reached by words in a row under the title, and the row was already 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 and the machines
|
||||
* themselves are all *the same backend*, looked at three ways, and none is a step down from
|
||||
* another. Settings still is, which is why it stays a pushed screen with its own Back.
|
||||
*
|
||||
* Models were a fourth tab until 2026-09-19. They are a machine's models now -- downloaded onto the
|
||||
* machine that has to serve them -- so they live under that machine's llama.cpp provider, beside
|
||||
* the settings deciding how each one is loaded. A tab about "the models" was a claim that there is
|
||||
* one such set, and there is one per machine.
|
||||
*/
|
||||
private enum class MainTab(val label: String) {
|
||||
Sessions("Sessions"),
|
||||
Import("Import"),
|
||||
Models("Models"),
|
||||
Setups("Setups"),
|
||||
Machines("Machines"),
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -51,6 +55,10 @@ fun MainScreen(
|
||||
onSpawn: () -> Unit,
|
||||
onImported: (SessionSummary) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
/** One machine's provider, opened from the machines tab. */
|
||||
onProvider: (String, String) -> Unit,
|
||||
/** A session the list has just deleted; see [SessionListScreen]. */
|
||||
onDeleted: (String) -> Unit = {},
|
||||
) {
|
||||
var tab by remember { mutableStateOf(MainTab.Sessions) }
|
||||
var refreshToken by remember { mutableIntStateOf(0) }
|
||||
@@ -140,11 +148,12 @@ fun MainScreen(
|
||||
reloadToken = token,
|
||||
onOpen = onOpen,
|
||||
onSpawn = onSpawn,
|
||||
onDeleted = onDeleted,
|
||||
)
|
||||
MainTab.Import ->
|
||||
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
|
||||
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
|
||||
MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token)
|
||||
MainTab.Machines ->
|
||||
MachinesScreen(settings = settings, reloadToken = token, onProvider = onProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import com.mikepenz.markdown.model.markdownAnnotator
|
||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||
import com.mikepenz.markdown.utils.resolveImageAlt
|
||||
import com.mikepenz.markdown.utils.resolveImageLink
|
||||
import java.net.URI
|
||||
import org.intellij.markdown.MarkdownElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
@@ -89,6 +90,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
||||
content.buildMarkdownAnnotatedString(node, style, settings)
|
||||
}
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val fileLinkHandler = LocalFileLinkHandler.current
|
||||
val onPlainTap = LocalMarkdownTap.current
|
||||
val layout = remember { Ref<TextLayoutResult>() }
|
||||
// The renderer's own rule for a style that names no colour: the theme's text colour.
|
||||
@@ -127,7 +129,7 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
||||
when {
|
||||
url != null -> {
|
||||
up.consume()
|
||||
uriHandler.openUri(url)
|
||||
if (fileLinkHandler?.invoke(url) != true) uriHandler.openUri(url)
|
||||
}
|
||||
onPlainTap != null -> {
|
||||
up.consume()
|
||||
@@ -164,6 +166,55 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
||||
*/
|
||||
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
|
||||
|
||||
/**
|
||||
* Opens a markdown destination inside the current session when it names a file on that session's
|
||||
* machine. Null outside a session, where every link keeps its ordinary URI behaviour.
|
||||
*/
|
||||
val LocalFileLinkHandler = compositionLocalOf<((String) -> Boolean)?> { null }
|
||||
|
||||
/**
|
||||
* A stable markdown link handler whose behaviour follows the latest [onFile]. Keeping its identity
|
||||
* stable matters: every visible markdown paragraph reads it, and a session recomposes on every
|
||||
* streamed event.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberFileLinkHandler(onFile: (String) -> Unit): (String) -> Boolean {
|
||||
val latest = rememberUpdatedState(onFile)
|
||||
return remember {
|
||||
{ destination ->
|
||||
val path = filePathOf(destination)
|
||||
if (path == null) false
|
||||
else {
|
||||
latest.value(path)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The path named by a local-file markdown destination.
|
||||
*
|
||||
* Only absolute paths and local `file:` URIs are claimed. A relative destination might be a web
|
||||
* link, and sending one to a machine's filesystem would silently give an ordinary link a different
|
||||
* meaning. Editors commonly append a line and optional column; the current viewer opens the file
|
||||
* itself, so those coordinates are removed here.
|
||||
*/
|
||||
internal fun filePathOf(destination: String): String? {
|
||||
val uri = runCatching { URI(destination) }.getOrNull()
|
||||
val path =
|
||||
when {
|
||||
destination.startsWith("/") && !destination.startsWith("//") ->
|
||||
uri?.path ?: destination.substringBefore('#').substringBefore('?')
|
||||
uri != null &&
|
||||
uri.scheme.equals("file", ignoreCase = true) &&
|
||||
(uri.host.isNullOrEmpty() || uri.host == "localhost") -> uri.path
|
||||
else -> null
|
||||
}
|
||||
if (path.isNullOrEmpty() || !path.startsWith('/')) return null
|
||||
return path.replace(Regex(":\\d+(?::\\d+)?$"), "")
|
||||
}
|
||||
|
||||
/**
|
||||
* [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
|
||||
|
||||
@@ -21,12 +21,25 @@ const val DEFAULT_MODEL = "default"
|
||||
* one model rather than one model from another. Anything that does not look like that is returned
|
||||
* untouched.
|
||||
*
|
||||
* A llama.cpp session's model is not an identifier at all -- it is `owner/repo/file.gguf`, where
|
||||
* the file was downloaded from -- so what is kept is the file, which is the part that tells two
|
||||
* models apart, and the extension goes with the directories. The model's *own* name is better still
|
||||
* and is not derivable here: it is inside the file, and only the server has ever opened it. Where a
|
||||
* screen has the server's answer it should prefer it; this is the floor under every screen that
|
||||
* does not.
|
||||
*
|
||||
* 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
|
||||
if (name.endsWith(GGUF)) {
|
||||
return name.substringAfterLast('/').removeSuffix(GGUF)
|
||||
}
|
||||
return name.removePrefix("claude-").replace(DATED_SUFFIX, "")
|
||||
}
|
||||
|
||||
/** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */
|
||||
private val DATED_SUFFIX = Regex("""-\d{8}$""")
|
||||
|
||||
/** What every model a llama.cpp session can run is stored as. */
|
||||
private const val GGUF = ".gguf"
|
||||
@@ -1,374 +0,0 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Models on the backend, and HuggingFace to get more from.
|
||||
*
|
||||
* Everything here is the server's state rather than this screen's: what is downloaded, and what is
|
||||
* downloading, are the same answers on every enrolled device, and a download started here keeps
|
||||
* going when this screen closes.
|
||||
*/
|
||||
@Composable
|
||||
fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember { mutableStateOf<LoadState<Models>>(LoadState.Loading) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var results by remember { mutableStateOf<LoadState<List<RemoteRepo>>?>(null) }
|
||||
var openRepo by remember { mutableStateOf<String?>(null) }
|
||||
var repoFiles by remember { mutableStateOf<LoadState<List<RemoteFile>>?>(null) }
|
||||
var actionError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
suspend fun reload() {
|
||||
state =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchModels(settings)) }
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
delay(1500)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
actionError?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
label = { Text("Search HuggingFace") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TextButton(
|
||||
enabled = query.isNotBlank(),
|
||||
onClick = {
|
||||
openRepo = null
|
||||
results = LoadState.Loading
|
||||
scope.launch {
|
||||
results =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
LoadState.Loaded(searchModels(settings, query))
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Search")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
when (val current = state) {
|
||||
is LoadState.Loading -> item { CircularProgressIndicator() }
|
||||
is LoadState.Error ->
|
||||
item { Text(current.message, color = MaterialTheme.colorScheme.error) }
|
||||
is LoadState.Loaded -> {
|
||||
if (current.value.downloads.isNotEmpty()) {
|
||||
item { SectionLabel("Downloading") }
|
||||
uniqueItems(current.value.downloads, key = { it.key + it.run }) { download
|
||||
->
|
||||
DownloadCard(download) {
|
||||
scope.launch {
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
cancelDownload(settings, download.key)
|
||||
}
|
||||
}
|
||||
.exceptionOrNull()
|
||||
?.message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item { SectionLabel("On the backend") }
|
||||
if (current.value.local.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"None yet. Search above to find one.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
uniqueItems(current.value.local, key = { it.key }) { model ->
|
||||
LocalModelCard(model) {
|
||||
scope.launch {
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteModel(settings, model.key)
|
||||
}
|
||||
}
|
||||
.exceptionOrNull()
|
||||
?.message
|
||||
reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results?.let { found ->
|
||||
item { SectionLabel("HuggingFace") }
|
||||
when (found) {
|
||||
is LoadState.Loading -> item { CircularProgressIndicator() }
|
||||
is LoadState.Error ->
|
||||
item { Text(found.message, color = MaterialTheme.colorScheme.error) }
|
||||
is LoadState.Loaded ->
|
||||
uniqueItems(found.value, key = { it.id }) { repo ->
|
||||
val open = openRepo == repo.id
|
||||
RepoRow(repo, expanded = open) {
|
||||
if (open) {
|
||||
openRepo = null
|
||||
} else {
|
||||
openRepo = repo.id
|
||||
repoFiles = LoadState.Loading
|
||||
scope.launch {
|
||||
repoFiles =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
LoadState.Loaded(
|
||||
fetchRepoFiles(settings, repo.id)
|
||||
)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 -> {}
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
is LoadState.Error ->
|
||||
Text(files.message, color = MaterialTheme.colorScheme.error)
|
||||
is LoadState.Loaded ->
|
||||
Column {
|
||||
val busy =
|
||||
(state as? LoadState.Loaded)
|
||||
?.value
|
||||
?.downloads
|
||||
.orEmpty()
|
||||
.filter { it.state == "running" }
|
||||
.map { it.key }
|
||||
.toSet()
|
||||
files.value.forEach { file ->
|
||||
RepoFileRow(
|
||||
file,
|
||||
downloading = "${repo.id}/${file.path}" in busy,
|
||||
) {
|
||||
scope.launch {
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
startDownload(
|
||||
settings,
|
||||
repo.id,
|
||||
file.path,
|
||||
)
|
||||
}
|
||||
}
|
||||
.exceptionOrNull()
|
||||
?.message
|
||||
reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionLabel(text: String) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(text, style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadCard(download: Download, onCancel: () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(download.file, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
download.repo,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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.
|
||||
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.
|
||||
color = progressColor,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
|
||||
Text(
|
||||
"${gigabytes(download.done)} so far, total size unknown",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
download.error?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Row {
|
||||
Text(
|
||||
download.state,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (download.state == "running") {
|
||||
TextButton(onClick = onCancel) { Text("Cancel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalModelCard(model: LocalModel, onDelete: () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(model.file, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"${model.repo} · ${gigabytes(model.bytes)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onDelete) { Text("Delete") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
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.
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
)
|
||||
Text(
|
||||
"${repo.downloads} downloads · ${repo.likes} likes",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(file.path, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
gigabytes(file.bytes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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.
|
||||
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
|
||||
Text(
|
||||
when {
|
||||
file.have -> "Downloaded"
|
||||
downloading -> "Downloading"
|
||||
else -> "Download"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun gigabytes(bytes: Long): String =
|
||||
if (bytes >= 1_000_000_000) {
|
||||
"%.2f GB".format(bytes / 1_000_000_000.0)
|
||||
} else {
|
||||
"%.0f MB".format(bytes / 1_000_000.0)
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import androidx.compose.ui.unit.sp
|
||||
* 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. 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
|
||||
* is `app/build-icon-font.sh`'s output -- eighteen glyphs, 2.9 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.
|
||||
*
|
||||
@@ -136,6 +136,34 @@ val EDIT_GLYPH = glyph(0xF03EB)
|
||||
*/
|
||||
val SAVE_GLYPH = glyph(0xF0193)
|
||||
|
||||
/**
|
||||
* `md-menu` -- the burger: three stacked rules, drawn as the handle a row is dragged by.
|
||||
*
|
||||
* The mark for "take hold of this and move it" rather than for a menu, which is what it means on a
|
||||
* row that has one: three rules look like the rows of a list, and the only thing here that draws
|
||||
* them is a list being rearranged. Nothing else in this app opens a menu from a burger, so the two
|
||||
* senses cannot be confused.
|
||||
*/
|
||||
val DRAG_GLYPH = glyph(0xF035C)
|
||||
|
||||
/**
|
||||
* `md-console_line` -- a shell prompt: a backgrounded command, in the panel beside the turn.
|
||||
*
|
||||
* The four marks here are one set, drawn by `backgroundTaskLook`: they exist because the kind of a
|
||||
* background task used to be a word on a line of its own, which on a list of commands was the same
|
||||
* two words down the whole panel. Each keeps its words as the description a screen reader is given.
|
||||
*/
|
||||
val COMMAND_GLYPH = glyph(0xF07B7)
|
||||
|
||||
/** `md-robot` -- a subagent: something running that is doing its own reasoning. */
|
||||
val AGENT_GLYPH = glyph(0xF06A9)
|
||||
|
||||
/** `md-sitemap` -- a workflow: steps arranged by something other than the agent itself. */
|
||||
val WORKFLOW_GLYPH = glyph(0xF04AA)
|
||||
|
||||
/** `md-help_circle_outline` -- a background task of a kind this build has not heard of. */
|
||||
val UNKNOWN_GLYPH = glyph(0xF0625)
|
||||
|
||||
/**
|
||||
* The size an icon draws at beside a line of text.
|
||||
*
|
||||
@@ -165,7 +193,7 @@ private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
|
||||
* 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
|
||||
val GLYPH_BUTTON_SIZE = 48.dp
|
||||
|
||||
/**
|
||||
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
|
||||
|
||||
@@ -34,6 +34,8 @@ 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.
|
||||
*
|
||||
* Every moment it hears about goes to the drawer; [show] decides what else is done with it.
|
||||
*
|
||||
* 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
|
||||
@@ -138,10 +140,11 @@ class NotificationService : Service() {
|
||||
// Nothing to tell somebody about the session they are reading. The transcript in front of
|
||||
// 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. 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
|
||||
// The app is up, so it says this itself as a banner over whatever screen they are on --
|
||||
// which interrupts, where the drawer's row records: a banner lasts seconds and reaches only
|
||||
// somebody already looking. Both go up, and the banner having done the interrupting is what
|
||||
// makes the row a silent one.
|
||||
val banner = handOver(notification)
|
||||
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.
|
||||
@@ -172,6 +175,7 @@ class NotificationService : Service() {
|
||||
.setAutoCancel(true)
|
||||
.setWhen((notification.at * 1000).toLong())
|
||||
.setShowWhen(true)
|
||||
.setSilent(banner)
|
||||
.build()
|
||||
manager.notify(notification.sessionId, ALERT_ID, built)
|
||||
}
|
||||
@@ -262,7 +266,8 @@ class NotificationService : Service() {
|
||||
* 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. `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.
|
||||
* sessions finishing together all land rather than the last one winning. Reaching the app
|
||||
* does not stop the drawer's row; it makes it a silent one.
|
||||
*/
|
||||
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
|
||||
|
||||
@@ -272,7 +277,11 @@ class NotificationService : Service() {
|
||||
private fun handOver(notification: SessionNotification) =
|
||||
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
|
||||
|
||||
/** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
|
||||
/**
|
||||
* Somebody is looking at [sessionId]; nothing is posted about it until they stop, and
|
||||
* whatever the drawer is already holding about it goes now rather than waiting to be swiped
|
||||
* away. Opening the session *is* reading the notification, whichever way they got here.
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import java.util.UUID
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
private const val PENDING_MESSAGES = "pending-messages"
|
||||
|
||||
/** A quiet user bubble below the durable transcript. */
|
||||
internal data class QueuedMessage(
|
||||
val id: String,
|
||||
val text: String,
|
||||
val attachments: List<String>,
|
||||
val refusal: String? = null,
|
||||
/** This phone is still waiting for any durable event that says the server accepted it. */
|
||||
val local: Boolean = false,
|
||||
/** The HTTP request returned successfully; the provider event is still outstanding. */
|
||||
val serverAccepted: Boolean = false,
|
||||
)
|
||||
|
||||
internal fun localPendingMessage(text: String, attachments: List<String>) =
|
||||
QueuedMessage("local-${UUID.randomUUID()}", text, attachments, local = true)
|
||||
|
||||
private fun QueuedMessage.matches(text: String, attachments: List<String>) =
|
||||
this.text == text && this.attachments == attachments
|
||||
|
||||
/** Replaces the local bridge with the server's durable waiting message, without drawing both. */
|
||||
internal fun reconcileQueuedMessage(
|
||||
queued: List<QueuedMessage>,
|
||||
event: SessionEvent.MessageQueued,
|
||||
): List<QueuedMessage> {
|
||||
if (queued.any { !it.local && it.id == event.id }) return queued
|
||||
val at = queued.indexOfFirst { it.local && it.matches(event.text, event.attachments) }
|
||||
if (at < 0) return queued + QueuedMessage(event.id, event.text, event.attachments)
|
||||
return queued.mapIndexed { index, message ->
|
||||
if (index == at) QueuedMessage(event.id, event.text, event.attachments) else message
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes exactly the pending bubble that became a provider-received user message. */
|
||||
internal fun reconcileUserMessage(
|
||||
queued: List<QueuedMessage>,
|
||||
event: SessionEvent.UserMessage,
|
||||
): List<QueuedMessage> {
|
||||
val at =
|
||||
event.id?.let { id -> queued.indexOfFirst { !it.local && it.id == id }.takeIf { it >= 0 } }
|
||||
?: queued.indexOfFirst { it.local && it.matches(event.text, event.attachments) }
|
||||
return if (at < 0) queued else queued.filterIndexed { index, _ -> index != at }
|
||||
}
|
||||
|
||||
/** Keeps a failed send in place and puts its actionable failure in that message's bubble. */
|
||||
internal fun markPendingFailure(
|
||||
queued: List<QueuedMessage>,
|
||||
id: String,
|
||||
failure: String,
|
||||
): List<QueuedMessage> = queued.map { message ->
|
||||
if (message.local && message.id == id) message.copy(refusal = failure) else message
|
||||
}
|
||||
|
||||
/** Stops persisting a send once the server owns it, while its bubble awaits the provider event. */
|
||||
internal fun markPendingAccepted(queued: List<QueuedMessage>, id: String): List<QueuedMessage> =
|
||||
queued.map { message ->
|
||||
if (message.local && message.id == id) message.copy(serverAccepted = true) else message
|
||||
}
|
||||
|
||||
internal fun discardPendingMessage(
|
||||
queued: List<QueuedMessage>,
|
||||
id: String,
|
||||
): List<QueuedMessage> = queued.filterNot { it.local && it.id == id }
|
||||
|
||||
/** Restores sends for which this phone has not yet seen a durable server event. */
|
||||
internal fun loadPendingMessages(context: Context, key: String): List<QueuedMessage> {
|
||||
val encoded =
|
||||
context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).getString(key, null)
|
||||
?: return emptyList()
|
||||
return try {
|
||||
val messages = JSONArray(encoded)
|
||||
List(messages.length()) { index ->
|
||||
val message = messages.getJSONObject(index)
|
||||
val attachments = message.optJSONArray("attachments") ?: JSONArray()
|
||||
QueuedMessage(
|
||||
id = message.getString("id"),
|
||||
text = message.getString("text"),
|
||||
attachments = List(attachments.length()) { attachments.getString(it) },
|
||||
refusal = message.optString("refusal").takeIf { it.isNotEmpty() },
|
||||
local = true,
|
||||
)
|
||||
}
|
||||
} catch (_: org.json.JSONException) {
|
||||
// A corrupt local outbox is not useful on the next open either. Remove it rather than
|
||||
// repeatedly pretending it decoded to an intentionally empty one.
|
||||
context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).edit { remove(key) }
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Stores only sends the server has not confirmed; everything accepted is the server's to keep. */
|
||||
internal fun savePendingMessages(context: Context, key: String, queued: List<QueuedMessage>) {
|
||||
val local = queued.filter { it.local && !it.serverAccepted }
|
||||
context.getSharedPreferences(PENDING_MESSAGES, Context.MODE_PRIVATE).edit {
|
||||
if (local.isEmpty()) {
|
||||
remove(key)
|
||||
} else {
|
||||
putString(
|
||||
key,
|
||||
JSONArray(
|
||||
local.map { message ->
|
||||
JSONObject()
|
||||
.put("id", message.id)
|
||||
.put("text", message.text)
|
||||
.put("attachments", JSONArray(message.attachments))
|
||||
.put("refusal", message.refusal ?: "")
|
||||
}
|
||||
)
|
||||
.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Relays a provider CLI's headless browser login without ever owning its credentials.
|
||||
*
|
||||
* The URL and code live only in this composition. The CLI process on [machineId] remains the one
|
||||
* OAuth client and the only writer of its credential file.
|
||||
*/
|
||||
@Composable
|
||||
fun ProviderLoginDialog(
|
||||
settings: ServerSettings,
|
||||
machineId: String,
|
||||
machineName: String,
|
||||
provider: String,
|
||||
onDismiss: () -> Unit,
|
||||
onSignedIn: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
var login by remember(machineId, provider) { mutableStateOf<ProviderLogin?>(null) }
|
||||
var code by remember(machineId, provider) { mutableStateOf("") }
|
||||
var error by remember(machineId, provider) { mutableStateOf<String?>(null) }
|
||||
var retry by remember(machineId, provider) { mutableIntStateOf(0) }
|
||||
|
||||
suspend fun follow(initial: ProviderLogin): ProviderLogin {
|
||||
var current = initial
|
||||
val wasSubmitting = initial.state == "submitting"
|
||||
while (current.state == "starting" || current.state == "submitting") {
|
||||
delay(400)
|
||||
current =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchProviderLogin(
|
||||
settings,
|
||||
machineId,
|
||||
provider,
|
||||
current.attempt,
|
||||
)
|
||||
}
|
||||
login = current
|
||||
}
|
||||
if (wasSubmitting && current.state == "waitingForCode" && current.detail == null) {
|
||||
current =
|
||||
current.copy(
|
||||
detail = "That code was not accepted. Copy the complete code and try again."
|
||||
)
|
||||
login = current
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
LaunchedEffect(machineId, provider, retry) {
|
||||
error = null
|
||||
code = ""
|
||||
login = null
|
||||
try {
|
||||
val started =
|
||||
withContext(Dispatchers.IO) { startProviderLogin(settings, machineId, provider) }
|
||||
login = started
|
||||
if (follow(started).state == "succeeded") {
|
||||
onSignedIn()
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
login
|
||||
?.takeUnless { it.state in setOf("succeeded", "failed", "cancelled") }
|
||||
?.let {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching { cancelProviderLogin(settings, machineId, provider, it.attempt) }
|
||||
}
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = ::dismiss,
|
||||
title = { Text("Sign in to Claude") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"Claude will sign in on $machineName. Open the authorization page, then " +
|
||||
"paste the code it gives you here."
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
when (val current = login) {
|
||||
null ->
|
||||
if (error == null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator()
|
||||
Text("Starting sign-in…")
|
||||
}
|
||||
}
|
||||
else ->
|
||||
when (current.state) {
|
||||
"starting",
|
||||
"submitting" ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
if (current.state == "submitting") "Checking code…"
|
||||
else "Starting sign-in…"
|
||||
)
|
||||
}
|
||||
"waitingForCode" -> {
|
||||
TextButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
current.authorizationUrl?.let(uriHandler::openUri)
|
||||
}
|
||||
.onFailure {
|
||||
error = "Couldn't open the authorization page."
|
||||
}
|
||||
},
|
||||
enabled = current.authorizationUrl != null,
|
||||
) {
|
||||
Text("Open authorization page")
|
||||
}
|
||||
LabelledField(
|
||||
label = "Authorization code",
|
||||
value = code,
|
||||
onValueChange = { code = it },
|
||||
)
|
||||
current.detail?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
"succeeded" -> Text("Signed in on $machineName.")
|
||||
"cancelled" -> Text("Sign-in was cancelled.")
|
||||
else ->
|
||||
Text(
|
||||
current.detail ?: "Sign-in failed.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
val current = login
|
||||
when {
|
||||
current?.state == "waitingForCode" ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
error = null
|
||||
try {
|
||||
val submitted =
|
||||
withContext(Dispatchers.IO) {
|
||||
submitProviderLoginCode(
|
||||
settings,
|
||||
machineId,
|
||||
provider,
|
||||
current.attempt,
|
||||
code,
|
||||
)
|
||||
}
|
||||
login = submitted
|
||||
if (follow(submitted).state == "succeeded") {
|
||||
onSignedIn()
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = code.isNotBlank(),
|
||||
) {
|
||||
Text("Continue")
|
||||
}
|
||||
error != null || current?.state == "failed" || current?.state == "cancelled" ->
|
||||
TextButton(onClick = { retry++ }) { Text("Try again") }
|
||||
current?.state == "succeeded" -> TextButton(onClick = onDismiss) { Text("Done") }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (login?.state != "succeeded") {
|
||||
TextButton(onClick = ::dismiss) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The controls for whatever settings a provider says it takes.
|
||||
*
|
||||
* One composable for both screens that offer them — the spawn form and the session settings dialog
|
||||
* — and for every provider, because the server declares the list (see `DriverKind::params`) rather
|
||||
* than this file knowing it. A driver that grows a setting gets a control here with no change to
|
||||
* the app, which is the whole point: the values that suit one machine ship as defaults, and every
|
||||
* one of them stays reachable from a phone.
|
||||
*
|
||||
* [values] is the whole map and [onChange] hands back the whole map. A key absent from it means the
|
||||
* setting is unset, which is what every [ParamSpec.unset] describes — so clearing a field and never
|
||||
* touching it are deliberately the same state.
|
||||
*/
|
||||
@Composable
|
||||
fun ProviderParamFields(
|
||||
specs: List<ParamSpec>,
|
||||
values: Map<String, String>,
|
||||
onChange: (Map<String, String>) -> Unit,
|
||||
/**
|
||||
* How a choice is drawn here, which is the screen's to decide rather than the setting's: chips
|
||||
* on a form somebody is filling in, a picker row in a list of settings. A provider's choices
|
||||
* have to look like the choices beside them, whichever screen that is.
|
||||
*/
|
||||
choices: ChoiceStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (specs.isEmpty()) return
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
specs.forEach { spec ->
|
||||
val set = { value: String ->
|
||||
onChange(
|
||||
// Blank clears rather than storing an empty string: the server reads an absent
|
||||
// key as "use the default", and an empty one would be a value it then failed
|
||||
// to parse.
|
||||
if (value.isBlank()) values - spec.key else values + (spec.key to value)
|
||||
)
|
||||
}
|
||||
when (spec.kind) {
|
||||
"choice" -> {
|
||||
// The first option is what unset means, so selecting it clears the key — see
|
||||
// `ParamKind::Choice`. Without that the picker could show a default it could
|
||||
// not return to.
|
||||
val default = spec.options.firstOrNull().orEmpty()
|
||||
val selected = values[spec.key] ?: default
|
||||
val pick = { chosen: String -> set(if (chosen == default) "" else chosen) }
|
||||
when (choices) {
|
||||
ChoiceStyle.Chips ->
|
||||
ChipGroup(
|
||||
label = spec.label,
|
||||
options = spec.options,
|
||||
selected = selected,
|
||||
onSelect = pick,
|
||||
)
|
||||
ChoiceStyle.Picker -> PickerRow(spec.label, selected, spec.options, pick)
|
||||
}
|
||||
}
|
||||
else ->
|
||||
LabelledField(
|
||||
label = spec.label,
|
||||
value = values[spec.key].orEmpty(),
|
||||
onValueChange = set,
|
||||
hint = spec.unset,
|
||||
// Prose is written rather than filled in, so it gets the room to be read
|
||||
// back -- see `ParamKind::Prose`.
|
||||
lines = if (spec.kind == "prose") 4 else 1,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = keyboardFor(spec.kind)),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Which control a [ParamKind.Choice] gets -- see [ProviderParamFields]'s `choices`. */
|
||||
enum class ChoiceStyle {
|
||||
Chips,
|
||||
Picker,
|
||||
}
|
||||
|
||||
/**
|
||||
* The keyboard for a value's shape. A number field that opens the letter keyboard is one every
|
||||
* entry is made harder by, and these are nearly all numbers.
|
||||
*/
|
||||
private fun keyboardFor(kind: String): KeyboardType =
|
||||
when (kind) {
|
||||
"integer" -> KeyboardType.Number
|
||||
"decimal" -> KeyboardType.Decimal
|
||||
else -> KeyboardType.Text
|
||||
}
|
||||
|
||||
/**
|
||||
* How long typing has to stop before edited settings are sent.
|
||||
*
|
||||
* Long enough that a number is one request rather than one per digit, short enough that closing the
|
||||
* dialog straight after typing still saves — the save runs on the screen behind it, which outlives
|
||||
* the dialog, so this delay is not a window the value can be lost in.
|
||||
*/
|
||||
const val PARAM_SAVE_DELAY_MS = 700L
|
||||
@@ -0,0 +1,509 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* One provider on one machine: what it is, what its shared server is holding, and how each of its
|
||||
* models is loaded.
|
||||
*
|
||||
* This is where a setting that belongs to a *machine* lives, as opposed to one that belongs to a
|
||||
* session. The two were one list until llama.cpp sessions came to share one server per machine: how
|
||||
* a model is loaded stopped being anything a single session could decide, because one copy of it in
|
||||
* memory is what several sessions are talking to.
|
||||
*
|
||||
* It is also the only place a loaded model is taken out of memory. Nothing does that on its own —
|
||||
* closing a session leaves the model loaded on purpose, since the next one to want it would
|
||||
* otherwise pay the load again — so the memory is freed here, where what it costs everybody is
|
||||
* visible.
|
||||
*/
|
||||
@Composable
|
||||
fun ProviderScreen(
|
||||
settings: ServerSettings,
|
||||
machineId: String,
|
||||
provider: String,
|
||||
/**
|
||||
* The way back, or null where this is drawn inside something that has one of its own -- the
|
||||
* session settings screen's second tab. Two ways out stacked above each other is a reader
|
||||
* asking which of them goes where.
|
||||
*/
|
||||
onBack: (() -> Unit)?,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember { mutableStateOf<LoadState<ProviderView>>(LoadState.Loading) }
|
||||
var reload by remember { mutableIntStateOf(0) }
|
||||
var editing by remember { mutableStateOf<ProviderModel?>(null) }
|
||||
var confirmingStop by remember { mutableStateOf(false) }
|
||||
// What is being done to the server or to one of its models, in a word, and what went wrong
|
||||
// when it did. Both here rather than per row: these act on the whole machine.
|
||||
var busy by remember { mutableStateOf<String?>(null) }
|
||||
var actionError by remember { mutableStateOf<String?>(null) }
|
||||
var confirmingDelete by remember { mutableStateOf<ProviderModel?>(null) }
|
||||
|
||||
// The machine's own models and what is being fetched onto it. Only for a provider that serves
|
||||
// files off that machine's disk -- everything else names its models rather than holding them,
|
||||
// and a search for a GGUF under the Claude CLI would be an offer that leads nowhere.
|
||||
val kind = (state as? LoadState.Loaded)?.value?.kind
|
||||
val machineModels =
|
||||
rememberMachineModels(
|
||||
settings = settings,
|
||||
machineId = machineId,
|
||||
enabled = kind == "llama_cpp",
|
||||
// A download that became a model is a model this screen has no settings for yet, so
|
||||
// the view it is drawing is now one model short of the truth.
|
||||
onLocalChange = { reload++ },
|
||||
)
|
||||
|
||||
LaunchedEffect(reload) {
|
||||
state =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
LoadState.Loaded(fetchProvider(settings, machineId, provider))
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Say what is happening, do it, say what went wrong, refetch: every action on this screen
|
||||
// changes what it is showing.
|
||||
val act = { what: String, action: suspend () -> Unit ->
|
||||
scope.launch {
|
||||
busy = what
|
||||
actionError =
|
||||
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
|
||||
busy = null
|
||||
reload++
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
// The models search at the bottom takes the keyboard, and everything below the field it is
|
||||
// typed in -- the Search button, the results -- is behind it without this.
|
||||
Column(Modifier.fillMaxSize().imePadding().padding(16.dp)) {
|
||||
onBack?.let {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
TextButton(onClick = it) { Text("Back") }
|
||||
}
|
||||
}
|
||||
when (val current = state) {
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
||||
is LoadState.Loaded -> {
|
||||
val view = current.value
|
||||
Text(view.name, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"on ${view.machine}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
view.command?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
actionError?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
busy?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp))
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
view.server?.let { server ->
|
||||
item("server") {
|
||||
ServerCard(
|
||||
server = server,
|
||||
maxLoaded = view.maxLoaded,
|
||||
enabled = busy == null,
|
||||
onStop = { confirmingStop = true },
|
||||
onMaxLoaded = { chosen ->
|
||||
act("Saving…") {
|
||||
setProviderSettings(
|
||||
settings,
|
||||
machineId,
|
||||
provider,
|
||||
chosen,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
if (view.models.isNotEmpty() && view.modelParams.isNotEmpty()) {
|
||||
item("models-heading") {
|
||||
Text("Models", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
machineModels.actionError?.let { failure ->
|
||||
item("models-error") {
|
||||
Text(failure, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
// Above the models: this is what is about to be one of them.
|
||||
downloadCards(machineModels)
|
||||
val sizes = machineModels.sizes
|
||||
uniqueItems(view.models, key = { it.id }) { model ->
|
||||
ModelCard(
|
||||
model = model,
|
||||
specs = view.modelParams,
|
||||
bytes = sizes[model.id],
|
||||
onDelete =
|
||||
if (model.id in sizes) ({ confirmingDelete = model }) else null,
|
||||
// Tapping opens the settings; a provider whose models take none has
|
||||
// nothing to open, so the row is not a control.
|
||||
onEdit =
|
||||
if (view.modelParams.isEmpty()) null else ({ editing = model }),
|
||||
onUnload =
|
||||
if (model.status == "loaded" || model.status == "sleeping") {
|
||||
{
|
||||
act("Unloading ${model.label}…") {
|
||||
unloadProviderModel(
|
||||
settings,
|
||||
machineId,
|
||||
provider,
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else null,
|
||||
enabled = busy == null,
|
||||
)
|
||||
}
|
||||
if (kind == "llama_cpp") modelSearch(machineModels)
|
||||
if (view.mcpServers.isNotEmpty()) {
|
||||
item("mcp") {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Tool servers", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
view.mcpServers.joinToString(", ") +
|
||||
" — configured on the backend, in its config file.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editing?.let { model ->
|
||||
val view = (state as? LoadState.Loaded)?.value
|
||||
ModelSettingsDialog(
|
||||
model = model,
|
||||
specs = view?.modelParams.orEmpty(),
|
||||
onDismiss = { editing = null },
|
||||
onSave = { params ->
|
||||
editing = null
|
||||
act("Saving ${model.label}…") {
|
||||
setModelSettings(settings, machineId, provider, model.id, params)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
confirmingDelete?.let { model ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmingDelete = null },
|
||||
title = { Text("Delete ${model.label}?") },
|
||||
text = {
|
||||
Text(
|
||||
"The file is removed from ${(state as? LoadState.Loaded)?.value?.machine ?: "this machine"}. " +
|
||||
"Nothing here can get it back -- downloading it again is the whole file again. " +
|
||||
"Sessions using it keep their conversations and cannot start it."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmingDelete = null
|
||||
machineModels.remove(model.id)
|
||||
}
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (confirmingStop) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmingStop = false },
|
||||
title = { Text("Stop this server?") },
|
||||
text = {
|
||||
// Said plainly rather than hidden: this is the only thing that frees the memory,
|
||||
// and what it costs is that every session on this machine reloads its model.
|
||||
Text(
|
||||
"Every model it is holding is unloaded. Sessions using it will show as " +
|
||||
"exited, and the next message to one loads its model again — which is " +
|
||||
"the slow part, not the sending."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmingStop = false
|
||||
act("Stopping…") { stopProviderServer(settings, machineId, provider) }
|
||||
}
|
||||
) {
|
||||
Text("Stop")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmingStop = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerCard(
|
||||
server: ServerState,
|
||||
maxLoaded: Int?,
|
||||
enabled: Boolean,
|
||||
onStop: () -> Unit,
|
||||
onMaxLoaded: (Int?) -> Unit,
|
||||
) {
|
||||
// The saved value is what this starts at and what Save is compared against, so a field left
|
||||
// half-typed is visibly not saved rather than quietly either way.
|
||||
val saved = maxLoaded?.toString().orEmpty()
|
||||
var typed by remember(saved) { mutableStateOf(saved) }
|
||||
var confirming by remember { mutableStateOf(false) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text("Model server", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
if (server.running) {
|
||||
"Running" + (server.port?.let { ", reached on port $it" } ?: "")
|
||||
} else {
|
||||
// Not a fault: nothing is loaded because nothing has asked. Saying it in
|
||||
// words rather than colouring the row, since "stopped" and "we could not
|
||||
// ask" would otherwise look the same.
|
||||
"Not running. A session starts it when it needs a model."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LabelledField(
|
||||
label = "Models loaded at once",
|
||||
value = typed,
|
||||
onValueChange = { typed = it.filter(Char::isDigit) },
|
||||
hint = "one -- a second model replaces the first",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Shown whether or not it is running, and disabled when there is nothing to stop:
|
||||
// a button that comes and goes makes its own absence the message.
|
||||
TextButton(enabled = enabled && server.running, onClick = onStop) { Text("Stop") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(
|
||||
enabled = enabled && typed != saved,
|
||||
// Saving this while the server is up changes nothing until it comes down
|
||||
// again, which is asked rather than written underneath -- see [RestartDialog].
|
||||
onClick = {
|
||||
if (server.running) confirming = true else onMaxLoaded(typed.toIntOrNull())
|
||||
},
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (confirming) {
|
||||
RestartDialog(
|
||||
title = "Save for the next start?",
|
||||
text =
|
||||
"This server is running, and how many models it keeps loaded was decided when it " +
|
||||
"started. Saving now changes what it does the next time it starts -- stop it " +
|
||||
"here to have that be now.",
|
||||
onConfirm = {
|
||||
confirming = false
|
||||
onMaxLoaded(typed.toIntOrNull())
|
||||
},
|
||||
onDismiss = { confirming = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelCard(
|
||||
model: ProviderModel,
|
||||
specs: List<ParamSpec>,
|
||||
/** How big the file is on the machine, for a provider whose models are files. */
|
||||
bytes: Long?,
|
||||
onEdit: (() -> Unit)?,
|
||||
onUnload: (() -> Unit)?,
|
||||
onDelete: (() -> Unit)?,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.then(if (onEdit != null && enabled) Modifier.clickable(onClick = onEdit) else Modifier)
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
model.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
bytes?.let {
|
||||
Text(
|
||||
gigabytes(it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
// What the server is doing with it, in its own word. Absent means nobody could ask --
|
||||
// the server is not running -- and the line is left out rather than guessed at.
|
||||
model.status?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (model.settings.isNotEmpty()) {
|
||||
Text(
|
||||
// In the words the dialog uses, and in the order it draws them: a summary
|
||||
// naming `contextSize` is a summary of a different screen than the one it
|
||||
// sits under.
|
||||
specs
|
||||
.mapNotNull { spec ->
|
||||
model.settings[spec.key]?.let { "${spec.label} $it" }
|
||||
}
|
||||
.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (onUnload != null || onDelete != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Both shown whenever this kind of model has them, disabled rather than
|
||||
// absent: unloading frees memory and deleting frees disk, and a button that
|
||||
// comes and goes makes its own absence the message.
|
||||
onUnload?.let { TextButton(enabled = enabled, onClick = it) { Text("Unload") } }
|
||||
Spacer(Modifier.weight(1f))
|
||||
onDelete?.let { TextButton(enabled = enabled, onClick = it) { Text("Delete") } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How one model is loaded.
|
||||
*
|
||||
* Saved on Save rather than as it is typed, unlike the session settings dialog: writing this
|
||||
* unloads the model for everybody using it, which is not something to do once per keystroke.
|
||||
*/
|
||||
@Composable
|
||||
private fun ModelSettingsDialog(
|
||||
model: ProviderModel,
|
||||
specs: List<ParamSpec>,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Map<String, String>) -> Unit,
|
||||
) {
|
||||
var params by remember(model.id) { mutableStateOf(model.settings) }
|
||||
// Asked over this dialog rather than instead of it, so Cancel comes back to the edits rather
|
||||
// than throwing them away.
|
||||
var confirming by remember(model.id) { mutableStateOf(false) }
|
||||
// Whether saving costs anything worth asking about: something has to be in memory, and at
|
||||
// least one of these settings has to be one it read on the way in.
|
||||
val reloads =
|
||||
(model.status == "loaded" || model.status == "sleeping") && specs.any { it.restart }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
// Every control here is a number, so the keyboard is up for most of this dialog's life --
|
||||
// and a dialog that keeps its own size under the keyboard puts Save off the bottom of the
|
||||
// screen, where nothing on screen says it is there. Taking the insets ourselves is what
|
||||
// lets `imePadding` shrink it instead.
|
||||
properties = DialogProperties(decorFitsSystemWindows = false),
|
||||
modifier = Modifier.imePadding(),
|
||||
title = { Text(model.label) },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
ProviderParamFields(
|
||||
specs = specs,
|
||||
values = params,
|
||||
onChange = { params = it },
|
||||
// Chips: this is a form of its own rather than a row in a list of settings.
|
||||
choices = ChoiceStyle.Chips,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { if (reloads) confirming = true else onSave(params) }) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
if (confirming) {
|
||||
RestartDialog(
|
||||
title = "Unload ${model.label}?",
|
||||
text =
|
||||
"It is in memory now, and these are read when it is loaded. Saving takes it out " +
|
||||
"of memory; the sessions using it load it again with these settings on their " +
|
||||
"next message.",
|
||||
onConfirm = {
|
||||
confirming = false
|
||||
onSave(params)
|
||||
},
|
||||
onDismiss = { confirming = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -18,6 +20,14 @@ import androidx.compose.ui.unit.dp
|
||||
* 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 is adjusted.
|
||||
*
|
||||
* **Nothing in here wraps; it scrolls sideways instead.** This is column-aligned far more often
|
||||
* than it is prose -- a diff, a table, a test run, a command and its arguments -- and wrapping
|
||||
* destroys exactly the alignment that was carrying the meaning, while turning one line into four
|
||||
* and a run of them into a wall. The scroll belongs to the block rather than to each line so that
|
||||
* the lines stay aligned with each other as it moves: one offset for the whole column is what makes
|
||||
* a shifted diff still read as a diff. Every [Text] inside is therefore drawn with `softWrap =
|
||||
* false`, which is the half of this a caller has to remember.
|
||||
*
|
||||
* The colour is [rawSurface], which is also what a code block inside a reply is given.
|
||||
*/
|
||||
@Composable
|
||||
@@ -29,6 +39,10 @@ fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.()
|
||||
// rectangle drawn at the same radius as the one behind it reads as a misprint.
|
||||
.clip(MaterialTheme.shapes.extraSmall)
|
||||
.background(rawSurface)
|
||||
// Clipped and filled before this, so the tint is the viewport and does not scroll away
|
||||
// from under the text; padded after it, so the inset travels with the content and the
|
||||
// last column does not end flush against the edge.
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
content = content,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Dragging a row of a [androidx.compose.foundation.lazy.LazyColumn] into a different place in it.
|
||||
*
|
||||
* Generic rather than the session list's own, because "hold this and move it" is one gesture
|
||||
* wherever it appears and the arithmetic below is the whole of it. The list itself is left alone:
|
||||
* this reports a move and the caller decides what a move means -- it is the caller that holds the
|
||||
* rows and the caller that tells a server about the new order.
|
||||
*
|
||||
* The drag is on a [ReorderHandle] rather than on the row, which is what keeps it out of the way of
|
||||
* the scroll. A whole row that can be dragged sideways-ish is a row that sometimes eats a fling,
|
||||
* and a list is scrolled far more often than it is rearranged.
|
||||
*/
|
||||
class Reorder
|
||||
internal constructor(
|
||||
private val listState: LazyListState,
|
||||
private val scope: CoroutineScope,
|
||||
private val haptics: HapticFeedback,
|
||||
/** What the [EDGE] band is in pixels here; a band in raw pixels is one screen's answer. */
|
||||
private val density: Density,
|
||||
/**
|
||||
* The caller's own lists are what move; these are [State] so that the gesture, which outlives a
|
||||
* recomposition, is never holding the first composition's copy of them.
|
||||
*/
|
||||
private val onMove: State<(from: Int, to: Int) -> Unit>,
|
||||
private val onSettled: State<() -> Unit>,
|
||||
) {
|
||||
/** The key of the row in hand, or null when nothing is being dragged. */
|
||||
var held by mutableStateOf<Any?>(null)
|
||||
private set
|
||||
|
||||
/** Where the list had laid the row out when it was taken hold of, in viewport pixels. */
|
||||
private var grabbedAt = 0
|
||||
|
||||
/** How far the finger has moved since, which is what the row is drawn following. */
|
||||
private var dragged by mutableFloatStateOf(0f)
|
||||
|
||||
/** How far the list has scrolled under it since -- see [follow]. */
|
||||
private var scrolled = 0f
|
||||
|
||||
/** The index the row has been moved to so far, which is what the next move counts from. */
|
||||
private var at = 0
|
||||
|
||||
/** Where it started, so that a handle merely pressed is not reported as a rearrangement. */
|
||||
private var from = 0
|
||||
|
||||
/**
|
||||
* How much of the travel below the moves so far have accounted for.
|
||||
*
|
||||
* The travel is what decides a crossing, rather than where the row is drawn *now*: a lazy list
|
||||
* animates an item into its new place, so for a few frames after a move `offset` still reports
|
||||
* roughly the old one. Deciding from that offset re-decided the same crossing on every frame
|
||||
* until the animation caught up, and a drag of two rows arrived six rows down.
|
||||
*/
|
||||
private var settled = 0f
|
||||
|
||||
private fun info(key: Any): LazyListItemInfo? =
|
||||
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key }
|
||||
|
||||
private fun itemAt(index: Int): LazyListItemInfo? =
|
||||
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
|
||||
|
||||
/**
|
||||
* How far from where the list laid it out this row should be drawn -- zero for every row but
|
||||
* the one in hand.
|
||||
*
|
||||
* Measured against where the row is laid out *now* rather than accumulated, which is what makes
|
||||
* it self-correcting: a move, or a scroll under the finger, puts the row somewhere new, and the
|
||||
* same subtraction cancels that out so the row stays under the finger instead of jumping by its
|
||||
* own height.
|
||||
*/
|
||||
fun offsetOf(key: Any): Float {
|
||||
if (key != held) return 0f
|
||||
val now = info(key) ?: return 0f
|
||||
return grabbedAt + dragged - now.offset
|
||||
}
|
||||
|
||||
internal fun grab(key: Any) {
|
||||
val from = info(key) ?: return
|
||||
held = key
|
||||
grabbedAt = from.offset
|
||||
at = from.index
|
||||
this.from = from.index
|
||||
dragged = 0f
|
||||
scrolled = 0f
|
||||
settled = 0f
|
||||
// The platform's "you have picked this up", the same feedback a long press gives, because
|
||||
// the gesture it confirms is the same kind of commitment.
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
|
||||
internal fun drag(by: Float) {
|
||||
if (held == null) return
|
||||
dragged += by
|
||||
cross()
|
||||
}
|
||||
|
||||
/**
|
||||
* Trades places with as many neighbours as the travel so far has earned.
|
||||
*
|
||||
* Half a neighbour's height each way, so the row changes place when it covers most of the one
|
||||
* it is passing -- and a full height of hysteresis before it can come back, since the move has
|
||||
* already paid that half in the other direction. A loop rather than one step: a fast drag, or a
|
||||
* list scrolling under a parked finger, crosses several rows between two events.
|
||||
*/
|
||||
private fun cross() {
|
||||
while (true) {
|
||||
val slack = dragged + scrolled - settled
|
||||
val next = itemAt(if (slack > 0) at + 1 else at - 1) ?: return
|
||||
if (abs(slack) < next.size / 2f) return
|
||||
// Where the list is looking, taken before the move and put back after it. A lazy list
|
||||
// keeps its place by the *key* of the item at the top, so moving that item takes the
|
||||
// viewport with it -- drag the top row down two places and the list scrolls two rows
|
||||
// to follow it, which reads as the row never having moved. The correction is by index,
|
||||
// which is the thing that did not change.
|
||||
val anchor = listState.firstVisibleItemIndex
|
||||
val within = listState.firstVisibleItemScrollOffset
|
||||
onMove.value(at, next.index)
|
||||
// Requested rather than scrolled to: this has to take effect in the *same* measurement
|
||||
// as the move, and a scroll launched beside it lands before the list has taken the new
|
||||
// order and is then undone by it.
|
||||
listState.requestScrollToItem(anchor, within)
|
||||
settled += if (slack > 0) next.size.toFloat() else -next.size.toFloat()
|
||||
at = next.index
|
||||
// Loud on purpose: the row is under a finger that is covering it, so the tick is how
|
||||
// the reader knows a place was taken rather than that they are still between two.
|
||||
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun release() {
|
||||
// Only where the row actually went somewhere: a handle pressed and let go has rearranged
|
||||
// nothing, and reporting one would have the server rewrite the order it already has.
|
||||
val moved = held != null && at != from
|
||||
held = null
|
||||
dragged = 0f
|
||||
scrolled = 0f
|
||||
settled = 0f
|
||||
if (moved) onSettled.value()
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the list while the row in hand is held against one end of it, so a row can be moved
|
||||
* further than one screenful. A frame loop rather than a response to the drag, because a finger
|
||||
* parked at the bottom edge sends no more events and is exactly the case this exists for.
|
||||
*/
|
||||
internal fun follow() {
|
||||
val key = held ?: return
|
||||
scope.launch {
|
||||
while (held == key) {
|
||||
withFrameNanos {}
|
||||
val moving = info(key) ?: continue
|
||||
val viewport = listState.layoutInfo.viewportEndOffset
|
||||
val edge = with(density) { EDGE.toPx() }
|
||||
val top = grabbedAt + dragged
|
||||
val bottom = top + moving.size
|
||||
val step =
|
||||
when {
|
||||
top < edge -> -(edge - top).coerceAtMost(edge)
|
||||
bottom > viewport - edge -> (bottom - (viewport - edge)).coerceAtMost(edge)
|
||||
else -> 0f
|
||||
}
|
||||
if (step == 0f) continue
|
||||
// Counted as travel of its own: the finger has not moved, but the rows have moved
|
||||
// under it, which is the same thing to everything above. Nothing is added to the
|
||||
// drag, because where the row is *drawn* is measured against the list's own
|
||||
// offsets and those have already moved.
|
||||
scrolled += listState.scrollBy(step * SPEED)
|
||||
cross()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** How close to an end of the list a held row has to be before the list follows it. */
|
||||
val EDGE = 36.dp
|
||||
|
||||
/** A fraction of the overshoot per frame, so the scroll eases in rather than lurching. */
|
||||
const val SPEED = 0.12f
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberReorder(
|
||||
listState: LazyListState,
|
||||
/** Two indices into the lazy list, which is the caller's own order to rearrange. */
|
||||
onMove: (from: Int, to: Int) -> Unit,
|
||||
/** The drag is over: the order on screen is the one to keep. */
|
||||
onSettled: () -> Unit,
|
||||
): Reorder {
|
||||
val move = rememberUpdatedState(onMove)
|
||||
val settled = rememberUpdatedState(onSettled)
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
return remember(listState) { Reorder(listState, scope, haptics, density, move, settled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The handle a row is dragged by: the burger, at about the size of a heading.
|
||||
*
|
||||
* Bigger than an icon beside a line of text -- this is what a row is taken hold of by, and at
|
||||
* [GLYPH_SIZE] it read as decoration on the end of the row. Not as big as the row either: a mark
|
||||
* scaled to the card's whole inner height came out heavier than anything else on screen, since
|
||||
* these rules thicken with the glyph.
|
||||
*
|
||||
* The touch square around it is [GLYPH_BUTTON_SIZE], the same as every other icon control here, so
|
||||
* the mark and the area that answers to a finger are two different sizes -- which is why the caller
|
||||
* subtracts [HANDLE_MARGIN] from the gap it wants: what has to line up with the text on the other
|
||||
* side is the mark, not the box around it.
|
||||
*
|
||||
* [key] is the row's own key in the list, which is how a gesture that started here finds the row it
|
||||
* belongs to -- an index would be stale the moment the first move landed.
|
||||
*/
|
||||
@Composable
|
||||
fun ReorderHandle(state: Reorder, key: Any, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
modifier
|
||||
.size(GLYPH_BUTTON_SIZE)
|
||||
// Nothing here draws a word, and a handle is the kind of control somebody using a
|
||||
// screen reader has no other way to find.
|
||||
.semantics { contentDescription = "Drag to reorder" }
|
||||
.pointerInput(key) {
|
||||
detectDragGestures(
|
||||
onDragStart = {
|
||||
state.grab(key)
|
||||
state.follow()
|
||||
},
|
||||
onDrag = { _, amount -> state.drag(amount.y) },
|
||||
onDragEnd = { state.release() },
|
||||
onDragCancel = { state.release() },
|
||||
)
|
||||
},
|
||||
) {
|
||||
Glyph(DRAG_GLYPH, colour = MaterialTheme.colorScheme.onSurfaceVariant, size = HANDLE_MARK)
|
||||
}
|
||||
}
|
||||
|
||||
/** How big the mark itself is: a heading's size, which is what the font is asked for in `sp`. */
|
||||
private val HANDLE_MARK = 24.sp
|
||||
|
||||
/**
|
||||
* How much of the touch square lies outside the mark on each side.
|
||||
*
|
||||
* A caller that wants the *mark* a given distance from something takes this off that distance --
|
||||
* see the rule about aligning the mark rather than the box it is centred in.
|
||||
*/
|
||||
val HANDLE_MARGIN = (GLYPH_BUTTON_SIZE - HANDLE_MARK.value.dp) / 2
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The line under a finished reply: what it cost to produce, and when it was sent.
|
||||
*
|
||||
* Small and set back, in the tone the session's own subtitle takes: it is about the message rather
|
||||
* than part of it, and at the reply's own size it would read as the last thing the model said.
|
||||
*
|
||||
* Right-aligned because it closes the message rather than opening one -- a reader scanning down the
|
||||
* left edge is reading what was said, and this is where that ends.
|
||||
*/
|
||||
@Composable
|
||||
fun ReplyFooter(
|
||||
ts: Double,
|
||||
tokensPerSecond: Double?,
|
||||
prefillMs: Long?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val text = replyFooterText(ts, tokensPerSecond, prefillMs, ZoneId.systemDefault()) ?: return
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the footer says, or null when there is nothing to say: "read 9.5s · 50.3 tok/s · 3:00 PM".
|
||||
*
|
||||
* Split out so the wording is testable without a screen, and [zone] is a parameter for the same
|
||||
* reason [limitSummary] takes one: a test has to say the same thing wherever it runs.
|
||||
*
|
||||
* **The time is last, and so sits against the right edge whatever else is on the line.** The
|
||||
* measurements in front of it are the provider's, so a session on another provider has fewer of
|
||||
* them or none -- and a reader who has learned where the clock is should not have to find it again
|
||||
* because the model changed. The costs grow leftwards into the space instead.
|
||||
*
|
||||
* Those measurements are drawn only where the provider made them. Most do not -- a coding CLI
|
||||
* reports what a turn cost and never how long the model spent on it -- and the time this app
|
||||
* watched a reply arrive over is a different quantity: it counts the network, the pauses between
|
||||
* tokens and whatever else the machine was doing. So the line is the clock alone rather than a
|
||||
* plausible figure beside it.
|
||||
*/
|
||||
fun replyFooterText(
|
||||
ts: Double,
|
||||
tokensPerSecond: Double?,
|
||||
prefillMs: Long?,
|
||||
zone: ZoneId,
|
||||
): String? {
|
||||
val at =
|
||||
if (ts <= 0.0) null
|
||||
else
|
||||
try {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
.withZone(zone)
|
||||
.format(Instant.ofEpochMilli((ts * 1000).toLong()))
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
// A tenth up to three digits, where the difference between 18 and 18.4 tok/s is something a
|
||||
// reader comparing two models can use; past that the tenth is noise on a figure that moves by
|
||||
// more than that between turns.
|
||||
val rate =
|
||||
tokensPerSecond
|
||||
?.takeIf { it > 0.0 }
|
||||
?.let {
|
||||
if (it >= 100) String.format(Locale.getDefault(), "%.0f tok/s", it)
|
||||
else String.format(Locale.getDefault(), "%.1f tok/s", it)
|
||||
}
|
||||
// Named "read" rather than given a unit alone, because a second figure in seconds beside a
|
||||
// rate is unreadable otherwise -- and it is the same word the status row uses while it is
|
||||
// happening, so the wait and the figure for it are one vocabulary.
|
||||
val read = prefillMs?.takeIf { it > 0 }?.let { "read ${formatMillis(it)}" }
|
||||
return listOfNotNull(read, rate, at).joinToString(" · ").ifEmpty { null }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* What a setting costs, asked before it is written.
|
||||
*
|
||||
* Every setting in this app whose consequence is worth saying says it here rather than in a
|
||||
* paragraph beside the control: a sentence under a switch is read after the decision if it is read
|
||||
* at all, and a form of a dozen settings each carrying its own explanation is mostly explanation. A
|
||||
* modal interrupts at the moment the consequence becomes real, and it is also a way out.
|
||||
*
|
||||
* What they have in common, and why it is one dialog rather than four: each of them ends something
|
||||
* that is running — a process, a loaded model, a server — and says what starting it again costs.
|
||||
*/
|
||||
@Composable
|
||||
fun RestartDialog(
|
||||
title: String,
|
||||
text: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
/** The word on the button, which is the action rather than a bare "OK". */
|
||||
confirm: String = "Save",
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = { Text(text) },
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
@@ -31,13 +31,13 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
|
||||
/**
|
||||
* A session wanting attention, said over the app rather than through Android's drawer.
|
||||
* A session wanting attention, said over the app as well as in Android's drawer.
|
||||
*
|
||||
* 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.
|
||||
* Two places carry the same fact and they are doing different jobs: a row in the shade waits
|
||||
* however long it has to, which makes it the record, and a banner is read now or not at all, which
|
||||
* makes it the interruption. So somebody with the app open gets both -- this, and a silent row
|
||||
* behind it that is still there when they go looking and goes by itself when they open the session.
|
||||
* Whether the app is open at all is this collection and nothing else.
|
||||
*
|
||||
* 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.
|
||||
|
||||
@@ -5,24 +5,32 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
@@ -30,12 +38,20 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -140,12 +156,23 @@ fun SessionImageViewer(
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
||||
val view = LocalView.current
|
||||
var hiddenBars by remember(ref) { mutableStateOf(ViewerBars()) }
|
||||
var barInsets by remember(ref) { mutableStateOf(ViewerBarInsets()) }
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
properties =
|
||||
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
||||
) {
|
||||
ViewerSystemBars(hiddenBars)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black).clickable(onClick = onClose),
|
||||
Modifier.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClose,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when (val image = bitmap) {
|
||||
@@ -165,7 +192,46 @@ fun SessionImageViewer(
|
||||
// beside it are.
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
else -> ZoomableImage(image)
|
||||
else -> {
|
||||
var viewport by remember { mutableStateOf(IntSize.Zero) }
|
||||
var nativeSizeRequest by remember { mutableIntStateOf(0) }
|
||||
ZoomableImage(
|
||||
image,
|
||||
nativeSizeRequest = nativeSizeRequest,
|
||||
onViewportChanged = {
|
||||
viewport = it
|
||||
ViewCompat.getRootWindowInsets(view)?.let { insets ->
|
||||
barInsets =
|
||||
ViewerBarInsets(
|
||||
status =
|
||||
insets
|
||||
.getInsetsIgnoringVisibility(
|
||||
WindowInsetsCompat.Type.statusBars()
|
||||
)
|
||||
.top,
|
||||
navigation =
|
||||
insets
|
||||
.getInsetsIgnoringVisibility(
|
||||
WindowInsetsCompat.Type.navigationBars()
|
||||
)
|
||||
.bottom,
|
||||
)
|
||||
}
|
||||
},
|
||||
onBarsChanged = { hiddenBars = it },
|
||||
barInsets = barInsets,
|
||||
viewport = viewport,
|
||||
)
|
||||
Button(
|
||||
onClick = { nativeSizeRequest++ },
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomEnd)
|
||||
.navigationBarsPadding()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text("100%")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,34 +291,72 @@ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality
|
||||
* The image on its own, as large as it fits, with pinch to zoom.
|
||||
*
|
||||
* 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.
|
||||
* gesture returns to the transcript instead of leaving the app. It opens fitted, with the whole
|
||||
* image visible without enlarging a smaller one; the 100% control changes to one bitmap pixel per
|
||||
* screen pixel and recenters it.
|
||||
*/
|
||||
@Composable
|
||||
private fun ZoomableImage(image: ImageBitmap) {
|
||||
private fun ZoomableImage(
|
||||
image: ImageBitmap,
|
||||
nativeSizeRequest: Int,
|
||||
onViewportChanged: (IntSize) -> Unit,
|
||||
onBarsChanged: (ViewerBars) -> Unit,
|
||||
barInsets: ViewerBarInsets,
|
||||
viewport: IntSize,
|
||||
) {
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||
val nativeScale = nativeScale(image.width, image.height, viewport.width, viewport.height)
|
||||
LaunchedEffect(nativeSizeRequest, nativeScale) {
|
||||
if (nativeSizeRequest > 0) {
|
||||
scale = nativeScale
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
}
|
||||
}
|
||||
val bars =
|
||||
viewerBars(
|
||||
image.width,
|
||||
image.height,
|
||||
viewport.width,
|
||||
viewport.height,
|
||||
scale,
|
||||
Offset(offsetX, offsetY),
|
||||
barInsets,
|
||||
)
|
||||
SideEffect { onBarsChanged(bars) }
|
||||
Image(
|
||||
bitmap = image,
|
||||
contentDescription = "Attached image",
|
||||
contentScale = ContentScale.Fit,
|
||||
contentScale = ContentScale.Inside,
|
||||
// Zoomed in, the reader is looking at pixels on purpose.
|
||||
filterQuality = FilterQuality.None,
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
// Floor of 1 so the image cannot be pinched smaller than fitted, which is
|
||||
// already the whole of it; a ceiling so it cannot be lost off-screen.
|
||||
scale = (scale * zoom).coerceIn(1f, 8f)
|
||||
if (scale > 1f) {
|
||||
offsetX += pan.x
|
||||
offsetY += pan.y
|
||||
.onSizeChanged(onViewportChanged)
|
||||
.pointerInput(nativeScale) {
|
||||
detectTransformGestures { centroid, pan, zoom, _ ->
|
||||
val oldScale = scale
|
||||
val maximumScale = maxOf(8f, nativeScale)
|
||||
val newScale = (oldScale * zoom).coerceIn(1f, maximumScale)
|
||||
if (newScale > 1f) {
|
||||
val offset =
|
||||
zoomOffset(
|
||||
Offset(offsetX, offsetY),
|
||||
centroid,
|
||||
pan,
|
||||
oldScale,
|
||||
newScale,
|
||||
Offset(size.width / 2f, size.height / 2f),
|
||||
)
|
||||
offsetX = offset.x
|
||||
offsetY = offset.y
|
||||
} else {
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
}
|
||||
scale = newScale
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
@@ -263,3 +367,104 @@ private fun ZoomableImage(image: ImageBitmap) {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Lets the picture use the whole display, hiding only the system bars it actually reaches. */
|
||||
@Composable
|
||||
private fun ViewerSystemBars(hidden: ViewerBars) {
|
||||
val view = LocalView.current
|
||||
val window = (view.parent as? DialogWindowProvider)?.window
|
||||
val controller = window?.let { WindowCompat.getInsetsController(it, view) }
|
||||
SideEffect {
|
||||
controller?.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
if (hidden.status) {
|
||||
controller?.hide(WindowInsetsCompat.Type.statusBars())
|
||||
} else {
|
||||
controller?.show(WindowInsetsCompat.Type.statusBars())
|
||||
}
|
||||
if (hidden.navigation) {
|
||||
controller?.hide(WindowInsetsCompat.Type.navigationBars())
|
||||
} else {
|
||||
controller?.show(WindowInsetsCompat.Type.navigationBars())
|
||||
}
|
||||
}
|
||||
DisposableEffect(view) {
|
||||
onDispose {
|
||||
controller?.show(
|
||||
WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ViewerBars(val status: Boolean = false, val navigation: Boolean = false)
|
||||
|
||||
internal data class ViewerBarInsets(val status: Int = 0, val navigation: Int = 0)
|
||||
|
||||
/** Which full-screen system-bar regions the fitted, zoomed and panned image intersects. */
|
||||
internal fun viewerBars(
|
||||
imageWidth: Int,
|
||||
imageHeight: Int,
|
||||
viewportWidth: Int,
|
||||
viewportHeight: Int,
|
||||
scale: Float,
|
||||
offset: Offset,
|
||||
insets: ViewerBarInsets,
|
||||
): ViewerBars {
|
||||
if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) {
|
||||
return ViewerBars()
|
||||
}
|
||||
val fittedScale = insideScale(imageWidth, imageHeight, viewportWidth, viewportHeight)
|
||||
val width = imageWidth * fittedScale * scale
|
||||
val height = imageHeight * fittedScale * scale
|
||||
val left = viewportWidth / 2f + offset.x - width / 2f
|
||||
val right = left + width
|
||||
val top = viewportHeight / 2f + offset.y - height / 2f
|
||||
val bottom = top + height
|
||||
val crossesScreen = right > 0f && left < viewportWidth
|
||||
return ViewerBars(
|
||||
status = crossesScreen && insets.status > 0 && bottom > 0f && top < insets.status,
|
||||
navigation =
|
||||
crossesScreen &&
|
||||
insets.navigation > 0 &&
|
||||
bottom > viewportHeight - insets.navigation &&
|
||||
top < viewportHeight,
|
||||
)
|
||||
}
|
||||
|
||||
/** Scale relative to [ContentScale.Inside] at which bitmap and screen pixels are one-to-one. */
|
||||
internal fun nativeScale(
|
||||
imageWidth: Int,
|
||||
imageHeight: Int,
|
||||
viewportWidth: Int,
|
||||
viewportHeight: Int,
|
||||
): Float {
|
||||
if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) return 1f
|
||||
return 1f / insideScale(imageWidth, imageHeight, viewportWidth, viewportHeight)
|
||||
}
|
||||
|
||||
/** The downscale-only factor used by [ContentScale.Inside]. */
|
||||
private fun insideScale(
|
||||
imageWidth: Int,
|
||||
imageHeight: Int,
|
||||
viewportWidth: Int,
|
||||
viewportHeight: Int,
|
||||
): Float =
|
||||
minOf(
|
||||
1f,
|
||||
viewportWidth.toFloat() / imageWidth,
|
||||
viewportHeight.toFloat() / imageHeight,
|
||||
)
|
||||
|
||||
/** Keeps the image point beneath [centroid] beneath the fingers as its scale changes. */
|
||||
internal fun zoomOffset(
|
||||
offset: Offset,
|
||||
centroid: Offset,
|
||||
pan: Offset,
|
||||
oldScale: Float,
|
||||
newScale: Float,
|
||||
viewportCenter: Offset,
|
||||
): Offset {
|
||||
val scaleChange = newScale / oldScale
|
||||
return offset * scaleChange + (centroid - viewportCenter) * (1f - scaleChange) + pan
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -12,11 +14,15 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -29,14 +35,30 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox.
|
||||
* The sessions tab: every session, in the order the reader has put them in.
|
||||
*
|
||||
* Nothing here sorts. The order is the server's `sessions` list and the reader's own -- see
|
||||
* [reorderSessions] -- which is the one arrangement a row cannot be moved out of by something the
|
||||
* session does. It replaced sorting by activity, and then sorting by when each agent was turned on:
|
||||
* both meant a list that rearranged itself under whoever was reading it, and the status word and
|
||||
* its colour already say which session wants something without the row having to move to say it.
|
||||
*
|
||||
* Holding a row puts the screen in selection mode, the same gesture and the same bottom bar as the
|
||||
* import tab, so the two lists are learned once. Rearranging is deliberately *not* part of a
|
||||
* selection -- the handle moves the row it is on, whether or not that row is picked out -- because
|
||||
* "which rows am I acting on" and "where does this one go" are two questions.
|
||||
*
|
||||
* No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one.
|
||||
* What stays here is the button that adds a session, because that acts on this list and nothing
|
||||
@@ -48,10 +70,23 @@ fun SessionListScreen(
|
||||
reloadToken: Int,
|
||||
onOpen: (SessionSummary) -> Unit,
|
||||
onSpawn: () -> Unit,
|
||||
/** A session this list has just deleted, for whoever is showing it elsewhere. */
|
||||
onDeleted: (String) -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
||||
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
|
||||
|
||||
// Which rows the reader has picked out. Empty means selection mode is off, as on the import
|
||||
// tab: a selection mode with nothing in it has no controls and no way out but Back.
|
||||
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
|
||||
// The sessions a delete has been confirmed for, or none. A list rather than one session,
|
||||
// because a selection is what the bar below acts on.
|
||||
var confirmingDelete by remember { mutableStateOf<List<SessionSummary>>(emptyList()) }
|
||||
|
||||
// Whether an answer is outstanding, which is a different question from whether there is
|
||||
// anything to draw: see [refresh].
|
||||
var reloading by remember { mutableStateOf(false) }
|
||||
|
||||
// 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,
|
||||
@@ -60,6 +95,10 @@ fun SessionListScreen(
|
||||
// Cleared on the next successful load below -- an entry outlives its session otherwise.
|
||||
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
|
||||
// Why the order on screen is not the order that was saved, when saving one failed. The list is
|
||||
// what failed, so it is reported over the list rather than on any row.
|
||||
var orderError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// 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()) }
|
||||
@@ -70,31 +109,142 @@ fun SessionListScreen(
|
||||
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
||||
|
||||
fun refresh() {
|
||||
listState = LoadState.Loading
|
||||
// The rows stay while the answer is on its way, with the bar below saying one is: this
|
||||
// list is asked again every time the panel over a session is opened, and blanking it each
|
||||
// time hands the reader an empty screen to report on something that was never in doubt.
|
||||
// A first load has nothing to keep, and says so with the spinner instead.
|
||||
if (listState !is LoadState.Loaded) listState = LoadState.Loading
|
||||
reloading = true
|
||||
scope.launch {
|
||||
listState =
|
||||
try {
|
||||
val loaded =
|
||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
|
||||
deleteErrors = emptyMap()
|
||||
val alive = loaded.value.map { it.id }.toSet()
|
||||
// A selection is of sessions, so one deleted somewhere else leaves it. Only
|
||||
// that one: the other rows the reader picked out are still there.
|
||||
selected = selected.intersect(alive)
|
||||
// The path out for a cached transcript whose session was deleted somewhere
|
||||
// else. This list is the only place that ever learns the full set. On the
|
||||
// answer rather than in `finally`: a list that failed to arrive says nothing
|
||||
// about which sessions exist.
|
||||
withContext(Dispatchers.IO) {
|
||||
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
|
||||
}
|
||||
withContext(Dispatchers.IO) { transcriptCache.retainOnly(alive) }
|
||||
loaded
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
reloading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes every session in [targets], one after another.
|
||||
*
|
||||
* One at a time and in the order they are drawn: the server has no batch delete for sessions,
|
||||
* and each one ends a process. Each row says what is happening to it from the moment the work
|
||||
* is handed over, which is also when the selection goes -- a bar still naming sessions being
|
||||
* deleted is a set nobody can act on.
|
||||
*/
|
||||
fun deleteChosen(targets: List<SessionSummary>, alsoDeleteForeign: Boolean) {
|
||||
selected = emptySet()
|
||||
// Marked here rather than after the request returns: a row has to say something is
|
||||
// happening to it from the moment it is asked for.
|
||||
deleting = deleting + targets.map { it.id }
|
||||
deleteErrors = deleteErrors - targets.map { it.id }.toSet()
|
||||
scope.launch {
|
||||
for (session in targets) {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
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(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 never in doubt.
|
||||
val loaded = listState
|
||||
if (loaded is LoadState.Loaded) {
|
||||
listState = LoadState.Loaded(loaded.value.filterNot { it.id == session.id })
|
||||
}
|
||||
onDeleted(session.id)
|
||||
} catch (e: ApiException) {
|
||||
// Kept, because it is still there: the server refused, so the session it
|
||||
// refused about is exactly as it was.
|
||||
deleteErrors = deleteErrors + (session.id to (e.message ?: "Delete failed"))
|
||||
} finally {
|
||||
deleting = deleting - session.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(reloadToken) { refresh() }
|
||||
|
||||
val rows = rememberLazyListState()
|
||||
val reorder =
|
||||
rememberReorder(
|
||||
listState = rows,
|
||||
onMove = { from, to ->
|
||||
// Moved here and now, because the row is under a finger: waiting for the server to
|
||||
// agree would drag the handle away from the card it is on. What the server thinks
|
||||
// is asked for when the finger comes up, and a refusal puts the list back.
|
||||
val loaded = listState
|
||||
if (loaded is LoadState.Loaded) {
|
||||
val moved = loaded.value.toMutableList()
|
||||
moved.add(to, moved.removeAt(from))
|
||||
listState = LoadState.Loaded(moved)
|
||||
}
|
||||
},
|
||||
onSettled = {
|
||||
val loaded = listState
|
||||
if (loaded is LoadState.Loaded) {
|
||||
val order = loaded.value.map { it.id }
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { reorderSessions(settings, order) }
|
||||
orderError = null
|
||||
} catch (e: ApiException) {
|
||||
orderError = e.message ?: "The new order couldn't be saved"
|
||||
// The screen must not go on showing an arrangement nothing kept, so
|
||||
// the server's own order comes back -- which is also the only way to
|
||||
// see what it does think.
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Back leaves selection mode rather than the tab, which is the level it is one step above.
|
||||
// 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.
|
||||
var barHeight by remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
// What the bar covers *now*: its measurement is kept while it is away, but nothing is
|
||||
// reserved for a bar that is not up.
|
||||
val covered = if (selected.isEmpty()) 0.dp else barHeight
|
||||
|
||||
// The spawn button floats over the list, so the list ends above it -- measured, for the
|
||||
// reason the bar is. Without this the last row sat under the button, which was survivable
|
||||
// while every part of a row did the same thing and is not now that corner is a handle.
|
||||
var buttonHeight by remember { mutableStateOf(0.dp) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
orderError?.let { message ->
|
||||
// The server's own words, unprefixed, the way every other failure is shown.
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
when (val state = listState) {
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
// The message as Api.kt wrote it, with nothing added: it is already a whole
|
||||
@@ -113,20 +263,32 @@ fun SessionListScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Awaiting-answer first (the point of the screen), then most recently active.
|
||||
val ordered =
|
||||
state.value.sortedWith(
|
||||
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
|
||||
.thenByDescending { it.lastActivity }
|
||||
)
|
||||
LazyColumn {
|
||||
uniqueItems(ordered, key = { it.id }) { session ->
|
||||
LazyColumn(
|
||||
state = rows,
|
||||
contentPadding =
|
||||
PaddingValues(bottom = covered + buttonHeight + BUTTON_RING * 2),
|
||||
) {
|
||||
uniqueItems(state.value, key = { it.id }) { session ->
|
||||
SessionCard(
|
||||
session = session,
|
||||
error = deleteErrors[session.id],
|
||||
deleting = session.id in deleting,
|
||||
onOpen = { onOpen(session) },
|
||||
onLongPress = { confirmingDelete = session },
|
||||
picked = session.id in selected,
|
||||
// The handle is a selection-mode control, so it is absent rather
|
||||
// than disabled outside one: this is not a capability being
|
||||
// withheld, it is a mode the list is not in.
|
||||
reorder = reorder.takeIf { selected.isNotEmpty() },
|
||||
onClick = {
|
||||
// In selection mode a tap is a selection, so the reader is
|
||||
// never one mis-tap away from opening a session they were only
|
||||
// picking rows for.
|
||||
if (selected.isEmpty()) onOpen(session)
|
||||
else
|
||||
selected =
|
||||
if (session.id in selected) selected - session.id
|
||||
else selected + session.id
|
||||
},
|
||||
onLongPress = { selected = selected + session.id },
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
@@ -135,70 +297,114 @@ fun SessionListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Over the list rather than above it: a bar that appears in the flow moves every row down
|
||||
// by its own height at the moment the reader is looking at them.
|
||||
if (reloading) {
|
||||
LinearProgressIndicator(Modifier.align(Alignment.TopCenter).fillMaxWidth())
|
||||
}
|
||||
|
||||
// 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 =
|
||||
(listState as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
|
||||
SessionSelectionBar(
|
||||
count = picked.size,
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomCenter).onSizeChanged {
|
||||
barHeight = with(density) { it.height.toDp() }
|
||||
},
|
||||
onDelete = { confirmingDelete = picked },
|
||||
)
|
||||
}
|
||||
|
||||
// Above the bar when there is one, by what that bar measured: the button stays rather than
|
||||
// coming and going, since an absent control cannot say whether there was nothing to do.
|
||||
FloatingActionButton(
|
||||
onClick = onSpawn,
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomEnd)
|
||||
.padding(end = BUTTON_RING, bottom = BUTTON_RING + covered)
|
||||
.onSizeChanged { buttonHeight = with(density) { it.height.toDp() } },
|
||||
) {
|
||||
Text("+", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
||||
confirmingDelete?.let { session ->
|
||||
// Reset per session, so a toggle turned on for one conversation is not still on for the
|
||||
// next. Off to begin with: see [deleteSession].
|
||||
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
|
||||
val targets = confirmingDelete
|
||||
if (targets.isNotEmpty()) {
|
||||
// Reset per selection, so a toggle turned on for one set of conversations is not still
|
||||
// on for the next. Off to begin with: see [deleteSession].
|
||||
var alsoDeleteForeign by remember(targets) { mutableStateOf(false) }
|
||||
// Whichever of these keep a transcript of their own decide what the sentences below say,
|
||||
// and whether the switch is offered at all. Old servers reported only the capability, when
|
||||
// Claude Code was its sole owner.
|
||||
val owned = targets.filter { it.keepsOwnTranscript }
|
||||
val transcriptOwner = owned.firstOrNull()?.ownTranscriptName ?: "Claude Code"
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmingDelete = null },
|
||||
title = { Text("Delete \"${session.title}\"?") },
|
||||
onDismissRequest = { confirmingDelete = emptyList() },
|
||||
title = {
|
||||
Text(
|
||||
if (targets.size == 1) "Delete \"${targets.first().title}\"?"
|
||||
else "Delete ${targets.size} sessions?"
|
||||
)
|
||||
},
|
||||
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, whether this app spawned the session or imported it;
|
||||
// separates them is whether the *driver* keeps its own record of the
|
||||
// conversation
|
||||
// -- the coding CLIs do, 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 false for every claude-cli session
|
||||
// started here has no copy anywhere". That was false for every coding-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
|
||||
// "this can't be undone", said of something that can, spends the credibility that
|
||||
// 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, 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.
|
||||
// Neither branch promises a restore. The recoverable one says what is known,
|
||||
// that 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.
|
||||
//
|
||||
// A selection takes the sentence that covers all of it: "some of these" is what
|
||||
// makes the mixed case true without either half of it being read as a promise
|
||||
// about every row.
|
||||
Column {
|
||||
Text(
|
||||
when {
|
||||
!session.keepsOwnTranscript ->
|
||||
owned.isEmpty() ->
|
||||
"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: "should still be
|
||||
// there to import again", left on screen beside a switch that removes
|
||||
// it, is the reassurance being read as 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 " +
|
||||
"this app's, and $transcriptOwner's own transcript on the " +
|
||||
"machine. Nothing keeps another, so this can't be undone."
|
||||
else ->
|
||||
"Stops the process and deletes this app's copy of the " +
|
||||
"conversation, including any images, peer messages and " +
|
||||
"commands recorded only here. Claude Code keeps its own " +
|
||||
"transcript on the machine, so the conversation itself " +
|
||||
"should still be there to import again."
|
||||
"commands recorded only here. $transcriptOwner keeps its own " +
|
||||
"transcript on the machine" +
|
||||
(if (owned.size < targets.size) " for some of these" else "") +
|
||||
", so the conversation itself should still be there to " +
|
||||
"import again."
|
||||
}
|
||||
)
|
||||
// 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.
|
||||
if (session.keepsOwnTranscript) {
|
||||
if (owned.isNotEmpty()) {
|
||||
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",
|
||||
"Delete $transcriptOwner's transcript too",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -214,38 +420,8 @@ fun SessionListScreen(
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
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.
|
||||
deleting = deleting + session.id
|
||||
deleteErrors = deleteErrors - session.id
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteSession(settings, session.id, alsoDeleteForeign)
|
||||
// After it succeeded, not before: a refused delete leaves the
|
||||
// session exactly as it was, and its transcript with it.
|
||||
transcriptCache.session(session.id).purge()
|
||||
}
|
||||
// Only this row, and only what changed. Refetching the list 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 =
|
||||
LoadState.Loaded(
|
||||
loaded.value.filterNot { it.id == session.id }
|
||||
)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
// Kept, because it is still there: the server refused, so the
|
||||
// session it refused about is exactly as it was.
|
||||
deleteErrors =
|
||||
deleteErrors + (session.id to (e.message ?: "Delete failed"))
|
||||
} finally {
|
||||
deleting = deleting - session.id
|
||||
}
|
||||
}
|
||||
confirmingDelete = emptyList()
|
||||
deleteChosen(targets, alsoDeleteForeign)
|
||||
}
|
||||
) {
|
||||
// Coloured by consequence: this takes something away, and does so wherever it
|
||||
@@ -254,12 +430,54 @@ fun SessionListScreen(
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
|
||||
TextButton(onClick = { confirmingDelete = emptyList() }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ring of space inside a session's card, which is also what its handle leaves around itself.
|
||||
*/
|
||||
private val CARD_PADDING = 16.dp
|
||||
|
||||
/** The gap the spawn button keeps from the edges it floats over, and from the list above it. */
|
||||
private val BUTTON_RING = 24.dp
|
||||
|
||||
/**
|
||||
* What can be done to the sessions that are selected.
|
||||
*
|
||||
* Delete only, for now, which is the one thing this screen has ever done to a session from the list
|
||||
* rather than from inside it. The same bar as the import tab's, down to the wording of the count.
|
||||
*/
|
||||
@Composable
|
||||
private fun SessionSelectionBar(
|
||||
count: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 3.dp,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
"$count selected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDelete) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SessionCard(
|
||||
@@ -274,62 +492,118 @@ private fun SessionCard(
|
||||
* about a request that has not been answered yet.
|
||||
*/
|
||||
deleting: Boolean,
|
||||
onOpen: () -> Unit,
|
||||
/** Whether this row is one of the selection the bottom bar acts on. */
|
||||
picked: Boolean,
|
||||
/** The drag this row can be moved by, or null where the list is not in selection mode. */
|
||||
reorder: Reorder?,
|
||||
onClick: () -> Unit,
|
||||
onLongPress: () -> Unit,
|
||||
) {
|
||||
val held = reorder?.held == session.id
|
||||
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.
|
||||
Modifier.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
enabled = !deleting,
|
||||
onClick = onOpen,
|
||||
onLongClick = onLongPress,
|
||||
)
|
||||
colors =
|
||||
if (picked)
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
else CardDefaults.cardColors(),
|
||||
// Lifted while it is in hand, which is the one cue that says this row is being carried
|
||||
// rather than sitting where it belongs.
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = if (held) 8.dp else 0.dp),
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
// Drawn where the finger has taken it, above the rows it is passing over. Both
|
||||
// in the layer rather than in the layout, so nothing around it moves and the
|
||||
// list does not remeasure per frame of a drag.
|
||||
.zIndex(if (held) 1f else 0f)
|
||||
.graphicsLayer { translationY = reorder?.offsetOf(session.id) ?: 0f },
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(
|
||||
// Everything but the handle, which is what makes the two gestures separate
|
||||
// rather than competing: a press that lands on the handle never reaches this,
|
||||
// so holding it cannot select the row it is about to move. The card had the
|
||||
// click while the handle was the only thing inside it that did not want one,
|
||||
// and a hold on the handle then both selected the row and ate the drag.
|
||||
//
|
||||
// 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. Here rather than in
|
||||
// [BusyItem], which leaves gestures alone so the list still scrolls.
|
||||
Modifier.weight(1f)
|
||||
.combinedClickable(
|
||||
enabled = !deleting,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongPress,
|
||||
)
|
||||
.padding(CARD_PADDING)
|
||||
) {
|
||||
Text(
|
||||
session.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatusText(session.status)
|
||||
}
|
||||
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.
|
||||
listOfNotNull(
|
||||
session.setupName,
|
||||
session.provider,
|
||||
session.model?.let { modelLabel(it) },
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
session.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatusText(session.status)
|
||||
if (session.backgroundTasks > 0) {
|
||||
Text(
|
||||
backgroundTaskLabel(session.backgroundTasks),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
relativeTime(session.lastActivity),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
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.
|
||||
listOfNotNull(
|
||||
session.machineName,
|
||||
session.provider,
|
||||
session.model?.let { modelLabel(it) },
|
||||
)
|
||||
.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
relativeTime(session.lastActivity),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// The server's own words, unprefixed, the way every other failure is shown.
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// The server's own words, unprefixed, the way every other failure is shown.
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
// Inside the card, so what it moves is the thing it is drawn on. Nothing is held
|
||||
// open for it outside selection mode: the row is then the row it always was.
|
||||
if (reorder != null) {
|
||||
ReorderHandle(
|
||||
reorder,
|
||||
session.id,
|
||||
// Dimmed with the rest of the row while something is happening to it, since
|
||||
// a row on its way out is not one to rearrange -- see [BusyItem], whose
|
||||
// appearance this matches rather than repeating its dimming rule.
|
||||
// The mark lines up with the text on the other side of the card,
|
||||
// which means taking the square it is centred in off the gap: see
|
||||
// [HANDLE_MARGIN].
|
||||
Modifier.alpha(if (deleting) 0.4f else 1f)
|
||||
.padding(end = CARD_PADDING - HANDLE_MARGIN),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -339,18 +613,8 @@ private fun SessionCard(
|
||||
|
||||
@Composable
|
||||
fun StatusText(status: String) {
|
||||
val (label, color) =
|
||||
when (status) {
|
||||
"awaitingInput" -> "your turn" to awaitingColor
|
||||
"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.
|
||||
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val label = sessionStatusWord(status)
|
||||
val color = sessionStatusColour(status)
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,331 +0,0 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* What can be changed about one session, as opposed to about this app.
|
||||
*
|
||||
* Over the session rather than a step down from it: everything here is about the conversation
|
||||
* behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a
|
||||
* screen of its own until 2026-08-30, which put a page transition and a back stack around two
|
||||
* 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".
|
||||
*
|
||||
* 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(
|
||||
settings: ServerSettings,
|
||||
sessionId: String,
|
||||
/**
|
||||
* What the session is called now, as the screen behind this knows it -- see the rename below.
|
||||
*/
|
||||
title: String,
|
||||
onRenamed: (String) -> Unit,
|
||||
/**
|
||||
* What this phone is holding of the conversation, or null while that is being measured -- see
|
||||
* the Reload row below, which is what would discard it.
|
||||
*/
|
||||
cachedBytes: Long?,
|
||||
onReload: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
/**
|
||||
* Copies what this session costs to draw. Built by the session screen, because everything it
|
||||
* measures is that screen's own state.
|
||||
*/
|
||||
onCopyRenderReport: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by remember(sessionId) { mutableStateOf(title) }
|
||||
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. 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. 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) }
|
||||
var movingCwd by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(sessionId) {
|
||||
try {
|
||||
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
||||
notify = fresh.notify
|
||||
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.
|
||||
notifyError = e.message
|
||||
notify = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the session, which ends the process that is in the old directory.
|
||||
*
|
||||
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
|
||||
* process, and a stopped session is a state this app already has a word and a button for.
|
||||
*/
|
||||
fun moveCwd() {
|
||||
val chosen = typedCwd.trim()
|
||||
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
|
||||
movingCwd = true
|
||||
cwdError = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
|
||||
cwd = chosen
|
||||
} catch (e: ApiException) {
|
||||
// Where it happened: this field is the only thing on screen that knows a move was
|
||||
// asked for, and the reason is usually the path itself.
|
||||
cwdError = e.message
|
||||
} finally {
|
||||
movingCwd = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
fun setNotify(wanted: Boolean) {
|
||||
val was = notify
|
||||
notify = wanted
|
||||
notifyError = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) }
|
||||
} catch (e: ApiException) {
|
||||
notify = was
|
||||
notifyError = 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
|
||||
|
||||
fun save() {
|
||||
if (!changed || saving) return
|
||||
val chosen = name.trim()
|
||||
saving = true
|
||||
error = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
|
||||
onRenamed(chosen)
|
||||
} catch (e: ApiException) {
|
||||
// Reported here, where it happened, because this dialog is the only place that
|
||||
// knows a rename was attempted.
|
||||
error = e.message
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Session settings") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
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.
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { save() }),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Notifications", modifier = Modifier.weight(1f))
|
||||
if (notify == null && notifyError == null) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(16.dp).height(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Switch(
|
||||
checked = notify == true,
|
||||
onCheckedChange = { setNotify(it) },
|
||||
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.
|
||||
notifyError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
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.
|
||||
placeholder = { Text("wherever the session was started") },
|
||||
singleLine = true,
|
||||
enabled = cwd != null && !movingCwd,
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
|
||||
)
|
||||
TextButton(
|
||||
onClick = { moveCwd() },
|
||||
enabled =
|
||||
cwd != null &&
|
||||
!movingCwd &&
|
||||
typedCwd.trim().isNotEmpty() &&
|
||||
typedCwd.trim() != cwd,
|
||||
) {
|
||||
Text(if (movingCwd) "Moving..." else "Move")
|
||||
}
|
||||
}
|
||||
// The whole of what pressing Move does, where it is about to be pressed. A
|
||||
// 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.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
cwdError?.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.
|
||||
when {
|
||||
cachedBytes == null ->
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(16.dp).height(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
else ->
|
||||
Text(
|
||||
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
// Enabled whether or not anything is cached: "what I see disagrees with the
|
||||
// machine" is a state an empty cache can be in too, and a control that comes
|
||||
// and goes makes its own presence the signal.
|
||||
TextButton(onClick = onReload) { Text("Reload") }
|
||||
}
|
||||
// Captioned, unlike the controls above it, for the same reason Move is: what it
|
||||
// costs is not visible, and neither is the case it exists for.
|
||||
Text(
|
||||
"Reload throws away this phone's copy and fetches the transcript from the " +
|
||||
"server again. Use it when what is shown here disagrees with the file " +
|
||||
"on the machine.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// About this session, which is what everything in here is -- and it was on the
|
||||
// header until 2026-09-03, where the folder button now is. It copies rather than
|
||||
// opening anything, so it says so and then says it happened: a row that looks like
|
||||
// a control and gives no sign of having run is one people press twice.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Render timings", modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
||||
}
|
||||
}
|
||||
},
|
||||
// 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")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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
|
||||
|
||||
/**
|
||||
* What can be changed about one session, and about the provider serving it.
|
||||
*
|
||||
* A screen rather than a dialog, again, and for the reason the dialog was chosen in the first place
|
||||
* turned around: it has outgrown one. A Material dialog constrains its own height and scrolls
|
||||
* inside itself, so a form of a dozen settings is read through a letterbox that also covers the
|
||||
* conversation it is about -- and there is nowhere in it to put a second tab. Drawn over the
|
||||
* session rather than as a `Screen` of its own, so the session under it stays composed and its
|
||||
* stream keeps flowing; the back gesture closes it.
|
||||
*
|
||||
* **Two tabs, and the second is not a copy.** It is [ProviderScreen] -- the same composable the
|
||||
* machines tab opens, for this session's machine and provider. A session's settings and its
|
||||
* provider's are different things with different owners (one rides on a request, one decides how a
|
||||
* model is loaded for everybody), and this is the second way in rather than a second version of
|
||||
* them.
|
||||
*
|
||||
* The model and the permission mode are on the session's own bar as well, because those are changed
|
||||
* *while* reading a turn -- "not this model, try that one". They are here too because that bar is
|
||||
* one row shared with three actions: a long model name leaves the other picker a few pixels wide,
|
||||
* and this is where somebody goes looking for a setting anyway.
|
||||
*
|
||||
* 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 SessionSettingsScreen(
|
||||
settings: ServerSettings,
|
||||
sessionId: String,
|
||||
/** Which machine and provider the second tab is about. */
|
||||
machineId: String,
|
||||
provider: String,
|
||||
/**
|
||||
* What the session is called now, as the screen behind this knows it -- see the rename below.
|
||||
*/
|
||||
title: String,
|
||||
onRenamed: (String) -> Unit,
|
||||
/**
|
||||
* How hard the model thinks, or null for the CLI's own default.
|
||||
*
|
||||
* Owned by the screen behind this rather than held here, like [title]: this dialog is what
|
||||
* changes it, and a level kept only for as long as the dialog is open is the old one again the
|
||||
* next time it is opened.
|
||||
*
|
||||
* Not 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?,
|
||||
onEffortChanged: (String?) -> Unit,
|
||||
/** Whether a level does anything here; the row is left out entirely where it does not. */
|
||||
takesEffort: Boolean,
|
||||
/**
|
||||
* The settings that are one of a list -- the model and the permission mode.
|
||||
*
|
||||
* Owned by the screen behind this, like [title] and [effort]: it is what asked the machine what
|
||||
* the provider offers. Whichever of them this one has no answer for is not in the list, and
|
||||
* draws no row.
|
||||
*/
|
||||
choices: List<SessionChoice>,
|
||||
/**
|
||||
* The settings this session's provider takes, and what they are set to.
|
||||
*
|
||||
* Declared by the server rather than listed here -- see [ProviderParamFields]. Empty for a
|
||||
* provider with none, which draws no section at all.
|
||||
*/
|
||||
paramSpecs: List<ParamSpec>,
|
||||
params: Map<String, String>,
|
||||
onParamsChanged: (Map<String, String>) -> Unit,
|
||||
/**
|
||||
* What this phone is holding of the conversation, or null while that is being measured -- see
|
||||
* the Reload row below, which is what would discard it.
|
||||
*/
|
||||
cachedBytes: Long?,
|
||||
/**
|
||||
* How big the record on the server is, or null where it did not say. The other half of the pair
|
||||
* beside it: what the conversation costs there, against what this phone is holding of it.
|
||||
*/
|
||||
transcriptBytes: Long?,
|
||||
onReload: () -> Unit,
|
||||
/**
|
||||
* Opens the transcript file itself in the explorer. Null from a server that does not say where
|
||||
* it is, which draws no button rather than one that cannot work.
|
||||
*/
|
||||
onViewRaw: (() -> Unit)?,
|
||||
onDismiss: () -> Unit,
|
||||
/**
|
||||
* Copies what this session costs to draw. Built by the session screen, because everything it
|
||||
* measures is that screen's own state.
|
||||
*/
|
||||
onCopyRenderReport: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by remember(sessionId) { mutableStateOf(title) }
|
||||
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. 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) }
|
||||
// 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) }
|
||||
var movingCwd by remember { mutableStateOf(false) }
|
||||
// The two settings on this screen that end the session's process, held while the reader is
|
||||
// asked whether that is what they meant. Null is nobody being asked.
|
||||
var askedCwd by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
var askedEffort by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(sessionId) {
|
||||
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.
|
||||
notifyError = e.message
|
||||
notify = null
|
||||
resumeError = e.message
|
||||
autoResume = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the session, which ends the process that is in the old directory.
|
||||
*
|
||||
* Only ever reached through [RestartDialog], which is where what it costs is said -- see
|
||||
* `askToMove`.
|
||||
*/
|
||||
fun askToMove() {
|
||||
val chosen = typedCwd.trim()
|
||||
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
|
||||
askedCwd = chosen
|
||||
}
|
||||
|
||||
fun moveCwd() {
|
||||
val chosen = typedCwd.trim()
|
||||
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
|
||||
movingCwd = true
|
||||
cwdError = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
|
||||
cwd = chosen
|
||||
} catch (e: ApiException) {
|
||||
// Where it happened: this field is the only thing on screen that knows a move was
|
||||
// asked for, and the reason is usually the path itself.
|
||||
cwdError = e.message
|
||||
} finally {
|
||||
movingCwd = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses a thinking level, which ends the process the old level was launched with. Asked for
|
||||
* first, the same way a move is.
|
||||
*
|
||||
* 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 = effort
|
||||
onEffortChanged(chosen)
|
||||
effortError = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
|
||||
} catch (e: ApiException) {
|
||||
onEffortChanged(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.
|
||||
fun setNotify(wanted: Boolean) {
|
||||
val was = notify
|
||||
notify = wanted
|
||||
notifyError = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) }
|
||||
} catch (e: ApiException) {
|
||||
notify = was
|
||||
notifyError = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
fun save() {
|
||||
if (!changed || saving) return
|
||||
val chosen = name.trim()
|
||||
saving = true
|
||||
error = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
|
||||
onRenamed(chosen)
|
||||
} catch (e: ApiException) {
|
||||
// Reported here, where it happened, because this dialog is the only place that
|
||||
// knows a rename was attempted.
|
||||
error = e.message
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The platform's own way back out of a layer: without it, back falls through to whatever is
|
||||
// under this and closes the session -- which reads as a crash to somebody who meant to return
|
||||
// to what they were reading.
|
||||
BackHandler(onBack = onDismiss)
|
||||
var tab by remember(sessionId) { mutableIntStateOf(0) }
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
// The keyboard covers the lower half of a form of fields, and this is a screen rather
|
||||
// than a dialog now -- nothing else is going to move it out of the way.
|
||||
Column(Modifier.fillMaxSize().imePadding()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
) {
|
||||
GlyphButton(BACK_GLYPH, "Back", onDismiss)
|
||||
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
||||
Text(
|
||||
"Settings",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// 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.
|
||||
TextButton(onClick = { save() }, enabled = changed && !saving) {
|
||||
Text(if (saving) "Saving..." else "Save")
|
||||
}
|
||||
}
|
||||
// The same two-tab shape the main screen uses for its three, so a reader who has
|
||||
// learned one has learned the other.
|
||||
TabRow(selectedTabIndex = tab) {
|
||||
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Session") })
|
||||
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(provider) })
|
||||
}
|
||||
if (tab == 1) {
|
||||
// The machines tab's own screen, with its back control left off: this one has a
|
||||
// header of its own, and two ways out stacked above each other is a reader asking
|
||||
// which of them goes where.
|
||||
ProviderScreen(
|
||||
settings = settings,
|
||||
machineId = machineId,
|
||||
provider = provider,
|
||||
onBack = null,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 12.dp, bottom = 16.dp)
|
||||
) {
|
||||
LabelledField(
|
||||
label = "Name",
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
enabled = !saving,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
// 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.
|
||||
keyboardActions = KeyboardActions(onDone = { save() }),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Notifications", modifier = Modifier.weight(1f))
|
||||
if (notify == null && notifyError == null) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(16.dp).height(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Switch(
|
||||
checked = notify == true,
|
||||
onCheckedChange = { setNotify(it) },
|
||||
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.
|
||||
notifyError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
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.
|
||||
LabelledField(
|
||||
label = "Message to send",
|
||||
value = resumeMessage,
|
||||
onValueChange = { resumeMessage = it },
|
||||
// What an empty field means: the server's own word rather than a session
|
||||
// poked with nothing to read.
|
||||
hint = DEFAULT_RESUME_MESSAGE,
|
||||
enabled = autoResume == true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions =
|
||||
KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }),
|
||||
)
|
||||
// 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))
|
||||
// The button sits at the bottom of the row rather than centred on it: the field
|
||||
// beside it is a label above a box, and a control centred against the pair lands
|
||||
// beside the label rather than beside the thing it acts on.
|
||||
Row(
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
LabelledField(
|
||||
label = "Working directory",
|
||||
value = typedCwd,
|
||||
onValueChange = { typedCwd = it },
|
||||
// What the field cannot say by being empty: a session that was never
|
||||
// given one starts wherever its launcher does.
|
||||
hint = "wherever the session was started",
|
||||
enabled = cwd != null && !movingCwd,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { askToMove() }),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = { askToMove() },
|
||||
enabled =
|
||||
cwd != null &&
|
||||
!movingCwd &&
|
||||
typedCwd.trim().isNotEmpty() &&
|
||||
typedCwd.trim() != cwd,
|
||||
) {
|
||||
Text(if (movingCwd) "Moving..." else "Move")
|
||||
}
|
||||
}
|
||||
cwdError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
choices.forEach { choice ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PickerRow(choice.label, choice.current, choice.options, choice.onPick)
|
||||
}
|
||||
// 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))
|
||||
PickerRow(
|
||||
"Thinking",
|
||||
effort ?: 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.
|
||||
listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
||||
) { chosen ->
|
||||
askedEffort = chosen
|
||||
}
|
||||
effortError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (paramSpecs.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Model settings",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Edited here and saved by the screen behind this, which is what makes
|
||||
// typing in a text field affordable: the save is debounced, and a dialog
|
||||
// dismissed mid-edit would take an unsaved value with it.
|
||||
ProviderParamFields(
|
||||
specs = paramSpecs,
|
||||
values = params,
|
||||
onChange = onParamsChanged,
|
||||
// The same picker the model and permission rows above use: how hard a
|
||||
// session thinks is one kind of setting, whichever provider declares it.
|
||||
choices = ChoiceStyle.Picker,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Transcript", modifier = Modifier.weight(1f))
|
||||
// What the conversation costs on the server, and then what Reload would
|
||||
// discard here -- one line, so the two sizes read as a pair. A server that
|
||||
// did not measure its file leaves its half out rather than saying zero.
|
||||
val onServer = transcriptBytes?.let { humanSize(it) ?: "0 B" }
|
||||
// The unknown state is drawn rather than guessed: a spinner while the cache
|
||||
// is being measured, and words when there is nothing in it, because "nothing
|
||||
// cached" and "0 B" read as different claims.
|
||||
when {
|
||||
cachedBytes == null -> {
|
||||
onServer?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(16.dp).height(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val cached =
|
||||
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached"
|
||||
Text(
|
||||
onServer?.let { "$it · $cached" } ?: cached,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Both on a line of their own under what they act on, rather than crowded against
|
||||
// the size on the line above: two buttons and a measurement do not fit the width
|
||||
// of a phone, and the one that would lose is the number.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
// The record as it is on disk, for the question the drawn conversation cannot
|
||||
// answer -- which is most of what anybody opens this dialog to debug.
|
||||
onViewRaw?.let { TextButton(onClick = it) { Text("View raw") } }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// Enabled whether or not anything is cached: "what I see disagrees with the
|
||||
// machine" is a state an empty cache can be in too, and a control that comes
|
||||
// and goes makes its own presence the signal.
|
||||
TextButton(onClick = onReload) { Text("Reload") }
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// About this session, which is what everything in here is -- and it was on the
|
||||
// header until 2026-09-03, where the folder button now is. It copies rather than
|
||||
// opening anything, so it says so and then says it happened: a row that looks like
|
||||
// a control and gives no sign of having run is one people press twice.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Render timings", modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A directory is settled when the process is spawned, so moving means ending it. Said here
|
||||
// rather than under the field, which is the rule the whole screen follows -- see
|
||||
// [RestartDialog].
|
||||
askedCwd?.let { chosen ->
|
||||
RestartDialog(
|
||||
title = "Move to $chosen?",
|
||||
text =
|
||||
"This stops the session's process. It starts again in the new directory with " +
|
||||
"the next message, or with Start.",
|
||||
confirm = "Move",
|
||||
onConfirm = {
|
||||
askedCwd = null
|
||||
moveCwd()
|
||||
},
|
||||
onDismiss = { askedCwd = null },
|
||||
)
|
||||
}
|
||||
// The same cost for the same reason: the CLI reads the level when it launches, and has no
|
||||
// control request for changing one.
|
||||
askedEffort?.let { chosen ->
|
||||
RestartDialog(
|
||||
title = "Think $chosen?",
|
||||
text =
|
||||
"This stops the session's process. It starts again with the next message, or " +
|
||||
"with Start.",
|
||||
confirm = "Change",
|
||||
onConfirm = {
|
||||
askedEffort = null
|
||||
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
|
||||
},
|
||||
onDismiss = { askedEffort = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One session setting that is a choice from a list, as this dialog draws it.
|
||||
*
|
||||
* A shape rather than a pair of parameters each, because a provider may offer either of them, both
|
||||
* or neither, and they are otherwise the same control.
|
||||
*/
|
||||
data class SessionChoice(
|
||||
val label: String,
|
||||
val current: String,
|
||||
val options: List<String>,
|
||||
val onPick: (String) -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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"
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* What a session's status is called on screen, and what colour it is drawn in.
|
||||
*
|
||||
* One pair of functions rather than a branch on each screen that shows a status. There were two,
|
||||
* and the second silently fell short the moment the server grew a state: `waiting` arrived and the
|
||||
* session list learned the word and the colour while the session screen's status row printed the
|
||||
* wire's own word in the muted grey every quiet state uses. That comment already said the words
|
||||
* were "the session list's own"; this is what makes that true rather than a promise.
|
||||
*
|
||||
* A subagent's own three states are deliberately not here -- see `subagentStatusLabel`, which
|
||||
* collapses everything it does not recognise rather than passing it through, because a subagent has
|
||||
* fewer states than a session and reporting one it cannot have is worse than reporting none.
|
||||
*/
|
||||
fun sessionStatusWord(status: String, subagent: Boolean = false): String =
|
||||
when (status) {
|
||||
"idle" -> "idle"
|
||||
"running" -> "running"
|
||||
"compacting" -> "compacting"
|
||||
// Not "running": a model coming off disk is not a model answering, and the difference is
|
||||
// minutes. Said in its own word so a first message that waits is explained rather than
|
||||
// looking like a session that has stopped responding. See `SessionStatus::Loading`.
|
||||
//
|
||||
// "model" rather than "loading" alone, because there are two waits before an answer and
|
||||
// the reader is entitled to know which one they are in: this one happens once, and
|
||||
// "reading prompt" below happens on every turn.
|
||||
"loading" -> "loading model"
|
||||
// The model has the prompt and has not started answering. Its own word for the same
|
||||
// reason: a long conversation spends real time here, and reported as "running" it looked
|
||||
// like a model thinking. See `SessionStatus::Reading`.
|
||||
"reading" -> "reading prompt"
|
||||
// Its own word, because the state it is easily mistaken for means the opposite: "idle"
|
||||
// invites the reader to type something, and a waiting session is going to carry on without
|
||||
// them. See `SessionStatus::Waiting`.
|
||||
"waiting" -> "waiting"
|
||||
"awaitingInput" -> "your turn"
|
||||
// A subagent's process was always its parent's, so it had none of its own to merely stop.
|
||||
"exited" -> if (subagent) "finished" else "exited"
|
||||
// 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"
|
||||
// A state this build has never heard of, said as itself. The nearest word we do know would
|
||||
// read as a fact somebody established.
|
||||
else -> status
|
||||
}
|
||||
|
||||
fun backgroundTaskLabel(count: Int): String = "$count bg ${if (count == 1) "task" else "tasks"}"
|
||||
|
||||
/**
|
||||
* The colour that goes with [sessionStatusWord]: the accent is spent on the states that are about
|
||||
* to do something or want something, and every quiet one shares the muted colour.
|
||||
*
|
||||
* Stated beside whatever draws it rather than inherited -- a colour that carries meaning has to
|
||||
* carry its own contrast, since the surface under it will not change to rescue it.
|
||||
*/
|
||||
@Composable
|
||||
fun sessionStatusColour(status: String): Color =
|
||||
when (status) {
|
||||
"awaitingInput" -> awaitingColor
|
||||
"running" -> runningColor
|
||||
"compacting" -> commandColor
|
||||
// The same accent as the other states that are busy on their own account, because that is
|
||||
// what this is: something is happening and nothing is wanted from the reader.
|
||||
"loading",
|
||||
"reading" -> commandColor
|
||||
"waiting" -> waitingColor
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.Duration
|
||||
@@ -70,13 +72,22 @@ class UsageFeed(
|
||||
/** 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.machine, provider, session.model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,8 +127,8 @@ 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
|
||||
* passes windows it does not recognise straight through.
|
||||
* Taken over however many windows this session's provider returned rather than the three Claude
|
||||
* sends today -- the backend 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
|
||||
@@ -133,7 +144,7 @@ fun usageGlyphColour(usage: SessionUsage): Color =
|
||||
}
|
||||
|
||||
/**
|
||||
* The five-hour window for the machine this session runs on, under the session's own header.
|
||||
* The shortest usage window for the pool this session uses, under the session's own header.
|
||||
*
|
||||
* Here rather than only in the usage dialog because it is the number that decides whether to keep
|
||||
* going, and it was a screen away from the place that decision gets made. It reports on this
|
||||
@@ -150,17 +161,17 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
||||
// 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 moved
|
||||
// would sit at a stale figure for hours.
|
||||
var now by remember { mutableStateOf(OffsetDateTime.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(REFRESH_MS)
|
||||
now = OffsetDateTime.now()
|
||||
}
|
||||
}
|
||||
val now = rememberUsageNow()
|
||||
|
||||
// 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 machine 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
|
||||
}
|
||||
|
||||
@@ -171,24 +182,18 @@ 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
|
||||
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
|
||||
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
|
||||
// Both handled above, before the row exists at all.
|
||||
SessionUsage.NotMetered,
|
||||
SessionUsage.Waiting -> Unit
|
||||
is SessionUsage.Unavailable -> UsageNote("Usage unknown -- ${state.why}")
|
||||
is SessionUsage.Known -> {
|
||||
val window = state.windows.firstOrNull { it.kind == "session" }
|
||||
val window = shortestUsageWindow(state.windows)
|
||||
if (window == null) {
|
||||
UsageNote("5-hour usage unknown -- no five-hour window reported")
|
||||
UsageNote("Usage unknown -- no window duration was reported")
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
||||
// The same step at the same percentages as the dialog's bars: this is the
|
||||
// same measurement, and a reader who learned the colour there has to be
|
||||
// able to read it here without checking which screen they are on.
|
||||
color = quotaColor(window.percent),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
UsageProgressIndicator(window, now, Modifier.weight(1f))
|
||||
Text(
|
||||
fiveHourLabel(window, now),
|
||||
usageWindowLabel(window, now),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
@@ -199,6 +204,56 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A clock shared by each usage surface, advanced independently of changes to the quota. */
|
||||
@Composable
|
||||
internal fun rememberUsageNow(): OffsetDateTime {
|
||||
var now by remember { mutableStateOf(OffsetDateTime.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(REFRESH_MS)
|
||||
now = OffsetDateTime.now()
|
||||
}
|
||||
}
|
||||
return now
|
||||
}
|
||||
|
||||
/** The quota fill with a white tick showing how far the current time window has progressed. */
|
||||
@Composable
|
||||
internal fun UsageProgressIndicator(
|
||||
window: UsageWindow,
|
||||
now: OffsetDateTime,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val elapsed = usageWindowElapsedFraction(window, now)
|
||||
LinearProgressIndicator(
|
||||
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
||||
// The same step at the same percentages everywhere: this is the same measurement, and a
|
||||
// reader who learned the colour on one surface should not have to relearn it on another.
|
||||
color = quotaColor(window.percent),
|
||||
modifier =
|
||||
modifier.drawWithContent {
|
||||
drawContent()
|
||||
elapsed?.let { fraction ->
|
||||
drawLine(
|
||||
color = Color.White,
|
||||
start = Offset(size.width * fraction, 0f),
|
||||
end = Offset(size.width * fraction, size.height),
|
||||
strokeWidth = 2.dp.toPx(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Elapsed time divided by the reported window duration, or null when either value is unknown. */
|
||||
internal fun usageWindowElapsedFraction(window: UsageWindow, now: OffsetDateTime): Float? {
|
||||
val durationMinutes = window.durationMinutes?.takeIf { it > 0 } ?: return null
|
||||
val end = windowEnd(window.resetsAt, now) as? WindowEnd.Ends ?: return null
|
||||
val remainingMinutes =
|
||||
end.until.seconds.toDouble() / 60.0 + end.until.nano.toDouble() / 60_000_000_000.0
|
||||
return (1.0 - remainingMinutes / durationMinutes).coerceIn(0.0, 1.0).toFloat()
|
||||
}
|
||||
|
||||
/** Anything this row says instead of drawing a bar, so all of them look the same. */
|
||||
@Composable
|
||||
private fun UsageNote(text: String) {
|
||||
@@ -210,42 +265,103 @@ private fun UsageNote(text: String) {
|
||||
}
|
||||
|
||||
/**
|
||||
* "42% -- 2h 15m left": how much is gone, then how long what is left has to last.
|
||||
* "42% -- 2h 15m left / 5h": how much is gone, then how long what is left has to last, then how
|
||||
* long the whole window is.
|
||||
*
|
||||
* 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 *length* is what the provider's own name for it used to carry ("5-hour window"), and
|
||||
* it is worth more beside the time left than in front of the percentage: "3h 42m left / 5h" says in
|
||||
* one reading both how much of the cycle is to come and which cycle this is. Where the provider
|
||||
* reported no duration there is simply nothing after the span -- the name it gave is not a
|
||||
* measurement of one, so nothing is inferred from it.
|
||||
*
|
||||
* 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 {
|
||||
private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String {
|
||||
val percent = "${window.percent.toInt()}%"
|
||||
val outOf =
|
||||
window.durationMinutes?.takeIf { it > 0 }?.let { " / ${formatMillis(it * 60_000)}" } ?: ""
|
||||
return when (val end = windowEnd(window.resetsAt, now)) {
|
||||
// Between blocks the five-hour window has no reset time, and saying so is a fact about
|
||||
// nothing: there is no window to run out. The percentage is the whole answer.
|
||||
// Between blocks a window can have no reset time, and saying so is a fact about nothing:
|
||||
// there is no window to run out. The percentage is the whole answer.
|
||||
WindowEnd.NotRunning -> percent
|
||||
WindowEnd.Unreadable -> "$percent · reset time unreadable"
|
||||
is WindowEnd.Ends ->
|
||||
// Under a minute, including past the end: the number would round to "0m left", which
|
||||
// reads as a measurement rather than as the window having run out.
|
||||
if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon"
|
||||
else "$percent · ${formatSpan(end.until)} left"
|
||||
else "$percent · ${formatSpan(end.until)} left$outOf"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One machine's snapshot, out of every machine's.
|
||||
* One meter's snapshot, out of every machine's: [machine]'s row for [provider].
|
||||
*
|
||||
* 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>,
|
||||
machine: String,
|
||||
provider: String,
|
||||
model: 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 pools = usageSnapshotsFor(snapshots, machine, provider)
|
||||
if (pools.isEmpty()) return SessionUsage.NotMetered
|
||||
val mine =
|
||||
usagePoolFor(pools, model)
|
||||
?: return SessionUsage.Unavailable("couldn't tell which usage pool this session uses")
|
||||
if (mine.state != "ok") {
|
||||
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
||||
val why =
|
||||
mine.detail
|
||||
?: when (mine.state) {
|
||||
"notLoggedIn" -> "no Claude account is signed in on this machine"
|
||||
"authenticating" -> "Claude sign-in is in progress"
|
||||
"loginRequired" -> "Claude sign-in is required"
|
||||
else -> mine.state
|
||||
}
|
||||
return SessionUsage.Unavailable(why)
|
||||
}
|
||||
return SessionUsage.Known(mine.windows)
|
||||
}
|
||||
|
||||
/** Every billing pool reported for one provider on one machine. */
|
||||
internal fun usageSnapshotsFor(
|
||||
snapshots: List<UsageSnapshot>,
|
||||
machine: String,
|
||||
provider: String?,
|
||||
): List<UsageSnapshot> =
|
||||
if (provider == null) emptyList()
|
||||
else snapshots.filter { it.machine == machine && it.provider == provider }
|
||||
|
||||
/** The pool an explicit model names, or the provider's generic pool for every other model. */
|
||||
internal fun usagePoolFor(pools: List<UsageSnapshot>, model: String?): UsageSnapshot? {
|
||||
if (pools.size == 1) return pools.first()
|
||||
val normalizedModel = model?.normalizedPoolName()
|
||||
val named = normalizedModel?.let { wanted ->
|
||||
pools.firstOrNull { pool ->
|
||||
val name = pool.limitName?.normalizedPoolName()
|
||||
name == wanted || (wanted.contains("luna") && name == "gptreserve")
|
||||
}
|
||||
}
|
||||
return named ?: pools.firstOrNull { it.limitId == "codex" }
|
||||
}
|
||||
|
||||
/** The shortest cycle the selected pool actually reported. */
|
||||
internal fun shortestUsageWindow(windows: List<UsageWindow>): UsageWindow? =
|
||||
windows
|
||||
.mapNotNull { window -> window.durationMinutes?.let { duration -> duration to window } }
|
||||
.minByOrNull { it.first }
|
||||
?.second
|
||||
|
||||
private fun String.normalizedPoolName(): String = lowercase().filter(Char::isLetterOrDigit)
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -125,28 +124,14 @@ fun SettingsScreen(
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = { Text("Host") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
LabelledField(label = "Host", value = host, onValueChange = { host = it })
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { port = it },
|
||||
label = { Text("Port") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
LabelledField(label = "Port", value = port, onValueChange = { port = it })
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
LabelledField(
|
||||
label = if (existing != null) "Token (unchanged if left blank)" else "Token",
|
||||
value = token,
|
||||
onValueChange = { token = it },
|
||||
label = { Text(if (existing != null) "Token (unchanged if left blank)" else "Token") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val OPEN_THRESHOLD = 0.35f
|
||||
private val FLING_THRESHOLD = 400.dp
|
||||
|
||||
/**
|
||||
* How much of the screen a panel takes by default, leaving a sliver of what it is over.
|
||||
*
|
||||
* A panel given the whole width instead is standing in for the screen rather than sitting over it,
|
||||
* and then the sliver would be a strip of a screen the reader has just left behind.
|
||||
*/
|
||||
const val PANEL_FRACTION = 0.88f
|
||||
|
||||
/** How dark the scrim over [SidePanels]' content goes with a panel fully open. */
|
||||
private const val SCRIM_ALPHA = 0.32f
|
||||
|
||||
/**
|
||||
* Which side of the content a panel comes in from: where it sits, and which way it slides out.
|
||||
*
|
||||
* [sign] is also the direction of the reveal this side owns, so the drag arithmetic is written once
|
||||
* rather than once per side with the minus signs moved around.
|
||||
*/
|
||||
enum class PanelSide(val alignment: Alignment, val sign: Float) {
|
||||
Left(Alignment.CenterStart, -1f),
|
||||
Right(Alignment.CenterEnd, 1f),
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps [content] composed while a panel belonging to it moves over from the left or the right.
|
||||
*
|
||||
* One gesture drives both sides rather than one handler each, because two `draggable`s over the
|
||||
* same content cannot share a horizontal drag: the inner one claims it whichever way the finger
|
||||
* went, and the outer never sees a thing. So the position is a single signed reveal -- negative is
|
||||
* the left panel showing, positive the right -- which also makes it impossible to have both open.
|
||||
*
|
||||
* A side left null has no panel and no gesture toward it -- the reveal cannot travel that way at
|
||||
* all -- so one composable serves a screen with one panel and a screen with two.
|
||||
*
|
||||
* The root drag handler deliberately sits behind descendants. A horizontal scroller consumes its
|
||||
* drag first, so code blocks, attachments and tool inputs keep their existing gesture. Collapsing
|
||||
* that content, or starting over any ordinary part of the session, gives the gesture back to the
|
||||
* panel; Android's own right-edge Back gesture remains untouched.
|
||||
*/
|
||||
@Composable
|
||||
fun SidePanels(
|
||||
left: (@Composable (active: Boolean, close: () -> Unit) -> Unit)? = null,
|
||||
leftFraction: Float = PANEL_FRACTION,
|
||||
right: (@Composable (active: Boolean, close: () -> Unit) -> Unit)? = null,
|
||||
rightFraction: Float = PANEL_FRACTION,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
// Which panel the gesture settled on, null for neither. The *settled* side rather than the
|
||||
// current position, so a panel's contents know they are being looked at while the animation
|
||||
// is still running.
|
||||
var opened by remember { mutableStateOf<PanelSide?>(null) }
|
||||
var dragging by remember { mutableStateOf(false) }
|
||||
var draggedReveal by remember { mutableFloatStateOf(0f) }
|
||||
val animatedReveal = remember { Animatable(0f) }
|
||||
// Read from a draw or layout lambda, never from the composable body: where the panel has got
|
||||
// to changes every frame of a drag, and a body that reads it recomposes this whole subtree --
|
||||
// the session included -- once per frame. The booleans below are what composition is allowed
|
||||
// to know, and each of them changes twice per gesture. (Same rule as the keyboard inset in
|
||||
// SessionScreen, and found the same way.)
|
||||
fun revealNow() = if (dragging) draggedReveal else animatedReveal.value
|
||||
val leftShown by remember { derivedStateOf { revealNow() < 0f } }
|
||||
val rightShown by remember { derivedStateOf { revealNow() > 0f } }
|
||||
val engaged = leftShown || rightShown
|
||||
val flingThreshold = with(LocalDensity.current) { FLING_THRESHOLD.toPx() }
|
||||
|
||||
suspend fun startDrag() {
|
||||
animatedReveal.stop()
|
||||
draggedReveal = animatedReveal.value
|
||||
dragging = true
|
||||
}
|
||||
|
||||
// Which panel the reveal belongs to, [bias] breaking the tie at rest -- a drag away from
|
||||
// nothing is toward whichever panel that direction opens.
|
||||
fun sideOf(bias: Float): PanelSide? =
|
||||
when {
|
||||
draggedReveal < 0f -> PanelSide.Left
|
||||
draggedReveal > 0f -> PanelSide.Right
|
||||
bias > 0f -> PanelSide.Left
|
||||
bias < 0f -> PanelSide.Right
|
||||
else -> null
|
||||
}
|
||||
|
||||
suspend fun finishDrag(velocity: Float) {
|
||||
val side = sideOf(0f)
|
||||
// How fast the finger is moving toward that side's open position: the left panel opens
|
||||
// rightwards and the right panel leftwards, so the sign of a velocity only means something
|
||||
// once it is read against the side. A fling decides on its own; anything slower is decided
|
||||
// by how far in the panel already is.
|
||||
val toward = side?.let { -it.sign * velocity } ?: 0f
|
||||
val opens =
|
||||
if (toward.absoluteValue > flingThreshold) toward > 0f
|
||||
else draggedReveal.absoluteValue >= OPEN_THRESHOLD
|
||||
val target = side.takeIf { opens }
|
||||
opened = target
|
||||
animatedReveal.snapTo(draggedReveal)
|
||||
dragging = false
|
||||
animatedReveal.animateTo(target?.sign ?: 0f)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
opened = null
|
||||
scope.launch { animatedReveal.animateTo(0f) }
|
||||
}
|
||||
|
||||
BackHandler(enabled = opened != null) { close() }
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val dragState = rememberDraggableState { delta ->
|
||||
// Against the width of the panel this drag is moving, since the reveal is a fraction
|
||||
// of it and the two sides need not be the same width.
|
||||
val width =
|
||||
sideOf(delta)?.let {
|
||||
constraints.maxWidth * if (it == PanelSide.Left) leftFraction else rightFraction
|
||||
} ?: return@rememberDraggableState
|
||||
draggedReveal =
|
||||
(draggedReveal - delta / width.coerceAtLeast(1f)).coerceIn(
|
||||
if (left == null) 0f else -1f,
|
||||
if (right == null) 0f else 1f,
|
||||
)
|
||||
}
|
||||
val drag =
|
||||
Modifier.draggable(
|
||||
state = dragState,
|
||||
orientation = Orientation.Horizontal,
|
||||
onDragStarted = { startDrag() },
|
||||
onDragStopped = { velocity -> finishDrag(velocity) },
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
.then(drag)
|
||||
.then(if (engaged) Modifier.clearAndSetSemantics {} else Modifier)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
if (engaged) {
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
.graphicsLayer { alpha = revealNow().absoluteValue * SCRIM_ALPHA }
|
||||
.background(MaterialTheme.colorScheme.scrim)
|
||||
.semantics { contentDescription = "Dismiss panel" }
|
||||
.clickable { close() }
|
||||
)
|
||||
}
|
||||
|
||||
// Both panels stay composed while they are off screen, so opening one costs no
|
||||
// composition -- but an off-screen panel is cleared from the semantics tree, since nothing
|
||||
// a reader cannot see should be reachable by swiping through the screen.
|
||||
left?.let { panel ->
|
||||
SlidingPanel(
|
||||
side = PanelSide.Left,
|
||||
width = maxWidth * leftFraction,
|
||||
raised = leftFraction < 1f,
|
||||
shown = { (-revealNow()).coerceAtLeast(0f) },
|
||||
visible = leftShown,
|
||||
drag = drag,
|
||||
) {
|
||||
panel(opened == PanelSide.Left, ::close)
|
||||
}
|
||||
}
|
||||
right?.let { panel ->
|
||||
SlidingPanel(
|
||||
side = PanelSide.Right,
|
||||
width = maxWidth * rightFraction,
|
||||
raised = rightFraction < 1f,
|
||||
shown = { revealNow().coerceAtLeast(0f) },
|
||||
visible = rightShown,
|
||||
drag = drag,
|
||||
) {
|
||||
panel(opened == PanelSide.Right, ::close)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One panel at [shown] of the way in, sliding out to its own [side].
|
||||
*
|
||||
* [raised] is for a panel with some of the screen still beside it, which takes a tonal step to say
|
||||
* it is above what it has not covered. A panel covering the whole width has nothing to be above,
|
||||
* and a step there is a screen that is simply the wrong colour.
|
||||
*
|
||||
* [visible] says the same thing as `shown() > 0f` and is the form composition may read; see
|
||||
* [SidePanels].
|
||||
*/
|
||||
@Composable
|
||||
private fun BoxScope.SlidingPanel(
|
||||
side: PanelSide,
|
||||
width: Dp,
|
||||
raised: Boolean,
|
||||
shown: () -> Float,
|
||||
visible: Boolean,
|
||||
drag: Modifier,
|
||||
contents: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
tonalElevation = if (raised) 3.dp else 0.dp,
|
||||
shadowElevation = 8.dp,
|
||||
modifier =
|
||||
Modifier.align(side.alignment)
|
||||
.width(width)
|
||||
.fillMaxHeight()
|
||||
.graphicsLayer { translationX = side.sign * size.width * (1f - shown()) }
|
||||
.then(if (visible) Modifier else Modifier.clearAndSetSemantics {})
|
||||
.then(drag),
|
||||
) {
|
||||
contents()
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -48,44 +47,52 @@ fun SpawnScreen(
|
||||
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.
|
||||
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
||||
var options by remember { mutableStateOf<LoadState<List<Machine>>>(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
|
||||
// Machine first, then one of its providers. Choosing a machine can invalidate the provider, so
|
||||
// the
|
||||
// provider is stored by name and resolved against the current machine rather than held as an
|
||||
// object that could outlive the list it came from.
|
||||
var setupName by remember { mutableStateOf<String?>(null) }
|
||||
var machineName by remember { mutableStateOf<String?>(null) }
|
||||
var providerName by remember { mutableStateOf<String?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var model by remember { mutableStateOf("") }
|
||||
var providerModels by remember { mutableStateOf<List<OfferedModel>>(emptyList()) }
|
||||
var providerModelsLoading by remember { mutableStateOf(false) }
|
||||
var providerModelsError by remember { mutableStateOf<String?>(null) }
|
||||
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.
|
||||
var permissionMode by remember { mutableStateOf("auto") }
|
||||
// Set only after the selected provider reports its own default. An empty value is not sent.
|
||||
var permissionMode by remember { mutableStateOf("") }
|
||||
// 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.
|
||||
var spawnError by remember { mutableStateOf<String?>(null) }
|
||||
// Downloaded models, 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.
|
||||
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("") }
|
||||
// Whatever the chosen provider says it takes, by key. Empty until something is typed: an
|
||||
// absent key means the server's own default, which is what every field's placeholder says.
|
||||
var params by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Separate from the machines 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) }
|
||||
val fetched = withContext(Dispatchers.IO) { fetchMachines(settings) }
|
||||
val first = fetched.firstOrNull()
|
||||
setupName = first?.name
|
||||
machineName = first?.name
|
||||
providerName = first?.providers?.firstOrNull()?.name
|
||||
LoadState.Loaded(fetched)
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
models =
|
||||
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||
@@ -102,7 +109,7 @@ fun SpawnScreen(
|
||||
// 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 =
|
||||
val machines =
|
||||
when (val state = options) {
|
||||
is LoadState.Loading -> {
|
||||
CircularProgressIndicator()
|
||||
@@ -114,51 +121,88 @@ fun SpawnScreen(
|
||||
}
|
||||
is LoadState.Loaded -> state.value
|
||||
}
|
||||
val setup = setups.firstOrNull { it.name == setupName }
|
||||
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
|
||||
val machine = machines.firstOrNull { it.name == machineName }
|
||||
val current = machine?.providers?.firstOrNull { it.name == providerName }
|
||||
// Coding CLIs take a working directory, model, permission mode and thinking level. Keying
|
||||
// the extra fields on the kind rather than the provider name keeps a second installation
|
||||
// from needing anything here.
|
||||
val isClaude = current?.kind == "claude_cli"
|
||||
val isCodex = current?.kind == "codex_cli"
|
||||
val isCodingCli = isClaude || isCodex
|
||||
val isLlama = current?.kind == "llama_cpp"
|
||||
// Echo is the only kind with nothing to choose between.
|
||||
val offersModels = isCodingCli || isLlama
|
||||
// Where a session's tools act, which is the only thing a working directory decides.
|
||||
val takesCwd = isCodingCli || isLlama
|
||||
|
||||
// Whichever machine and provider are chosen now, asked again when either changes. The
|
||||
// previous answer is dropped first rather than left on screen: a model name from another
|
||||
// machine looks exactly like one from this one.
|
||||
LaunchedEffect(machine?.id, current?.name) {
|
||||
model = ""
|
||||
// A key from the previous provider would be a setting this one does not have, drawn
|
||||
// by no control and sent at the spawn anyway.
|
||||
params = emptyMap()
|
||||
providerModels = emptyList()
|
||||
providerModelsError = null
|
||||
permissionMode = current?.defaultPermissionMode.orEmpty()
|
||||
// Every kind that offers models at all, not only the coding CLIs: a llama provider
|
||||
// answers with the GGUFs on the machine it runs on, through the same call. One
|
||||
// question with one answer is what keeps the picker free of a branch on the kind.
|
||||
if (machine == null || current == null || !offersModels) {
|
||||
providerModelsLoading = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
providerModelsLoading = true
|
||||
try {
|
||||
providerModels =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchProviderModels(settings, machine.id, current.name)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
providerModelsError = e.message
|
||||
} finally {
|
||||
providerModelsLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// The machine first, because it decides what can be run at all.
|
||||
ChipGroup(
|
||||
label = "Setup",
|
||||
options = setups.map { it.name },
|
||||
selected = setupName,
|
||||
label = "Machine",
|
||||
options = machines.map { it.name },
|
||||
selected = machineName,
|
||||
onSelect = { name ->
|
||||
setupName = name
|
||||
machineName = 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.
|
||||
providerName =
|
||||
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
|
||||
machines.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
|
||||
},
|
||||
)
|
||||
setup?.address?.let {
|
||||
machine?.address?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// The address belongs to the setup above it, not to the provider label below; without
|
||||
// The address belongs to the machine 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
|
||||
// Only what this machine actually has. A machine with none says so rather than showing an
|
||||
// empty row that reads as a failure.
|
||||
if (setup != null && setup.providers.isEmpty()) {
|
||||
if (machine != null && machine.providers.isEmpty()) {
|
||||
Text(
|
||||
"\"${setup.name}\" has no providers configured.",
|
||||
"\"${machine.name}\" has no providers configured.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
ChipGroup(
|
||||
label = "Provider",
|
||||
options = setup?.providers?.map { it.name }.orEmpty(),
|
||||
options = machine?.providers?.map { it.name }.orEmpty(),
|
||||
selected = providerName,
|
||||
onSelect = { providerName = it },
|
||||
)
|
||||
@@ -166,91 +210,116 @@ fun SpawnScreen(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
label = { Text("Title") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
LabelledField(label = "Title", value = title, onValueChange = { title = it })
|
||||
|
||||
if (isLlama) {
|
||||
// A llama session names one of the models this backend has downloaded, so the choice is
|
||||
// that list rather than free text -- a name that is not on disk is a session that
|
||||
// cannot start.
|
||||
if (models.isEmpty()) {
|
||||
Text(
|
||||
"No models downloaded yet. Get one from the Models screen first.",
|
||||
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.
|
||||
options = models.map { it.file },
|
||||
selected = models.firstOrNull { it.key == modelKey }?.file,
|
||||
onSelect = { file -> modelKey = models.first { it.file == file }.key },
|
||||
)
|
||||
if (offersModels) {
|
||||
when {
|
||||
providerModelsLoading ->
|
||||
Text(
|
||||
"Loading model choices…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
providerModelsError != null ->
|
||||
Text(
|
||||
"Model choices unavailable: $providerModelsError",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
// A llama session cannot start without one, so this says what to do about it
|
||||
// rather than only that there is nothing -- the models it needs are on the
|
||||
// machine that will serve them, which is not always this backend.
|
||||
providerModels.isEmpty() && isLlama ->
|
||||
Text(
|
||||
"No models on ${machine.name}. The Models screen downloads " +
|
||||
"to the backend; another machine needs the file put there itself.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
providerModels.isEmpty() ->
|
||||
Text(
|
||||
"This machine reported no selectable models.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
ChipGroup(
|
||||
label = "Model",
|
||||
// The label, and the id is what is sent: for a llama model those differ,
|
||||
// since it is chosen by path and named by what is inside the file.
|
||||
options = providerModels.map { it.label },
|
||||
selected = providerModels.firstOrNull { it.id == model }?.label,
|
||||
onSelect = { chosen ->
|
||||
val id = providerModels.first { it.label == chosen }.id
|
||||
// A llama session has to have one, so choosing the same chip twice
|
||||
// must not clear it -- there is nothing to fall back to.
|
||||
model = if (model == id && !isLlama) "" else id
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = contextSize,
|
||||
onValueChange = { contextSize = it },
|
||||
label = { Text("Context size (blank = the model's default)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = temperature,
|
||||
onValueChange = { temperature = it },
|
||||
label = { Text("Temperature (blank = llama.cpp's default)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
if (isCodingCli) {
|
||||
// Free text as well as the chips above: the catalog is a shortcut, and a CLI will
|
||||
// take a name it did not list.
|
||||
LabelledField(
|
||||
label = "Model",
|
||||
value = model,
|
||||
onValueChange = { model = it },
|
||||
hint = "the CLI's default",
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (isClaude) {
|
||||
if (current.models.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
ChipGroup(
|
||||
label = "Model",
|
||||
options = current.models,
|
||||
selected = model.ifEmpty { null },
|
||||
onSelect = { chosen -> model = if (model == chosen) "" else chosen },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = model,
|
||||
onValueChange = { model = it },
|
||||
label = { Text("Model (blank = the CLI's default)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// Nothing is running yet, so nothing here waits for a restart -- every one of these is
|
||||
// read by the process this form is about to start.
|
||||
ProviderParamFields(
|
||||
specs = current?.params.orEmpty(),
|
||||
values = params,
|
||||
onChange = { params = it },
|
||||
// Chips, like every other choice on this form.
|
||||
choices = ChoiceStyle.Chips,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
// Every session whose tools act on files needs one, which is both kinds that have
|
||||
// tools -- a llama session's built-in tools run in it exactly as a CLI's do.
|
||||
if (takesCwd) {
|
||||
LabelledField(
|
||||
label = "Working directory",
|
||||
value = cwd,
|
||||
onValueChange = { cwd = it },
|
||||
label = { Text("Working directory") },
|
||||
placeholder = { Text("/home/…") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
hint = "wherever the session's process starts",
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Offered wherever the provider has modes, rather than where this screen believes it
|
||||
// does: the server is what knows, and llama.cpp grew them without this line changing.
|
||||
if (current != null && current.permissionModes.isNotEmpty()) {
|
||||
ChipGroup(
|
||||
label = "Permissions",
|
||||
options = PERMISSION_MODES,
|
||||
options = current.permissionModes,
|
||||
selected = permissionMode,
|
||||
onSelect = { permissionMode = it },
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (isCodingCli) {
|
||||
// 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))
|
||||
|
||||
@@ -268,33 +337,29 @@ 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 (isCodingCli) {
|
||||
runCatching { setDefaultEffort(settings, effort) }
|
||||
}
|
||||
spawnSession(
|
||||
settings,
|
||||
// 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,
|
||||
// `machine`'s own provider list.
|
||||
machine = machine.id,
|
||||
provider = chosen.name,
|
||||
title = title.trim(),
|
||||
model =
|
||||
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.
|
||||
params =
|
||||
buildMap {
|
||||
if (isLlama) {
|
||||
contextSize
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { put("contextSize", it) }
|
||||
temperature
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { put("temperature", it) }
|
||||
}
|
||||
},
|
||||
model = model.trim().takeIf { offersModels },
|
||||
cwd = cwd.trim().takeIf { takesCwd },
|
||||
permissionMode = permissionMode.takeIf { it.isNotEmpty() },
|
||||
effort = effort.takeIf { isCodingCli },
|
||||
// Already only the keys somebody set: a field left blank
|
||||
// removes its key rather than sending an empty value, so
|
||||
// "blank" reaches the server as "your default".
|
||||
params = params,
|
||||
)
|
||||
}
|
||||
onSpawned(spawned)
|
||||
@@ -304,7 +369,8 @@ fun SpawnScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !busy && current != null && !(isLlama && modelKey == null),
|
||||
// A llama session names the file to load, so there is nothing to spawn without one.
|
||||
enabled = !busy && current != null && !(isLlama && model.isEmpty()),
|
||||
) {
|
||||
Text(if (busy) "Spawning..." else "Spawn")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* What a session has running beside the turn you are reading: its background tasks, then its
|
||||
* subagents, in the panel [SidePanels] slides over it from the right.
|
||||
*
|
||||
* [active] is whether the panel is being looked at: the lists are fetched then rather than on
|
||||
* composition, since the panel is composed for every session whether or not anybody opens it.
|
||||
*
|
||||
* [onOpenCall] takes the reader to where a background task was started, in the transcript under
|
||||
* this panel -- so the panel is closed with it, which is the caller's to do.
|
||||
*
|
||||
* [backgroundTasks] is the live count from the session's own event stream, and is what the
|
||||
* background list is refetched against: a card for work that has since finished is a stale
|
||||
* measurement drawn as a current one, which is the one thing a list of what is running now must not
|
||||
* do.
|
||||
*
|
||||
* Both lists are items of one lazy column rather than two stacked scrollers, so expanding the
|
||||
* background section pushes the subagents down without either being able to run off the panel.
|
||||
*/
|
||||
@Composable
|
||||
fun SubagentPanel(
|
||||
settings: ServerSettings,
|
||||
summary: SessionSummary,
|
||||
active: Boolean,
|
||||
backgroundTasks: Int,
|
||||
onOpenSubagent: (SubagentSummary) -> Unit,
|
||||
onOpenCall: (CallSite) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
||||
var rows by
|
||||
remember(summary.id) { mutableStateOf<LoadState<List<SubagentSummary>>>(LoadState.Loading) }
|
||||
var selected by remember(summary.id) { mutableStateOf(setOf<String>()) }
|
||||
var deleting by remember(summary.id) { mutableStateOf(setOf<String>()) }
|
||||
var deleteError by remember(summary.id) { mutableStateOf<String?>(null) }
|
||||
var confirming by remember(summary.id) { mutableStateOf<List<SubagentSummary>?>(null) }
|
||||
var refreshToken by remember(summary.id) { mutableIntStateOf(0) }
|
||||
var background by
|
||||
remember(summary.id) {
|
||||
mutableStateOf<LoadState<List<BackgroundTaskSummary>?>>(LoadState.Loading)
|
||||
}
|
||||
var backgroundExpanded by remember(summary.id) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(active, refreshToken) {
|
||||
if (!active) return@LaunchedEffect
|
||||
rows = LoadState.Loading
|
||||
rows =
|
||||
try {
|
||||
val fetched = withContext(Dispatchers.IO) { fetchSubagents(settings, summary.id) }
|
||||
selected = selected intersect fetched.mapTo(mutableSetOf()) { it.id }
|
||||
LoadState.Loaded(fetched)
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
// No reset to Loading on a refetch: the spinner belongs to the first fetch, and one flashed
|
||||
// over the list at every start and end would blink precisely when something happened.
|
||||
LaunchedEffect(active, backgroundTasks, refreshToken) {
|
||||
if (!active || backgroundTasks == 0) return@LaunchedEffect
|
||||
background =
|
||||
try {
|
||||
LoadState.Loaded(
|
||||
withContext(Dispatchers.IO) { fetchBackgroundTasks(settings, summary.id) }
|
||||
)
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
|
||||
|
||||
val ordered = (rows as? LoadState.Loaded)?.value?.let(::subagentOrder)
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
|
||||
) {
|
||||
backgroundTaskSection(
|
||||
count = backgroundTasks,
|
||||
tasks = background,
|
||||
expanded = backgroundExpanded,
|
||||
onToggle = { backgroundExpanded = !backgroundExpanded },
|
||||
onRetry = { refreshToken++ },
|
||||
onOpenCall = onOpenCall,
|
||||
)
|
||||
item(key = "subagents-heading") { PanelSectionHeading("Subagents") }
|
||||
when (val state = rows) {
|
||||
is LoadState.Loading ->
|
||||
item(key = "subagents-loading") {
|
||||
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
|
||||
}
|
||||
is LoadState.Error ->
|
||||
item(key = "subagents-error") {
|
||||
Column {
|
||||
Text(
|
||||
state.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
TextButton(onClick = { refreshToken++ }) { Text("Try again") }
|
||||
}
|
||||
}
|
||||
is LoadState.Loaded ->
|
||||
if (ordered.isNullOrEmpty()) {
|
||||
item(key = "subagents-empty") {
|
||||
Text(
|
||||
"No subagents in this session.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
uniqueItems(ordered, key = { it.id }) { subagent ->
|
||||
SubagentCard(
|
||||
subagent = subagent,
|
||||
selected = subagent.id in selected,
|
||||
selecting = selected.isNotEmpty(),
|
||||
deleting = subagent.id in deleting,
|
||||
onClick = { onOpenSubagent(subagent) },
|
||||
onSelect = {
|
||||
selected =
|
||||
if (subagent.id in selected) selected - subagent.id
|
||||
else selected + subagent.id
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.isNotEmpty()) {
|
||||
val picked = ordered.orEmpty().filter { it.id in selected }
|
||||
SubagentSelectionBar(
|
||||
picked = picked,
|
||||
onDelete = { confirming = picked },
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
deleteError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
confirming?.let { picked ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirming = null },
|
||||
title = {
|
||||
Text(
|
||||
if (picked.size == 1) "Delete this subagent?"
|
||||
else "Delete ${picked.size} subagents?"
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
(if (picked.size == 1) "\"${picked.first().title}\"\n\n" else "") +
|
||||
"A subagent's transcript is the only record of what it did: the session " +
|
||||
"that started it kept just the Task call. Nothing else has a copy, so " +
|
||||
"this can't be undone. The session itself is untouched."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirming = null
|
||||
selected = emptySet()
|
||||
val ids = picked.map { it.id }
|
||||
val gone = ids.toSet()
|
||||
deleting += gone
|
||||
deleteError = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteSubagents(settings, summary.id, ids)
|
||||
gone.forEach {
|
||||
transcriptCache
|
||||
.session(TranscriptAddress(summary.id, it))
|
||||
.purge()
|
||||
}
|
||||
}
|
||||
val loaded = rows
|
||||
if (loaded is LoadState.Loaded) {
|
||||
rows =
|
||||
LoadState.Loaded(loaded.value.filterNot { it.id in gone })
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
deleteError = e.message ?: "Delete failed"
|
||||
} finally {
|
||||
deleting -= gone
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun subagentOrder(rows: List<SubagentSummary>): List<SubagentSummary> =
|
||||
rows.sortedWith(
|
||||
compareByDescending<SubagentSummary> { it.status == "running" }
|
||||
.thenByDescending { it.lastActivity }
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SubagentCard(
|
||||
subagent: SubagentSummary,
|
||||
selected: Boolean,
|
||||
selecting: Boolean,
|
||||
deleting: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onSelect: () -> Unit,
|
||||
) {
|
||||
BusyItem(label = if (deleting) "deleting" else null) {
|
||||
OutlinedCard(
|
||||
colors =
|
||||
if (selected)
|
||||
CardDefaults.outlinedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
else CardDefaults.outlinedCardColors(),
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
enabled = !deleting,
|
||||
onClick = { if (selecting) onSelect() else onClick() },
|
||||
onLongClick = onSelect,
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubagentSelectionBar(
|
||||
picked: List<SubagentSummary>,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val running = picked.count { it.status == "running" }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.fillMaxWidth().heightIn(min = 48.dp),
|
||||
) {
|
||||
Text(
|
||||
if (running == 0) "${picked.size} selected" else "$running still running",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDelete, enabled = running == 0) {
|
||||
Text(
|
||||
"Delete",
|
||||
color =
|
||||
if (running == 0) MaterialTheme.colorScheme.error
|
||||
else LocalContentColor.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subagentStatusLabel(status: String) =
|
||||
when (status) {
|
||||
"running" -> "running"
|
||||
"exited" -> "finished"
|
||||
else -> "unknown"
|
||||
}
|
||||
@@ -134,6 +134,20 @@ val clearedColor: Color
|
||||
val awaitingColor: Color
|
||||
@Composable get() = Mocha.Peach
|
||||
|
||||
/**
|
||||
* Waiting on itself: the session's turn is over, but a subagent or a backgrounded command it
|
||||
* started is still going, and it will speak again with nobody having typed anything.
|
||||
*
|
||||
* Its own colour rather than [awaitingColor], which is the opposite state -- that one means the
|
||||
* reader has something to do, and this one means they specifically do not. Not [runningColor]
|
||||
* either: nothing is being written, and a green "running" on a session that will say nothing for
|
||||
* ten minutes is the wrong promise. Blue for the same reason [commandColor] is blue -- not stuck,
|
||||
* but not replying to you either -- and a different blue because that one is the session acting on
|
||||
* itself rather than getting on with what was asked.
|
||||
*/
|
||||
val waitingColor: Color
|
||||
@Composable get() = Mocha.Sky
|
||||
|
||||
/** Approaching a limit -- still fine, worth seeing. */
|
||||
val warningColor: Color
|
||||
@Composable get() = Mocha.Yellow
|
||||
@@ -199,6 +213,8 @@ val rawSurface: Color
|
||||
*/
|
||||
fun catppuccinSyntax(): SyntaxPalette =
|
||||
SyntaxPalette(
|
||||
addition = Mocha.Green,
|
||||
deletion = Mocha.Red,
|
||||
keyword = Mocha.Mauve,
|
||||
string = Mocha.Green,
|
||||
literal = Mocha.Peach,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A model's working, shut until somebody asks for it.
|
||||
*
|
||||
* Shut by default, like a tool call and a memory note and for the same reason: it is not what the
|
||||
* session said, and left open it puts the reasoning between the question and the answer -- which on
|
||||
* a small model is most of the conversation.
|
||||
*
|
||||
* The heading is the whole of what the reader gets for free, so it carries the one thing worth
|
||||
* knowing without opening anything: whether this is still going, and if not how long it took. A
|
||||
* spinner while it runs, because that is the same fact a running command reports and it is drawn
|
||||
* the same way here.
|
||||
*/
|
||||
@Composable
|
||||
fun ThinkingCard(
|
||||
item: TranscriptItem.ThinkingRow,
|
||||
replies: ParsedReplies,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(thinkingHeadline(item), style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (item.open) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(16.dp).height(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Markdown, like every other thing the model wrote: a model reasons in the same
|
||||
// lists, headings and fenced code it answers in, and drawn plainly those arrive as
|
||||
// rows of hashes and asterisks around the working the reader opened the card to read.
|
||||
// Still arriving means the incremental parse -- see [MarkdownText] -- since an open
|
||||
// block gains a delta at a time.
|
||||
//
|
||||
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
|
||||
if (expanded) {
|
||||
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
|
||||
MarkdownText(
|
||||
item.text,
|
||||
replies,
|
||||
Modifier.padding(top = 6.dp),
|
||||
live = item.open,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Thinking", "Thought for 12.4s", or "Thought".
|
||||
*
|
||||
* The third is the one worth keeping: a block whose turn ended before the model said anything --
|
||||
* interrupted, stopped, a process that exited -- was thought about for a length of time nobody
|
||||
* measured. Naming a span there would be this screen inventing one, and the reader has no way to
|
||||
* tell an invented one from the rest.
|
||||
*/
|
||||
fun thinkingHeadline(item: TranscriptItem.ThinkingRow): String =
|
||||
when {
|
||||
item.open -> "Thinking"
|
||||
item.ms != null -> "Thought for ${formatMillis(item.ms)}"
|
||||
else -> "Thought"
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -51,24 +48,31 @@ data class ToolInput(
|
||||
private val SUBJECTS: Map<String, Pair<String, Language?>> =
|
||||
mapOf(
|
||||
"Bash" to ("command" to Language.SHELL),
|
||||
"Shell" to ("command" to Language.SHELL),
|
||||
"Patch" to ("diff" to Language.DIFF),
|
||||
"Read" to ("file_path" to null),
|
||||
"Write" to ("file_path" to null),
|
||||
"Edit" to ("file_path" to null),
|
||||
"Glob" to ("pattern" to null),
|
||||
"Grep" to ("pattern" to null),
|
||||
"WebFetch" to ("url" to null),
|
||||
"WebSearch" to ("query" to null),
|
||||
// Persisted transcripts keep the provider vocabulary they were written with.
|
||||
"web_search" to ("query" to null),
|
||||
)
|
||||
|
||||
/** Fields that are the tool's own prose about itself rather than input to it. */
|
||||
private val DESCRIPTIONS = listOf("description", "prompt")
|
||||
|
||||
fun parseToolInput(tool: String, input: String): ToolInput {
|
||||
if (input.trim() == "null") return ToolInput(null, null, null, null, emptyList())
|
||||
val json =
|
||||
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.
|
||||
// input, so it is still shown. JSON null is the one exception: it means the call had
|
||||
// no input, and drawing the word makes an absent value look like an instruction.
|
||||
return ToolInput(
|
||||
null,
|
||||
null,
|
||||
@@ -78,15 +82,20 @@ fun parseToolInput(tool: String, input: String): ToolInput {
|
||||
)
|
||||
}
|
||||
val (subjectKey, language) = SUBJECTS[tool] ?: (null to null)
|
||||
val subject = subjectKey?.let { json.optString(it) }?.takeIf { it.isNotBlank() }
|
||||
val subject =
|
||||
subjectKey
|
||||
?.let { json.text(it) }
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { if (tool == "Bash") renderedBashScript(it) ?: it else it }
|
||||
val description = DESCRIPTIONS.firstNotNullOfOrNull {
|
||||
json.optString(it).takeIf { v -> v.isNotBlank() }
|
||||
json.text(it)?.takeIf { value -> value.isNotBlank() }
|
||||
}
|
||||
val timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
|
||||
val timeout = json.text("timeout")?.takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
|
||||
val rest =
|
||||
json
|
||||
.keys()
|
||||
.asSequence()
|
||||
.filterNot(json::isNull)
|
||||
.filter { it != subjectKey || subject == null }
|
||||
.filter { it !in DESCRIPTIONS || description == null }
|
||||
.filter { it != "timeout" || timeout == null }
|
||||
@@ -96,6 +105,31 @@ fun parseToolInput(tool: String, input: String): ToolInput {
|
||||
return ToolInput(subject, language, description, timeout, rest)
|
||||
}
|
||||
|
||||
private fun JSONObject.text(key: String): String? =
|
||||
if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Removes Codex's rendered Bash argv from old transcript rows.
|
||||
*
|
||||
* New events arrive normalized by the server, but persisted transcripts keep the input originally
|
||||
* written to them. Only the outer pair are presentation quoting: quotes inside the command belong
|
||||
* to the command and must not be parsed as an early end delimiter.
|
||||
*/
|
||||
internal fun renderedBashScript(command: String): String? {
|
||||
val prefix =
|
||||
listOf("/usr/bin/bash -lc ", "/bin/bash -lc ", "bash -lc ").firstOrNull {
|
||||
command.startsWith(it)
|
||||
} ?: return null
|
||||
val quoted = command.removePrefix(prefix)
|
||||
return quoted
|
||||
.takeIf {
|
||||
it.length >= 2 &&
|
||||
((it.startsWith('\'') && it.endsWith('\'')) ||
|
||||
(it.startsWith('"') && it.endsWith('"')))
|
||||
}
|
||||
?.substring(1, quoted.lastIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool call's input: its subject highlighted, then whatever else it carried.
|
||||
*
|
||||
@@ -113,7 +147,8 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
||||
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.
|
||||
// one being read closely. The sideways scroll that makes that readable is the block's,
|
||||
// shared with the lines below -- see [RawBlock].
|
||||
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.
|
||||
@@ -121,7 +156,6 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false,
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
parsed.rest.forEach {
|
||||
@@ -130,6 +164,7 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
softWrap = false,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@@ -61,7 +64,9 @@ sealed class TranscriptRow {
|
||||
*
|
||||
* 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. Which value
|
||||
* that is belongs to the item ([TranscriptItem.key]), not to a `when` here.
|
||||
* that is belongs to the item ([TranscriptItem.key]) everywhere a row is one thing; where
|
||||
* [groupRuns] cuts a run into several rows it is the one deciding, and it says so by handing
|
||||
* each piece its key.
|
||||
*/
|
||||
abstract val key: Any
|
||||
|
||||
@@ -75,23 +80,15 @@ sealed class TranscriptRow {
|
||||
*/
|
||||
abstract val startSeq: Long
|
||||
|
||||
data class Single(val item: TranscriptItem) : TranscriptRow() {
|
||||
override val key: Any
|
||||
get() = item.key
|
||||
|
||||
data class Single(val item: TranscriptItem, override val key: Any = item.key) :
|
||||
TranscriptRow() {
|
||||
override val startSeq: Long
|
||||
get() = item.seq
|
||||
}
|
||||
|
||||
/** Two or more calls with nothing between them; drawn as one collapsed card. */
|
||||
data class Tools(val calls: List<TranscriptItem.ToolRun>) : TranscriptRow() {
|
||||
/** The run's own name, which every call in it already carries. */
|
||||
val id: String
|
||||
get() = calls.first().runId
|
||||
|
||||
override val key: Any
|
||||
get() = id
|
||||
|
||||
data class Tools(val calls: List<TranscriptItem.ToolRun>, override val key: String) :
|
||||
TranscriptRow() {
|
||||
override val startSeq: Long
|
||||
get() = calls.first().seq
|
||||
}
|
||||
@@ -102,33 +99,75 @@ sealed class TranscriptRow {
|
||||
*
|
||||
* A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words,
|
||||
* and the run this exists for is the burst of five greps nobody wants to scroll past.
|
||||
*
|
||||
* The last call is left alone too, and so is one still running wherever in its run it sits. What
|
||||
* the session is doing, or did last, is the one thing worth seeing without opening anything, and a
|
||||
* heading counting it hides it. What folds a call back into its run is therefore not finishing but
|
||||
* being overtaken: anything arriving behind it, a reply included, makes it history.
|
||||
*
|
||||
* [heldOut] is the one thing being read can change, and only in that direction: a call standing on
|
||||
* its own that somebody is reading is not overtaken while they read it. Opening a call *already*
|
||||
* inside a group does not pull it out (2026-09-16, after it briefly did) -- it is visible where it
|
||||
* is, and grouping is what gives a row its identity, so a rule that reads the open set both ways
|
||||
* makes the reader's own tap rebuild the rows around it: three rows became one the moment a call
|
||||
* was closed, and no anchor survives a row that no longer exists -- the list jumped by 450px and
|
||||
* took the closed card with it. Which calls are held out is [SessionScreen]'s to say, since being
|
||||
* inside a group once is what settles it.
|
||||
*/
|
||||
fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> =
|
||||
DebugStats.timed("grouped tool runs") { groupRuns(items) }
|
||||
fun groupToolRuns(
|
||||
items: List<TranscriptItem>,
|
||||
heldOut: Set<String> = emptySet(),
|
||||
): List<TranscriptRow> = DebugStats.timed("grouped tool runs") { groupRuns(items, heldOut) }
|
||||
|
||||
private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
|
||||
private fun groupRuns(items: List<TranscriptItem>, heldOut: Set<String>): List<TranscriptRow> {
|
||||
val rows = mutableListOf<TranscriptRow>()
|
||||
var run = mutableListOf<TranscriptItem.ToolRun>()
|
||||
// A run can occupy more than one non-adjacent piece, so claimed keys span the whole transcript
|
||||
// rather than resetting at each piece.
|
||||
var runId: String? = null
|
||||
val claimedKeys = mutableSetOf<String>()
|
||||
|
||||
fun flush() {
|
||||
when (run.size) {
|
||||
0 -> {}
|
||||
1 -> rows += TranscriptRow.Single(run.first())
|
||||
else -> rows += TranscriptRow.Tools(run.toList())
|
||||
val first = run.firstOrNull() ?: return
|
||||
// The first piece keeps the run's name, which survives a page landing in front of it
|
||||
// ([adoptRun]). Later pieces qualify that name with their first call; the suffix is the
|
||||
// final guard because a duplicate LazyColumn key takes down the whole screen.
|
||||
var key = first.runId
|
||||
if (!claimedKeys.add(key)) {
|
||||
key = "${first.runId}/${first.id}"
|
||||
var suffix = 2
|
||||
while (!claimedKeys.add(key)) {
|
||||
key = "${first.runId}/${first.id}/${suffix++}"
|
||||
}
|
||||
}
|
||||
rows +=
|
||||
if (run.size == 1) TranscriptRow.Single(first, key)
|
||||
else TranscriptRow.Tools(run.toList(), key)
|
||||
run = mutableListOf()
|
||||
}
|
||||
|
||||
items.forEach { item ->
|
||||
items.forEachIndexed { index, item ->
|
||||
// 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*.
|
||||
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
|
||||
run += item
|
||||
} else {
|
||||
val call = item as? TranscriptItem.ToolRun
|
||||
if (call == null || call.runId != runId) {
|
||||
flush()
|
||||
if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item)
|
||||
runId = call?.runId
|
||||
}
|
||||
when {
|
||||
call == null -> rows += TranscriptRow.Single(item)
|
||||
// Standing outside the run is the call's place in the list as it is now, not something
|
||||
// recorded on the call: the same finished call is a row of its own while it is the last
|
||||
// thing that happened, or open and never yet grouped, and part of its group once a
|
||||
// reply lands behind it.
|
||||
call.done && call.id !in heldOut && index != items.lastIndex -> run += call
|
||||
else -> {
|
||||
flush()
|
||||
run += call
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
flush()
|
||||
@@ -160,7 +199,15 @@ fun ToolGroup(
|
||||
*/
|
||||
onToggle: () -> Unit,
|
||||
isToolExpanded: (String) -> Boolean,
|
||||
onToolToggle: (String) -> Unit,
|
||||
/**
|
||||
* Toggles one call, and says where in the group it was drawn: how far down the group's own top
|
||||
* edge its card begins, and how tall that card is now.
|
||||
*
|
||||
* The screen anchors on *rows*, and a call is not one -- but what the reader is opening or
|
||||
* shutting is the call, and keeping it under their finger needs its place inside the row. Only
|
||||
* the group knows that, so only the group can say it. See `SessionScreen`'s `toggleAnchored`.
|
||||
*/
|
||||
onToolToggle: (id: String, top: Int, height: Int) -> Unit,
|
||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||
image: @Composable (String) -> Unit,
|
||||
) {
|
||||
@@ -175,8 +222,10 @@ fun ToolGroup(
|
||||
}
|
||||
return
|
||||
}
|
||||
val placed = remember { Placed() }
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.onPlaced { placed.top = it.positionInRoot().y }
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
) {
|
||||
@@ -196,13 +245,19 @@ fun ToolGroup(
|
||||
verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
|
||||
) {
|
||||
group.calls.forEachIndexed { index, call ->
|
||||
val card = remember(call.id) { Placed() }
|
||||
ToolCard(
|
||||
tool = call,
|
||||
expanded = isToolExpanded(call.id),
|
||||
onToggle = { onToolToggle(call.id) },
|
||||
onToggle = {
|
||||
onToolToggle(call.id, (card.top - placed.top).toInt(), card.height)
|
||||
},
|
||||
onAnswer = onAnswer,
|
||||
image = image,
|
||||
shape = connectedShape(index, group.calls.size),
|
||||
modifier =
|
||||
Modifier.onPlaced { card.top = it.positionInRoot().y }
|
||||
.onSizeChanged { card.height = it.height },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -212,6 +267,18 @@ fun ToolGroup(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where something was last placed, in the window's coordinates, and how tall it was.
|
||||
*
|
||||
* Deliberately not snapshot state: it is written from the layout phase, and a write there that
|
||||
* composition reads would schedule another recomposition of every group on screen, every frame.
|
||||
* Nothing reads it except the gesture that follows.
|
||||
*/
|
||||
private class Placed {
|
||||
var top = 0f
|
||||
var height = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The height of a group's heading, and so of the bar at its foot.
|
||||
*
|
||||
@@ -293,14 +360,17 @@ fun ToolCard(
|
||||
image: @Composable (String) -> Unit = {},
|
||||
/** Square where this card faces another in a group; see [connectedShape]. */
|
||||
shape: Shape = CardDefaults.shape,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
|
||||
val name = toolDisplayName(tool.tool)
|
||||
val output = toolDisplayOutput(tool.tool, tool.output)
|
||||
val deciding = tool.asks.any { it.answers.isEmpty() }
|
||||
val open = expanded || deciding
|
||||
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) {
|
||||
Card(modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) {
|
||||
Column(Modifier.padding(GROUP_INSET_LARGE)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(tool.tool, style = MaterialTheme.typography.titleSmall)
|
||||
Text(name, style = MaterialTheme.typography.titleSmall)
|
||||
if (open) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
parsed.timeout?.let {
|
||||
@@ -355,24 +425,26 @@ fun ToolCard(
|
||||
if (tool.tool != ASK_USER_QUESTION) {
|
||||
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
|
||||
}
|
||||
if (tool.output.isNotEmpty()) {
|
||||
if (output.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
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, and a proportional font silently destroys the alignment that carried
|
||||
// the meaning.
|
||||
// the meaning. Unwrapped for the same reason, and scrolled sideways by the
|
||||
// block around it -- see [RawBlock].
|
||||
//
|
||||
// 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) }
|
||||
val styled = remember(output, palette) { ansiStyled(output, palette) }
|
||||
RawBlock(Modifier.padding(top = 2.dp)) {
|
||||
Text(
|
||||
styled,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -391,6 +463,22 @@ fun ToolCard(
|
||||
}
|
||||
}
|
||||
|
||||
private val collaborationToolNames =
|
||||
mapOf(
|
||||
"Task" to "Spawn agent",
|
||||
"TaskOutput" to "Wait for agents",
|
||||
"SendMessage" to "Message agent",
|
||||
"CloseAgent" to "Close agent",
|
||||
"InterruptAgent" to "Interrupt agent",
|
||||
"ListAgents" to "List agents",
|
||||
"ResumeAgent" to "Resume agent",
|
||||
)
|
||||
|
||||
internal fun toolDisplayName(tool: String): String = collaborationToolNames[tool] ?: tool
|
||||
|
||||
internal fun toolDisplayOutput(tool: String, output: String): String =
|
||||
if (tool in collaborationToolNames && output == "completed") "" else output
|
||||
|
||||
/**
|
||||
* The permission ask on the call it is about.
|
||||
*
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -35,8 +35,15 @@ 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
|
||||
|
||||
@@ -63,6 +63,45 @@ sealed class TranscriptItem {
|
||||
* inside that reply would step the list under them.
|
||||
*/
|
||||
val settled: Boolean = false,
|
||||
/** A final value that supersedes provisional deltas behind a page boundary. */
|
||||
val replacesPrefix: Boolean = false,
|
||||
/**
|
||||
* When the reply was sent, in epoch seconds: the time on its newest delta, which is the
|
||||
* moment it finished rather than the moment it started.
|
||||
*
|
||||
* The transcript's own timestamp rather than a clock read here, so every device draws the
|
||||
* same time under the same reply and a replayed page agrees with the live stream.
|
||||
*/
|
||||
val ts: Double = 0.0,
|
||||
/**
|
||||
* How fast it was generated, where the provider measured it; null everywhere else.
|
||||
*
|
||||
* Folded on from the turn's usage event rather than carried by the text, because it is not
|
||||
* known until the reply is over.
|
||||
*/
|
||||
val tokensPerSecond: Double? = null,
|
||||
/** How long the provider spent reading the prompt, where it measured that. */
|
||||
val prefillMs: Long? = null,
|
||||
) : TranscriptItem()
|
||||
|
||||
/**
|
||||
* The model's working before -- or between -- the things it said.
|
||||
*
|
||||
* Its own row rather than part of the reply, and deliberately not a [ToolRun]: a run of tool
|
||||
* calls collapses into one card, and folding a model's reasoning into "Called 6 tools" would
|
||||
* file it as one of them. Shut by default, like every other card that is not what was said.
|
||||
*
|
||||
* Three states, because two of them are not the same absence. [open] is a block still being
|
||||
* thought, which is what the spinner is for. A closed one with an [ms] says how long it took; a
|
||||
* closed one without is a block whose turn ended before anything said -- an interrupted reply,
|
||||
* a session stopped mid-thought -- and it says so by not naming a duration rather than by
|
||||
* naming a wrong one.
|
||||
*/
|
||||
data class ThinkingRow(
|
||||
override val seq: Long,
|
||||
val text: String,
|
||||
val ms: Long? = null,
|
||||
val open: Boolean = true,
|
||||
) : TranscriptItem()
|
||||
|
||||
data class ToolRun(
|
||||
@@ -143,6 +182,29 @@ sealed class TranscriptItem {
|
||||
get() = arrived
|
||||
}
|
||||
|
||||
/**
|
||||
* Where one turn ended and the next began with nothing said in between.
|
||||
*
|
||||
* A rule and no words. Two replies meet like this whenever a turn starts without anybody typing
|
||||
* -- a subagent reporting back, a session the CLI picked up by itself -- and drawn with only
|
||||
* the ordinary gap between them they read as one answer with a paragraph break through the
|
||||
* middle of it. What the reader needs is to see that these are two; what started the turn is
|
||||
* somebody else's transcript's business, and a row per background task is a screenful of
|
||||
* dividers about work nobody was asking after.
|
||||
*
|
||||
* Made by the fold rather than sent by the server, because it is not something that happened:
|
||||
* it is the boundary between two things that did. See [foldEvent].
|
||||
*/
|
||||
data class TurnBreak(override val seq: Long) : TranscriptItem() {
|
||||
/**
|
||||
* Its own key, because it shares a [seq] with the reply it sits above -- that reply's first
|
||||
* delta is the event this was made at, and a keyed list refuses two items with one key by
|
||||
* taking the app down.
|
||||
*/
|
||||
override val key: Any
|
||||
get() = "break$seq"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -175,6 +237,18 @@ sealed class TranscriptItem {
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,7 +293,7 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
|
||||
* 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)
|
||||
val (older, newer) = healSplitThinking(healSplitMessage(earlier, later))
|
||||
val startedEarlier =
|
||||
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
|
||||
val endedLater =
|
||||
@@ -249,9 +323,10 @@ 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, 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 an *unfinished* assistant message with another behind it inside one
|
||||
* page, so an unsettled one at a join is always the far half of the reply the boundary cut, and
|
||||
* leaving the two apart drew a single answer as two with a paragraph break through the middle of a
|
||||
* sentence. Two settled replies meeting there are two turns and stay two.
|
||||
*
|
||||
* 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
|
||||
@@ -266,6 +341,36 @@ private fun healSplitMessage(
|
||||
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
|
||||
return earlier to later
|
||||
}
|
||||
// A settled reply is a whole turn, so the two are two answers that happen to meet at the
|
||||
// boundary rather than one cut in half -- the same distinction the fold makes, and joining them
|
||||
// here would put back exactly the run-together paragraph it stops.
|
||||
// The rule between them is put in here too, since the fold that would have made it never saw
|
||||
// these two side by side.
|
||||
if (head.settled) return earlier to (listOf(TranscriptItem.TurnBreak(tail.seq)) + later)
|
||||
if (tail.replacesPrefix) return earlier.dropLast(1) to later
|
||||
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejoins a thinking block the page boundary cut, the same way [healSplitMessage] rejoins a reply.
|
||||
*
|
||||
* A block streams a fragment at a time exactly as a reply does, so a boundary lands inside one as
|
||||
* readily. The older half then holds an open block whose [SessionEvent.ThinkingDone] is on the
|
||||
* newer page -- so it spun for the rest of the conversation, saying the machine was working on a
|
||||
* thought it finished minutes ago, and the same working was drawn as two blocks.
|
||||
*
|
||||
* Only where the older half is still open: a closed one has its own ending and the two are two
|
||||
* blocks that happen to meet here. The newer half keeps its identity, for the reason [adoptRun]
|
||||
* gives -- it is the part already on screen.
|
||||
*/
|
||||
private fun healSplitThinking(
|
||||
pages: Pair<List<TranscriptItem>, List<TranscriptItem>>
|
||||
): Pair<List<TranscriptItem>, List<TranscriptItem>> {
|
||||
val (earlier, later) = pages
|
||||
val head = earlier.lastOrNull()
|
||||
val tail = later.firstOrNull()
|
||||
if (head !is TranscriptItem.ThinkingRow || tail !is TranscriptItem.ThinkingRow) return pages
|
||||
if (!head.open) return pages
|
||||
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
|
||||
}
|
||||
|
||||
@@ -352,24 +457,75 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
// first of them: a row whose identity changed with every delta would be a new row on
|
||||
// every frame, and the list would jump for the whole of a streamed answer.
|
||||
val last = items.lastOrNull()
|
||||
if (last is TranscriptItem.AssistantMsg) {
|
||||
// A message growing again is not finished, whatever a status said in between.
|
||||
items.dropLast(1) + last.copy(text = last.text + event.delta, settled = false)
|
||||
// Only into a reply that is still arriving. A settled one is a turn that ended, and
|
||||
// text after it belongs to the next turn -- a separate message, drawn as its own row.
|
||||
// Growing it instead ran two answers together with not even a space between them,
|
||||
// which is what happens whenever a turn starts with nothing recorded in front of it:
|
||||
// a subagent reporting back, or a peer message the CLI only owns up to at the end.
|
||||
if (last is TranscriptItem.AssistantMsg && !last.settled) {
|
||||
items.dropLast(1) + last.copy(text = last.text + event.delta, ts = entry.ts)
|
||||
} else {
|
||||
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
|
||||
// A rule between the two, and only where they actually meet: anything that draws a
|
||||
// row of its own -- a message, a command, a peer note -- is already the boundary.
|
||||
val between =
|
||||
if (last is TranscriptItem.AssistantMsg)
|
||||
listOf(TranscriptItem.TurnBreak(entry.seq))
|
||||
else emptyList()
|
||||
items + between + TranscriptItem.AssistantMsg(entry.seq, event.delta, ts = entry.ts)
|
||||
}
|
||||
}
|
||||
is SessionEvent.AssistantTextFinal -> {
|
||||
val last = items.lastOrNull()
|
||||
if (last is TranscriptItem.AssistantMsg && !last.settled) {
|
||||
items.dropLast(1) +
|
||||
last.copy(text = event.text, replacesPrefix = true, ts = entry.ts)
|
||||
} else {
|
||||
val between =
|
||||
if (last is TranscriptItem.AssistantMsg)
|
||||
listOf(TranscriptItem.TurnBreak(entry.seq))
|
||||
else emptyList()
|
||||
items +
|
||||
between +
|
||||
TranscriptItem.AssistantMsg(
|
||||
entry.seq,
|
||||
event.text,
|
||||
replacesPrefix = true,
|
||||
ts = entry.ts,
|
||||
)
|
||||
}
|
||||
}
|
||||
is SessionEvent.Thinking -> {
|
||||
// Deltas grow the open block, keeping the seq of the first of them, for the same
|
||||
// reason a reply's do: a row whose identity changed per delta is a new row per frame.
|
||||
val last = items.lastOrNull()
|
||||
if (last is TranscriptItem.ThinkingRow && last.open) {
|
||||
items.dropLast(1) + last.copy(text = last.text + event.delta)
|
||||
} else {
|
||||
items + TranscriptItem.ThinkingRow(entry.seq, event.delta)
|
||||
}
|
||||
}
|
||||
// The newest block still open, rather than whatever row happens to be last.
|
||||
is SessionEvent.ThinkingDone ->
|
||||
closeThinking(items) { it.copy(ms = event.ms, open = false) }
|
||||
is SessionEvent.ToolStart ->
|
||||
items +
|
||||
TranscriptItem.ToolRun(
|
||||
entry.seq,
|
||||
event.id,
|
||||
runIdFor(items, event.id, event.tool),
|
||||
event.tool,
|
||||
event.input,
|
||||
"",
|
||||
done = false,
|
||||
)
|
||||
// A call id names one call for its whole lifetime. Codex can repeat the start while
|
||||
// recovering an in-flight item; appending that replay made two rows with one key, and
|
||||
// Compose aborts the entire LazyColumn when it encounters them. Ignoring the replay
|
||||
// also repairs transcripts which already contain it when they are folded on reopen.
|
||||
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
|
||||
items
|
||||
} else {
|
||||
items +
|
||||
TranscriptItem.ToolRun(
|
||||
entry.seq,
|
||||
event.id,
|
||||
runIdFor(items, event.id, event.tool),
|
||||
event.tool,
|
||||
event.input,
|
||||
"",
|
||||
done = false,
|
||||
)
|
||||
}
|
||||
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
|
||||
@@ -444,7 +600,13 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
// nothing it belongs above.
|
||||
is SessionEvent.MessageDropped -> items
|
||||
is SessionEvent.Settings -> items
|
||||
// Neither carries a row: both are about what the session can do rather than about anything
|
||||
// said in it, and the composer is where they are drawn.
|
||||
is SessionEvent.Images -> items
|
||||
is SessionEvent.BackgroundTasks -> items
|
||||
is SessionEvent.Status -> settleReply(items, event.state)
|
||||
is SessionEvent.AuthenticationRequired ->
|
||||
items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
||||
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
|
||||
@@ -458,12 +620,32 @@ 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)
|
||||
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
|
||||
// Screen-level state, not transcript rows -- see SessionScreen.
|
||||
is SessionEvent.UsageDelta -> items
|
||||
// Said rather than skipped: a line the server could not read is a hole in the conversation,
|
||||
// and one that draws nothing is a hole nothing on screen ever mentions.
|
||||
is SessionEvent.Unreadable ->
|
||||
items + TranscriptItem.Note(entry.seq, "[unreadable: ${event.kind}]")
|
||||
// No row: see [SessionEvent.RetiredTaskNote].
|
||||
is SessionEvent.RetiredTaskNote -> items
|
||||
// No row of its own -- the counts are screen-level state, see SessionScreen -- but the
|
||||
// generation speed belongs under the reply it measured, and this is where that reply ends.
|
||||
// Only onto the newest row, and only when that row is a reply: a turn whose usage arrives
|
||||
// after a tool call has nothing here to put it on, which draws as a footer without it.
|
||||
is SessionEvent.UsageDelta ->
|
||||
when (val last = items.lastOrNull()) {
|
||||
is TranscriptItem.AssistantMsg ->
|
||||
items.dropLast(1) +
|
||||
last.copy(
|
||||
tokensPerSecond = event.tokensPerSecond,
|
||||
prefillMs = event.prefillMs,
|
||||
)
|
||||
else -> items
|
||||
}
|
||||
is SessionEvent.ContextWindow -> items
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -474,9 +656,22 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
*/
|
||||
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
|
||||
if (sessionWorking(state)) return items
|
||||
val last = items.lastOrNull() as? TranscriptItem.AssistantMsg ?: return items
|
||||
if (last.settled) return items
|
||||
return items.dropLast(1) + last.copy(settled = true)
|
||||
// A block the turn ended in the middle of is over, however it ended. Left open it spins for
|
||||
// the rest of the conversation, which says the machine is working when nothing is.
|
||||
val ended = closeThinking(items) { it.copy(open = false) }
|
||||
val last = ended.lastOrNull() as? TranscriptItem.AssistantMsg ?: return ended
|
||||
if (last.settled) return ended
|
||||
return ended.dropLast(1) + last.copy(settled = true)
|
||||
}
|
||||
|
||||
/** [change] applied to the newest thinking block still open, if there is one. */
|
||||
private fun closeThinking(
|
||||
items: List<TranscriptItem>,
|
||||
change: (TranscriptItem.ThinkingRow) -> TranscriptItem.ThinkingRow,
|
||||
): List<TranscriptItem> {
|
||||
val at = items.indexOfLast { it is TranscriptItem.ThinkingRow && it.open }
|
||||
if (at < 0) return items
|
||||
return items.toMutableList().apply { this[at] = change(this[at] as TranscriptItem.ThinkingRow) }
|
||||
}
|
||||
|
||||
private fun updateTool(
|
||||
@@ -523,6 +718,10 @@ suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
|
||||
// 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)
|
||||
// A model's working is markdown too, and only once it is settled: an open block
|
||||
// gains a delta at a time and is drawn by the incremental parse, so warming one
|
||||
// would hold a parse of every prefix of it.
|
||||
is TranscriptItem.ThinkingRow -> if (row.open) emptyList() else listOf(row.text)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -10,6 +11,9 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.text.selection.SelectionState
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -49,6 +53,8 @@ fun TranscriptList(
|
||||
units: List<TranscriptUnit>,
|
||||
state: LazyListState,
|
||||
moreHistory: Boolean,
|
||||
historyError: String?,
|
||||
onRetryHistory: () -> Unit,
|
||||
selection: SelectionState,
|
||||
modifier: Modifier = Modifier,
|
||||
below: @Composable () -> Unit,
|
||||
@@ -94,15 +100,29 @@ fun TranscriptList(
|
||||
DebugStats.count("unit composed")
|
||||
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
|
||||
}
|
||||
// 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.
|
||||
// Standing in for everything not fetched yet. A failed fetch stays actionable here:
|
||||
// when the loaded transcript is too short to scroll, this boundary is the only place
|
||||
// the reader can be given another way to ask.
|
||||
if (moreHistory) {
|
||||
item(key = "history", contentType = "history") {
|
||||
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
|
||||
CircularProgressIndicator(
|
||||
Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
|
||||
)
|
||||
if (historyError == null) {
|
||||
CircularProgressIndicator(
|
||||
Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"Couldn't load earlier messages. $historyError",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
TextButton(onClick = onRetryHistory) { Text("Try again") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
*/
|
||||
class TranscriptSource(
|
||||
private val settings: ServerSettings,
|
||||
private val sessionId: String,
|
||||
private val address: TranscriptAddress,
|
||||
val cache: SessionCache,
|
||||
) {
|
||||
private val stream = AtomicReference<EventStream?>(null)
|
||||
@@ -65,7 +65,7 @@ class TranscriptSource(
|
||||
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 {
|
||||
@@ -83,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 }
|
||||
@@ -108,7 +108,7 @@ class TranscriptSource(
|
||||
val page =
|
||||
fetchTranscript(
|
||||
settings,
|
||||
sessionId,
|
||||
address,
|
||||
before = before,
|
||||
limit = limit,
|
||||
coalesce = coalesce,
|
||||
@@ -131,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 ->
|
||||
|
||||
@@ -130,6 +130,25 @@ sealed class TranscriptUnit {
|
||||
get() = "u$seq:$ordinal"
|
||||
}
|
||||
|
||||
/**
|
||||
* The line under a finished reply: when it was sent, and how fast it was generated.
|
||||
*
|
||||
* A unit of its own rather than something drawn inside the last block, because a settled reply
|
||||
* *is* its blocks -- there is no row left to hang it on, and the last block is a piece of
|
||||
* markdown that knows nothing about the message it came from.
|
||||
*/
|
||||
data class ReplyFoot(
|
||||
override val seq: Long,
|
||||
override val ordinal: Int,
|
||||
val ts: Double,
|
||||
val tokensPerSecond: Double?,
|
||||
val prefillMs: Long?,
|
||||
override val gap: Dp,
|
||||
) : TranscriptUnit() {
|
||||
override val key: Any
|
||||
get() = "f$seq"
|
||||
}
|
||||
|
||||
/** One memory note of a settled reply; see [MemoryNote]. */
|
||||
data class Memory(
|
||||
override val seq: Long,
|
||||
@@ -237,6 +256,19 @@ fun transcriptUnits(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unconditional, because being in this branch is what says the reply is over:
|
||||
// [splitWanted] is settled-or-overtaken. The case to keep out is a message still
|
||||
// arriving, whose "sent at" is not yet the one it ends up with, and that is drawn
|
||||
// whole.
|
||||
units +=
|
||||
TranscriptUnit.ReplyFoot(
|
||||
row.startSeq,
|
||||
ordinal,
|
||||
item.ts,
|
||||
item.tokensPerSecond,
|
||||
item.prefillMs,
|
||||
gap(FOOT_SPACING),
|
||||
)
|
||||
} else {
|
||||
units += TranscriptUnit.Whole(row, rowGap)
|
||||
}
|
||||
@@ -280,6 +312,14 @@ fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<Tra
|
||||
* length has lines that wrap, so its bubble is at the full width already and the slices match it
|
||||
* exactly. Below it, one item of at most a few screens is nothing the list minds composing.
|
||||
*/
|
||||
/**
|
||||
* The room between a reply's last block and the line under it.
|
||||
*
|
||||
* Tighter than the gap between blocks: the footer belongs to the message above it, and at a block's
|
||||
* spacing it reads as a row of its own floating between two replies.
|
||||
*/
|
||||
private val FOOT_SPACING: Dp = 2.dp
|
||||
|
||||
const val USER_SPLIT_CHARS = 4000
|
||||
|
||||
/**
|
||||
@@ -375,6 +415,7 @@ private val TranscriptUnit?.kind: String
|
||||
is TranscriptUnit.PeerBlock -> "peer block"
|
||||
is TranscriptUnit.UserChunk -> "user slice"
|
||||
is TranscriptUnit.Memory -> "memory note"
|
||||
is TranscriptUnit.ReplyFoot -> "reply footer"
|
||||
is TranscriptUnit.Whole ->
|
||||
when (val row = row) {
|
||||
is TranscriptRow.Tools -> "tool group"
|
||||
|
||||
@@ -9,12 +9,15 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -30,7 +33,14 @@ import java.time.OffsetDateTime
|
||||
* own, so the only thing its Back could ever have meant was "put this away".
|
||||
*/
|
||||
@Composable
|
||||
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
||||
fun UsageDialog(
|
||||
settings: ServerSettings,
|
||||
feed: UsageFeed,
|
||||
session: SessionSummary,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var signingIn by remember { mutableStateOf(false) }
|
||||
val now = rememberUsageNow()
|
||||
// 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
|
||||
@@ -45,10 +55,6 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
// Deliberately not subtitled with the provider this was opened from. These
|
||||
// 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,
|
||||
@@ -65,10 +71,24 @@ 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.
|
||||
// a provider can report several billing pools, 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)
|
||||
val state =
|
||||
when (val snapshots = feed.snapshots) {
|
||||
is LoadState.Loading -> LoadState.Loading
|
||||
is LoadState.Error -> snapshots
|
||||
is LoadState.Loaded ->
|
||||
LoadState.Loaded(
|
||||
usageSnapshotsFor(
|
||||
snapshots.value,
|
||||
session.machine,
|
||||
session.usageProvider,
|
||||
)
|
||||
)
|
||||
}
|
||||
UsageBody(state, now, onSignIn = { signingIn = true })
|
||||
}
|
||||
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
||||
Text("Close")
|
||||
@@ -76,29 +96,46 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (signingIn) {
|
||||
ProviderLoginDialog(
|
||||
settings = settings,
|
||||
machineId = session.machine,
|
||||
machineName = session.machineName,
|
||||
provider = session.provider,
|
||||
onDismiss = { signingIn = false },
|
||||
onSignedIn = {
|
||||
signingIn = false
|
||||
feed.refresh()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
|
||||
@Composable
|
||||
private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
||||
private fun UsageBody(
|
||||
state: LoadState<List<UsageSnapshot>>,
|
||||
now: OffsetDateTime,
|
||||
onSignIn: () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
when (val current = state) {
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
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
|
||||
// Not an error and not a blank screen: this provider has no paid quota, so
|
||||
// there is genuinely nothing to report and saying so is the answer.
|
||||
Text(
|
||||
"No machine here runs anything with usage limits.",
|
||||
"This session's provider has no usage limits.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} 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 on every side. What separates one machine from the next is the
|
||||
// line naming it.
|
||||
// No card around each pool. A card is a step up the surface ladder, and inside
|
||||
// a dialog -- itself a raised surface -- the step barely renders while costing
|
||||
// 16dp on every side. What separates one pool from the next is the line naming
|
||||
// it.
|
||||
current.value.forEachIndexed { index, snapshot ->
|
||||
if (index > 0) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
@@ -108,18 +145,18 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
||||
// 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}",
|
||||
usageSectionTitle(snapshot),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
SnapshotState(snapshot)
|
||||
SnapshotState(snapshot, onSignIn)
|
||||
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.
|
||||
if (windowIndex > 0) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
WindowBar(window)
|
||||
WindowBar(window, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,20 +164,44 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun usageSectionTitle(snapshot: UsageSnapshot): String {
|
||||
val machine = snapshot.machineName.ifEmpty { snapshot.machine }
|
||||
val provider = snapshot.provider
|
||||
val pool =
|
||||
if (provider == "codex" && snapshot.limitId != "codex") {
|
||||
when (snapshot.limitName) {
|
||||
"gpt-reserve" -> "Luna Reserve"
|
||||
null -> snapshot.limitId
|
||||
else -> snapshot.limitName
|
||||
}
|
||||
} else null
|
||||
return listOfNotNull(machine, provider, pool).joinToString(" · ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything other than numbers: why this machine has none.
|
||||
*
|
||||
* 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. Only the two faults are coloured as faults.
|
||||
* the interface nagging about a decision already made. It still offers the direct sign-in action;
|
||||
* unreachable and provider failures are the states coloured as faults.
|
||||
*/
|
||||
@Composable
|
||||
private fun SnapshotState(snapshot: UsageSnapshot) {
|
||||
private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) {
|
||||
when (snapshot.state) {
|
||||
"ok" -> {}
|
||||
"notLoggedIn" ->
|
||||
"notLoggedIn",
|
||||
"loginRequired" -> {
|
||||
Text(
|
||||
"No Claude account on this machine.",
|
||||
snapshot.detail ?: "No Claude account on this machine.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = onSignIn) { Text("Sign in") }
|
||||
}
|
||||
"authenticating" ->
|
||||
Text(
|
||||
"Claude sign-in is in progress.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -162,7 +223,7 @@ private fun SnapshotState(snapshot: UsageSnapshot) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WindowBar(window: UsageWindow) {
|
||||
private fun WindowBar(window: UsageWindow, now: OffsetDateTime) {
|
||||
Column {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
@@ -173,12 +234,8 @@ private fun WindowBar(window: UsageWindow) {
|
||||
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
||||
color = quotaColor(window.percent),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
resetLine(window)?.let {
|
||||
UsageProgressIndicator(window, now, Modifier.fillMaxWidth())
|
||||
resetLine(window, now)?.let {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
it,
|
||||
@@ -197,8 +254,8 @@ private fun WindowBar(window: UsageWindow) {
|
||||
* 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? =
|
||||
when (val end = windowEnd(window.resetsAt, OffsetDateTime.now())) {
|
||||
private fun resetLine(window: UsageWindow, now: OffsetDateTime): String? =
|
||||
when (val end = windowEnd(window.resetsAt, now)) {
|
||||
WindowEnd.NotRunning -> null
|
||||
WindowEnd.Unreadable -> "reset time unreadable"
|
||||
is WindowEnd.Ends ->
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AuthenticationPromptTest {
|
||||
@Test
|
||||
fun an_authentication_failure_stays_actionable_through_its_terminal_status() {
|
||||
val required =
|
||||
authenticationPromptAfter(
|
||||
false,
|
||||
SessionEvent.AuthenticationRequired("sign in again"),
|
||||
)
|
||||
|
||||
assertTrue(authenticationPromptAfter(required, SessionEvent.Status("idle")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_later_provider_response_makes_an_old_failure_stale() {
|
||||
assertFalse(
|
||||
authenticationPromptAfter(
|
||||
true,
|
||||
SessionEvent.AssistantText("Working again."),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class FilesNavigationTest {
|
||||
@Test
|
||||
fun `back walks through the common ancestor toward the project`() {
|
||||
val project = "/home/bob/repos/project"
|
||||
assertEquals("/", nextDirectoryToward("/etc", project))
|
||||
assertEquals("/home", nextDirectoryToward("/", project))
|
||||
assertEquals("/home/bob", nextDirectoryToward("/home", project))
|
||||
assertEquals("/home/bob/repos", nextDirectoryToward("/home/bob", project))
|
||||
assertEquals(project, nextDirectoryToward("/home/bob/repos", project))
|
||||
assertEquals(null, nextDirectoryToward(project, project))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back leaves a project descendant one directory at a time`() {
|
||||
assertEquals(
|
||||
"/home/bob/repos/project/src",
|
||||
nextDirectoryToward("/home/bob/repos/project/src/main", "/home/bob/repos/project"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paths inside the machine home use tilde notation`() {
|
||||
assertEquals("~", tildePath("/home/bob", "/home/bob"))
|
||||
assertEquals("~/repos/project", tildePath("/home/bob/repos/project", "/home/bob/"))
|
||||
assertEquals("/home/bobby/project", tildePath("/home/bobby/project", "/home/bob"))
|
||||
assertEquals("/etc", tildePath("/etc", "/home/bob"))
|
||||
}
|
||||
}
|
||||
@@ -338,6 +338,21 @@ class HighlighterTest {
|
||||
assertEquals("+[-]", highlight("+[-]", fenceLanguage("brainfuck")).text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a diff colours changes and identifies its framing separately`() {
|
||||
val code = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n context\n+new"
|
||||
assertSpans(code, Language.DIFF, Kind.DELETION, "-old")
|
||||
assertSpans(code, Language.DIFF, Kind.ADDITION, "+new")
|
||||
assertSpans(
|
||||
code,
|
||||
Language.DIFF,
|
||||
Kind.METADATA,
|
||||
"--- a/file",
|
||||
"+++ b/file",
|
||||
"@@ -1 +1 @@",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every language the fence table knows has a scanner`() {
|
||||
Language.entries.forEach { spansOf("x", it) }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class MarkdownLinksTest {
|
||||
@Test
|
||||
fun `absolute file paths are opened on the session machine`() {
|
||||
assertEquals(
|
||||
"/home/bob/repos/ai app/Main.kt",
|
||||
filePathOf("/home/bob/repos/ai%20app/Main.kt"),
|
||||
)
|
||||
assertEquals("/home/bob/Main.kt", filePathOf("file:///home/bob/Main.kt"))
|
||||
assertEquals("/home/bob/Main.kt", filePathOf("file://localhost/home/bob/Main.kt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editor coordinates select the file itself`() {
|
||||
assertEquals("/home/bob/Main.kt", filePathOf("/home/bob/Main.kt:42"))
|
||||
assertEquals("/home/bob/Main.kt", filePathOf("file:///home/bob/Main.kt:42:7#L42"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ordinary links keep their external meaning`() {
|
||||
assertNull(filePathOf("https://example.com/source.kt"))
|
||||
assertNull(filePathOf("docs/source.kt"))
|
||||
assertNull(filePathOf("//example.com/source.kt"))
|
||||
assertNull(filePathOf("file://example.com/source.kt"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PendingMessagesTest {
|
||||
private fun local(text: String = "keep this") =
|
||||
QueuedMessage("local-1", text, emptyList(), local = true)
|
||||
|
||||
@Test
|
||||
fun a_server_queue_replaces_the_local_bridge_instead_of_duplicating_it() {
|
||||
val queued =
|
||||
reconcileQueuedMessage(
|
||||
listOf(local()),
|
||||
SessionEvent.MessageQueued("server-1", "keep this", emptyList()),
|
||||
)
|
||||
|
||||
assertEquals(1, queued.size)
|
||||
assertEquals("server-1", queued.single().id)
|
||||
assertTrue(!queued.single().local)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun an_immediately_received_message_removes_its_local_bridge() {
|
||||
val queued =
|
||||
reconcileUserMessage(
|
||||
listOf(local()),
|
||||
SessionEvent.UserMessage("keep this", id = null, attachments = emptyList()),
|
||||
)
|
||||
|
||||
assertTrue(queued.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_transport_failure_stays_on_its_message() {
|
||||
val queued = markPendingFailure(listOf(local()), "local-1", "Can't reach the server")
|
||||
|
||||
assertEquals("Can't reach the server", queued.single().refusal)
|
||||
assertTrue(queued.single().local)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun server_acceptance_keeps_the_bubble_until_the_provider_event() {
|
||||
val queued = markPendingAccepted(listOf(local()), "local-1")
|
||||
|
||||
assertEquals(1, queued.size)
|
||||
assertTrue(queued.single().serverAccepted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discarding_a_failed_send_removes_only_that_local_copy() {
|
||||
val server = QueuedMessage("server-1", "already accepted", emptyList())
|
||||
val queued = listOf(local(), local("keep this one").copy(id = "local-2"), server)
|
||||
|
||||
val discarded = discardPendingMessage(queued, "local-1")
|
||||
|
||||
assertEquals(listOf("local-2", "server-1"), discarded.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun identical_messages_are_reconciled_one_at_a_time() {
|
||||
val queued = listOf(local(), local().copy(id = "local-2"))
|
||||
val afterFirst =
|
||||
reconcileUserMessage(
|
||||
queued,
|
||||
SessionEvent.UserMessage("keep this", id = null, attachments = emptyList()),
|
||||
)
|
||||
|
||||
assertEquals(listOf("local-2"), afterFirst.map { it.id })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SessionImageTest {
|
||||
@Test
|
||||
fun `a full-height image hides both bars`() {
|
||||
assertEquals(
|
||||
ViewerBars(status = true, navigation = true),
|
||||
viewerBars(
|
||||
imageWidth = 1000,
|
||||
imageHeight = 2000,
|
||||
viewportWidth = 1000,
|
||||
viewportHeight = 2000,
|
||||
scale = 1f,
|
||||
offset = Offset.Zero,
|
||||
insets = ViewerBarInsets(status = 100, navigation = 100),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a letterboxed image leaves both bars visible`() {
|
||||
assertEquals(
|
||||
ViewerBars(),
|
||||
viewerBars(
|
||||
imageWidth = 1000,
|
||||
imageHeight = 500,
|
||||
viewportWidth = 1000,
|
||||
viewportHeight = 2000,
|
||||
scale = 1f,
|
||||
offset = Offset.Zero,
|
||||
insets = ViewerBarInsets(status = 100, navigation = 100),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `panning into the status bar hides only that bar`() {
|
||||
assertEquals(
|
||||
ViewerBars(status = true),
|
||||
viewerBars(
|
||||
imageWidth = 1000,
|
||||
imageHeight = 500,
|
||||
viewportWidth = 1000,
|
||||
viewportHeight = 2000,
|
||||
scale = 2f,
|
||||
offset = Offset(0f, -500f),
|
||||
insets = ViewerBarInsets(status = 100, navigation = 100),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native scale reverses fitting a tall image`() {
|
||||
assertEquals(
|
||||
1.25f,
|
||||
nativeScale(
|
||||
imageWidth = 1000,
|
||||
imageHeight = 2000,
|
||||
viewportWidth = 1000,
|
||||
viewportHeight = 1600,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native scale leaves a small image alone`() {
|
||||
assertEquals(
|
||||
1f,
|
||||
nativeScale(
|
||||
imageWidth = 500,
|
||||
imageHeight = 500,
|
||||
viewportWidth = 1000,
|
||||
viewportHeight = 1000,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zoom keeps the region panned to in the center`() {
|
||||
assertEquals(
|
||||
Offset(240f, -160f),
|
||||
zoomOffset(
|
||||
offset = Offset(120f, -80f),
|
||||
centroid = Offset(500f, 1000f),
|
||||
pan = Offset.Zero,
|
||||
oldScale = 2f,
|
||||
newScale = 4f,
|
||||
viewportCenter = Offset(500f, 1000f),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zoom keeps an off-center pinch beneath moving fingers`() {
|
||||
assertEquals(
|
||||
Offset(460f, 420f),
|
||||
zoomOffset(
|
||||
offset = Offset(100f, -100f),
|
||||
centroid = Offset(250f, 400f),
|
||||
pan = Offset(10f, 20f),
|
||||
oldScale = 2f,
|
||||
newScale = 4f,
|
||||
viewportCenter = Offset(500f, 1000f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SessionUsageTest {
|
||||
@Test
|
||||
fun `usage snapshots stay with the session's machine and provider`() {
|
||||
val claude = snapshot("machine", "claude", null)
|
||||
val codex = snapshot("machine", "codex", "codex")
|
||||
val reserve = snapshot("machine", "codex", "gpt-reserve")
|
||||
val elsewhere = snapshot("other", "codex", "codex")
|
||||
|
||||
assertEquals(
|
||||
listOf(codex, reserve),
|
||||
usageSnapshotsFor(
|
||||
listOf(claude, codex, reserve, elsewhere),
|
||||
machine = "machine",
|
||||
provider = "codex",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session without a meter has no usage snapshots`() {
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
usageSnapshotsFor(
|
||||
listOf(snapshot("machine", "claude", null)),
|
||||
machine = "machine",
|
||||
provider = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the model selects its named pool and other models use the generic pool`() {
|
||||
val generic = snapshot("machine", "codex", "codex")
|
||||
val spark =
|
||||
snapshot(
|
||||
"machine",
|
||||
"codex",
|
||||
"codex_bengalfox",
|
||||
limitName = "GPT-5.3-Codex-Spark",
|
||||
)
|
||||
val reserve =
|
||||
snapshot("machine", "codex", "base_model_inference", limitName = "gpt-reserve")
|
||||
val pools = listOf(generic, spark, reserve)
|
||||
|
||||
assertEquals(spark, usagePoolFor(pools, "gpt-5.3-codex-spark"))
|
||||
assertEquals(reserve, usagePoolFor(pools, "gpt-5.6-luna"))
|
||||
assertEquals(generic, usagePoolFor(pools, "gpt-6-astra"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bar uses the shortest reported cycle`() {
|
||||
val weekly = window("Weekly", 10_080)
|
||||
val hourly = window("5-hour window", 300)
|
||||
|
||||
assertEquals(hourly, shortestUsageWindow(listOf(weekly, hourly)))
|
||||
assertEquals(null, shortestUsageWindow(listOf(window("unknown", null))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the time cursor follows elapsed time through the window`() {
|
||||
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
|
||||
|
||||
assertEquals(
|
||||
0.4f,
|
||||
usageWindowElapsedFraction(
|
||||
window("5-hour window", 300, "2026-09-17T15:00:00Z"),
|
||||
now,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the time cursor clamps at the window ends`() {
|
||||
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
|
||||
|
||||
assertEquals(
|
||||
0f,
|
||||
usageWindowElapsedFraction(
|
||||
window("5-hour window", 300, "2026-09-17T18:00:00Z"),
|
||||
now,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
1f,
|
||||
usageWindowElapsedFraction(
|
||||
window("5-hour window", 300, "2026-09-17T11:00:00Z"),
|
||||
now,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the time cursor is absent without a usable duration and reset time`() {
|
||||
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
|
||||
|
||||
assertEquals(null, usageWindowElapsedFraction(window("unknown", null), now))
|
||||
assertEquals(null, usageWindowElapsedFraction(window("not running", 300), now))
|
||||
assertEquals(
|
||||
null,
|
||||
usageWindowElapsedFraction(window("unreadable", 300, "not a timestamp"), now),
|
||||
)
|
||||
assertEquals(null, usageWindowElapsedFraction(window("zero", 0), now))
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
machine: String,
|
||||
provider: String,
|
||||
limitId: String?,
|
||||
limitName: String? = null,
|
||||
) =
|
||||
UsageSnapshot(
|
||||
provider = provider,
|
||||
machine = machine,
|
||||
machineName = machine,
|
||||
limitId = limitId,
|
||||
limitName = limitName,
|
||||
state = "ok",
|
||||
detail = null,
|
||||
windows = emptyList(),
|
||||
)
|
||||
|
||||
private fun window(label: String, durationMinutes: Long?, resetsAt: String? = null) =
|
||||
UsageWindow(
|
||||
kind = "test",
|
||||
label = label,
|
||||
percent = 12.0,
|
||||
durationMinutes = durationMinutes,
|
||||
resetsAt = resetsAt,
|
||||
active = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import java.time.ZoneId
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The model's working as its own row, and the line under a finished reply.
|
||||
*
|
||||
* Both have the same shape of hazard: a state nothing measured must not come out looking like one
|
||||
* that was. A block interrupted mid-thought has no duration, and a provider that reports no
|
||||
* generation speed has no figure -- neither may borrow one.
|
||||
*/
|
||||
class ThinkingTest {
|
||||
private val utc = ZoneId.of("UTC")
|
||||
private var seq = 0L
|
||||
|
||||
private fun fold(items: List<TranscriptItem>, event: SessionEvent, ts: Double = 1.0) =
|
||||
foldEvent(items, SeqEvent(seq = ++seq, ts = ts, event = event))
|
||||
|
||||
private fun fold(vararg events: SessionEvent) =
|
||||
events.fold(emptyList<TranscriptItem>()) { items, event -> fold(items, event) }
|
||||
|
||||
private fun thinking(items: List<TranscriptItem>) =
|
||||
items.filterIsInstance<TranscriptItem.ThinkingRow>()
|
||||
|
||||
@Test
|
||||
fun `deltas accumulate into one block that ends with its duration`() {
|
||||
val items =
|
||||
fold(
|
||||
SessionEvent.Thinking("the user "),
|
||||
SessionEvent.Thinking("wants a card"),
|
||||
SessionEvent.ThinkingDone(12_400),
|
||||
SessionEvent.AssistantText("Here it is."),
|
||||
)
|
||||
val block = thinking(items).single()
|
||||
assertEquals("the user wants a card", block.text)
|
||||
assertEquals(12_400, block.ms)
|
||||
assertEquals("Thought for 12.4s", thinkingHeadline(block))
|
||||
// Its own row, above the reply rather than inside it.
|
||||
assertEquals(1, items.filterIsInstance<TranscriptItem.AssistantMsg>().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block the turn ended in the middle of stops without naming a span`() {
|
||||
val items = fold(SessionEvent.Thinking("half a thought"), SessionEvent.Status("idle"))
|
||||
val block = thinking(items).single()
|
||||
assertNull(block.ms)
|
||||
assertTrue(!block.open)
|
||||
assertEquals("Thought", thinkingHeadline(block))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block still being thought says so`() {
|
||||
val block = thinking(fold(SessionEvent.Thinking("hmm"))).single()
|
||||
assertTrue(block.open)
|
||||
assertEquals("Thinking", thinkingHeadline(block))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `thinking between two replies is two replies and two blocks`() {
|
||||
val items =
|
||||
fold(
|
||||
SessionEvent.Thinking("first"),
|
||||
SessionEvent.ThinkingDone(1_000),
|
||||
SessionEvent.AssistantText("One."),
|
||||
SessionEvent.Thinking("second"),
|
||||
SessionEvent.ThinkingDone(2_000),
|
||||
SessionEvent.AssistantText("Two."),
|
||||
)
|
||||
assertEquals(listOf("first", "second"), thinking(items).map { it.text })
|
||||
assertEquals(
|
||||
listOf("One.", "Two."),
|
||||
items.filterIsInstance<TranscriptItem.AssistantMsg>().map { it.text },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reply carries when it was sent and what it cost to produce`() {
|
||||
val items =
|
||||
fold(emptyList(), SessionEvent.AssistantText("Done."), ts = 1_788_609_600.0).let {
|
||||
fold(it, SessionEvent.UsageDelta(42, 100, 18.37, 9_489))
|
||||
}
|
||||
val reply = items.filterIsInstance<TranscriptItem.AssistantMsg>().single()
|
||||
assertEquals(1_788_609_600.0, reply.ts)
|
||||
assertEquals(18.37, reply.tokensPerSecond)
|
||||
assertEquals(9_489, reply.prefillMs)
|
||||
|
||||
val footer = replyFooterText(reply.ts, reply.tokensPerSecond, reply.prefillMs, utc)
|
||||
// The clock reading rather than the whole string: the platform's own short-time format
|
||||
// differs by JDK and locale, which is the point of asking it for one.
|
||||
assertTrue(footer!!.startsWith("read 9.5s · 18.4 tok/s · "), footer)
|
||||
assertTrue(footer.contains("12:00"), footer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the clock stays at the end however much the provider measured`() {
|
||||
// What a provider that measures nothing leaves: the time, and nothing in front of it.
|
||||
val bare = replyFooterText(1_788_609_600.0, null, null, utc)
|
||||
assertTrue(bare!!.contains("12:00"), bare)
|
||||
assertTrue(!bare.contains("tok/s") && !bare.contains("read"), bare)
|
||||
// Every shape ends with the same thing, which is the whole point of the order: the clock
|
||||
// does not move because the session is on a provider that measures more or less.
|
||||
val shapes =
|
||||
listOf(
|
||||
bare,
|
||||
replyFooterText(1_788_609_600.0, 18.37, null, utc)!!,
|
||||
replyFooterText(1_788_609_600.0, null, 9_489, utc)!!,
|
||||
replyFooterText(1_788_609_600.0, 18.37, 9_489, utc)!!,
|
||||
)
|
||||
assertEquals(1, shapes.map { it.substringAfterLast("· ") }.distinct().size, "$shapes")
|
||||
// A reply with nothing to say has no line at all rather than an empty one.
|
||||
assertNull(replyFooterText(0.0, null, null, utc))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block cut by a page boundary is one block, and it is not still going`() {
|
||||
// Each page folded on its own, as the app does: the older one holds the fragments before
|
||||
// the cut and no ending, the newer one the rest and the ending.
|
||||
val older = fold(SessionEvent.Thinking("half a "))
|
||||
val newer = fold(SessionEvent.Thinking("thought"), SessionEvent.ThinkingDone(2_000))
|
||||
|
||||
val joined = joinPages(older, newer)
|
||||
val block = thinking(joined).single()
|
||||
assertEquals("half a thought", block.text)
|
||||
assertEquals(2_000, block.ms)
|
||||
assertTrue(!block.open)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two blocks meeting at a page boundary stay two`() {
|
||||
val older = fold(SessionEvent.Thinking("first"), SessionEvent.ThinkingDone(1_000))
|
||||
val newer = fold(SessionEvent.Thinking("second"), SessionEvent.ThinkingDone(2_000))
|
||||
assertEquals(listOf("first", "second"), thinking(joinPages(older, newer)).map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `usage that lands after a tool call is not folded onto an older reply`() {
|
||||
val items =
|
||||
fold(
|
||||
SessionEvent.AssistantText("Reading it."),
|
||||
SessionEvent.ToolStart("t1", "Read", "{}"),
|
||||
SessionEvent.ToolEnd("t1", "done"),
|
||||
SessionEvent.UsageDelta(42, 100, 18.0, 500),
|
||||
)
|
||||
assertNull(items.filterIsInstance<TranscriptItem.AssistantMsg>().single().tokensPerSecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ToolInputTest {
|
||||
@Test
|
||||
fun `bash wrapper ignores double quotes inside its outer pair`() {
|
||||
assertEquals(
|
||||
"rg -n \"needle\" server app",
|
||||
renderedBashScript("/usr/bin/bash -lc \"rg -n \"needle\" server app\""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bash wrapper ignores single quotes inside its outer pair`() {
|
||||
assertEquals(
|
||||
"printf 'hello'",
|
||||
renderedBashScript("/bin/bash -lc 'printf 'hello''"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unquoted or unfamiliar commands stay intact`() {
|
||||
assertNull(renderedBashScript("/usr/bin/bash -lc echo hello"))
|
||||
assertNull(renderedBashScript("/usr/bin/fish -lc 'echo hello'"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing optional input is not displayed as null`() {
|
||||
assertTrue(parseToolInput("TaskOutput", "null").rest.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collaboration calls say what they do and omit empty completion`() {
|
||||
assertEquals("Spawn agent", toolDisplayName("Task"))
|
||||
assertEquals("Wait for agents", toolDisplayName("TaskOutput"))
|
||||
assertEquals("", toolDisplayOutput("TaskOutput", "completed"))
|
||||
assertEquals("failed", toolDisplayOutput("TaskOutput", "failed"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* How a run of tool calls is cut into rows: the call still running, the last call in the
|
||||
* transcript, and one held out because the reader has it open are drawn on their own, and every
|
||||
* piece the cut leaves behind still has a key of its own -- two rows sharing one key take the app
|
||||
* down, and a key that moves takes the reader's place with it.
|
||||
*/
|
||||
class ToolRowsTest {
|
||||
private var seq = 0L
|
||||
|
||||
private fun call(id: String, runId: String = id, done: Boolean = true) =
|
||||
TranscriptItem.ToolRun(
|
||||
seq = ++seq,
|
||||
id = id,
|
||||
runId = runId,
|
||||
tool = "Bash",
|
||||
input = "{}",
|
||||
output = if (done) "ok" else "",
|
||||
done = done,
|
||||
)
|
||||
|
||||
/** Something that is not a tool call, to put behind the run so its last call folds in. */
|
||||
private fun reply() = TranscriptItem.AssistantMsg(seq = ++seq, text = "done")
|
||||
|
||||
private fun shape(rows: List<TranscriptRow>) = rows.map { row ->
|
||||
when (row) {
|
||||
is TranscriptRow.Tools -> row.calls.map { it.id }
|
||||
is TranscriptRow.Single -> listOf((row.item as? TranscriptItem.ToolRun)?.id ?: "reply")
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertKeysDistinct(rows: List<TranscriptRow>) =
|
||||
assertEquals(rows.size, rows.map { it.key }.toSet().size, "$rows")
|
||||
|
||||
@Test
|
||||
fun the_call_still_running_is_a_row_of_its_own() {
|
||||
val rows =
|
||||
groupToolRuns(
|
||||
listOf(
|
||||
call("a"),
|
||||
call("b", runId = "a"),
|
||||
call("c", runId = "a", done = false),
|
||||
call("d", runId = "a"),
|
||||
reply(),
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
listOf(listOf("a", "b"), listOf("c"), listOf("d"), listOf("reply")),
|
||||
shape(rows),
|
||||
)
|
||||
assertKeysDistinct(rows)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_call_running_in_the_middle_of_its_run_splits_the_group_in_two() {
|
||||
val rows =
|
||||
groupToolRuns(
|
||||
listOf(
|
||||
call("a"),
|
||||
call("b", runId = "a", done = false),
|
||||
call("c", runId = "a"),
|
||||
call("d", runId = "a"),
|
||||
reply(),
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
listOf(listOf("a"), listOf("b"), listOf("c", "d"), listOf("reply")),
|
||||
shape(rows),
|
||||
)
|
||||
assertKeysDistinct(rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* A call held out is one the reader opened while it stood on its own; being overtaken while
|
||||
* they read it does not fold it away, and closing it hands it back to its run.
|
||||
*/
|
||||
@Test
|
||||
fun a_held_out_call_stays_out_of_its_group() {
|
||||
val calls =
|
||||
listOf(
|
||||
call("a"),
|
||||
call("b", runId = "a"),
|
||||
call("c", runId = "a"),
|
||||
call("d", runId = "a"),
|
||||
reply(),
|
||||
)
|
||||
|
||||
val whileHeld = groupToolRuns(calls, heldOut = setOf("d"))
|
||||
val afterItCloses = groupToolRuns(calls)
|
||||
|
||||
assertEquals(listOf(listOf("a", "b", "c"), listOf("d"), listOf("reply")), shape(whileHeld))
|
||||
assertTrue(whileHeld[1] is TranscriptRow.Single, "$whileHeld")
|
||||
assertKeysDistinct(whileHeld)
|
||||
assertEquals(listOf(listOf("a", "b", "c", "d"), listOf("reply")), shape(afterItCloses))
|
||||
assertTrue(afterItCloses.first() is TranscriptRow.Tools, "$afterItCloses")
|
||||
}
|
||||
|
||||
/**
|
||||
* The one case where the run's name is a call that is not in the run's first row: a page of
|
||||
* history joined onto a run whose own first call is still going ([joinPages] renames the older
|
||||
* calls to the newer run's name). Both rows would key on that name.
|
||||
*/
|
||||
@Test
|
||||
fun the_run_keeps_its_name_even_when_the_call_it_is_named_after_is_the_one_running() {
|
||||
val rows = groupToolRuns(listOf(call("a", runId = "b"), call("b", done = false)))
|
||||
assertEquals(listOf(listOf("a"), listOf("b")), shape(rows))
|
||||
assertKeysDistinct(rows)
|
||||
assertEquals("b", rows.first().key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_run_that_reappears_after_another_row_keeps_distinct_keys() {
|
||||
val rows =
|
||||
groupToolRuns(
|
||||
listOf(
|
||||
call("older", runId = "exec-1"),
|
||||
call("older-2", runId = "exec-1"),
|
||||
reply(),
|
||||
call("exec-1", runId = "exec-1"),
|
||||
call("newer", runId = "exec-1"),
|
||||
reply(),
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(rows[0] is TranscriptRow.Tools, "$rows")
|
||||
assertTrue(rows[2] is TranscriptRow.Tools, "$rows")
|
||||
assertKeysDistinct(rows)
|
||||
assertEquals("exec-1", rows[0].key)
|
||||
assertEquals("exec-1/exec-1", rows[2].key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishing is not what folds a call back in -- being overtaken is. A session that has run its
|
||||
* last command and is writing its reply leaves that command standing until the reply starts.
|
||||
*/
|
||||
@Test
|
||||
fun the_last_call_stays_out_when_it_finishes_and_folds_in_when_something_follows() {
|
||||
val a = call("a")
|
||||
val running = call("b", runId = "a", done = false)
|
||||
val finished = running.copy(done = true)
|
||||
val whileRunning = groupToolRuns(listOf(a, running))
|
||||
val afterItEnds = groupToolRuns(listOf(a, finished))
|
||||
val afterTheReply = groupToolRuns(listOf(a, finished, reply()))
|
||||
assertEquals(listOf(listOf("a"), listOf("b")), shape(whileRunning))
|
||||
assertEquals(listOf(listOf("a"), listOf("b")), shape(afterItEnds))
|
||||
assertEquals(listOf(listOf("a", "b"), listOf("reply")), shape(afterTheReply))
|
||||
// The run keeps the key it was drawn under throughout, so the list rebuilds a row rather
|
||||
// than losing its anchor.
|
||||
assertEquals(whileRunning.first().key, afterItEnds.first().key)
|
||||
assertEquals(whileRunning.first().key, afterTheReply.first().key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_run_of_finished_calls_is_one_group_once_something_follows_it() {
|
||||
val rows =
|
||||
groupToolRuns(
|
||||
listOf(call("a"), call("b", runId = "a"), call("c", runId = "a"), reply())
|
||||
)
|
||||
assertEquals(listOf(listOf("a", "b", "c"), listOf("reply")), shape(rows))
|
||||
assertTrue(rows.first() is TranscriptRow.Tools, "$rows")
|
||||
}
|
||||
}
|
||||
@@ -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,185 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Where one turn ends and the next begins, which is the part of the fold that had no way of saying
|
||||
* anything was wrong: two replies run together read as one long answer, and the seam is somewhere
|
||||
* in the middle of a sentence.
|
||||
*/
|
||||
class TranscriptItemsTest {
|
||||
private var seq = 0L
|
||||
|
||||
private fun fold(items: List<TranscriptItem>, event: SessionEvent) =
|
||||
foldEvent(items, SeqEvent(seq = ++seq, ts = 1.0, event = event))
|
||||
|
||||
private fun fold(vararg events: SessionEvent) =
|
||||
events.fold(emptyList<TranscriptItem>()) { items, event -> fold(items, event) }
|
||||
|
||||
private fun texts(items: List<TranscriptItem>) =
|
||||
items.filterIsInstance<TranscriptItem.AssistantMsg>().map { it.text }
|
||||
|
||||
@Test
|
||||
fun an_authentication_failure_stays_visible_as_an_error_row() {
|
||||
val entry =
|
||||
SeqEvent(
|
||||
seq = 7,
|
||||
ts = 1.0,
|
||||
event = SessionEvent.AuthenticationRequired("sign in again"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
TranscriptItem.ErrorMsg(7, "sign in again"),
|
||||
foldEvent(emptyList(), entry).single(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun text_after_the_turn_ended_is_a_new_reply_rather_than_more_of_the_last_one() {
|
||||
val items =
|
||||
fold(
|
||||
SessionEvent.AssistantText("You'll get the one-line notice when it lands."),
|
||||
SessionEvent.Status("idle"),
|
||||
SessionEvent.AssistantText("Dev Updater fix is pushed."),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("You'll get the one-line notice when it lands.", "Dev Updater fix is pushed."),
|
||||
texts(items),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deltas_of_one_reply_still_accumulate_into_it() {
|
||||
val items =
|
||||
fold(
|
||||
SessionEvent.AssistantText("Still "),
|
||||
SessionEvent.AssistantText("running "),
|
||||
SessionEvent.Status("running"),
|
||||
SessionEvent.AssistantText("its tests."),
|
||||
)
|
||||
assertEquals(listOf("Still running its tests."), texts(items))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completed_text_replaces_provisional_deltas_live() {
|
||||
val items =
|
||||
fold(
|
||||
SessionEvent.AssistantText("I'll inspect the color-c concrete implementation"),
|
||||
SessionEvent.AssistantTextFinal(
|
||||
"I’ll inspect the color-correction TODO and the relevant design."
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("I’ll inspect the color-correction TODO and the relevant design."),
|
||||
texts(items),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completed_text_discards_provisional_deltas_across_a_page_boundary() {
|
||||
val earlier = fold(SessionEvent.AssistantText("1. provisional section\n\n"))
|
||||
val later =
|
||||
fold(
|
||||
SessionEvent.AssistantTextFinal("1. final first section\n\n2. final second section")
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("1. final first section\n\n2. final second section"),
|
||||
texts(joinPages(earlier, later)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_final_value_after_a_settled_reply_is_a_new_message_across_a_page_boundary() {
|
||||
val earlier =
|
||||
fold(
|
||||
SessionEvent.AssistantText("Previous answer."),
|
||||
SessionEvent.Status("idle"),
|
||||
)
|
||||
val later = fold(SessionEvent.AssistantTextFinal("Next answer."))
|
||||
|
||||
assertEquals(
|
||||
listOf("Previous answer.", "Next answer."),
|
||||
texts(joinPages(earlier, later)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule that replaced the wall of reports. A turn that starts with nothing recorded in front
|
||||
* of it -- a subagent finishing, the CLI picking a conversation back up -- leaves two replies
|
||||
* abutting, and only the break says they are two.
|
||||
*/
|
||||
@Test
|
||||
fun two_replies_that_meet_are_separated_by_a_rule_and_nothing_else() {
|
||||
val items =
|
||||
fold(
|
||||
SessionEvent.AssistantText("Launched it."),
|
||||
SessionEvent.Status("waiting"),
|
||||
SessionEvent.AssistantText("Noted."),
|
||||
)
|
||||
assertEquals(3, items.size, "$items")
|
||||
assertTrue(items[1] is TranscriptItem.TurnBreak, "$items")
|
||||
assertEquals(listOf("Launched it.", "Noted."), texts(items))
|
||||
// Distinct keys: the break shares the reply's seq, and two items with one key take the
|
||||
// app down.
|
||||
assertEquals(3, items.map { it.key }.toSet().size, "$items")
|
||||
}
|
||||
|
||||
/**
|
||||
* A reply after anything that draws a row of its own needs no rule: that row is the boundary.
|
||||
*/
|
||||
@Test
|
||||
fun a_reply_after_a_row_of_its_own_gets_no_rule() {
|
||||
val items =
|
||||
fold(
|
||||
SessionEvent.AssistantText("Launched it."),
|
||||
SessionEvent.Status("idle"),
|
||||
SessionEvent.UserMessage("carry on", null, emptyList()),
|
||||
SessionEvent.AssistantText("Noted."),
|
||||
)
|
||||
assertTrue(items.none { it is TranscriptItem.TurnBreak }, "$items")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_repeated_tool_start_is_still_one_row() {
|
||||
val start = SessionEvent.ToolStart("exec-1", "Bash", "{\"command\":\"cargo test\"}")
|
||||
val items =
|
||||
fold(
|
||||
start,
|
||||
SessionEvent.AssistantText("The test run is still going."),
|
||||
start,
|
||||
SessionEvent.ToolEnd("exec-1", "finished"),
|
||||
)
|
||||
|
||||
val tools = items.filterIsInstance<TranscriptItem.ToolRun>()
|
||||
assertEquals(1, tools.size, "$items")
|
||||
assertEquals("finished", tools.single().output)
|
||||
assertTrue(tools.single().done)
|
||||
}
|
||||
|
||||
/**
|
||||
* The page-join half of the same rule. A boundary that cuts one reply leaves an unfinished half
|
||||
* to be rejoined; a boundary that lands between two turns must not join anything, or paging
|
||||
* back puts the run-together paragraph straight back.
|
||||
*/
|
||||
@Test
|
||||
fun paging_back_rejoins_a_cut_reply_and_leaves_two_finished_ones_apart() {
|
||||
val cut =
|
||||
joinPages(
|
||||
listOf(TranscriptItem.AssistantMsg(1, "half a ")),
|
||||
listOf(TranscriptItem.AssistantMsg(2, "sentence", settled = true)),
|
||||
)
|
||||
assertEquals(listOf("half a sentence"), texts(cut))
|
||||
|
||||
val whole =
|
||||
joinPages(
|
||||
listOf(TranscriptItem.AssistantMsg(1, "One turn.", settled = true)),
|
||||
listOf(TranscriptItem.AssistantMsg(2, "The next.", settled = true)),
|
||||
)
|
||||
assertEquals(listOf("One turn.", "The next."), texts(whole))
|
||||
// And the rule between them, which the fold that would have made it never got to see.
|
||||
assertTrue(whole.any { it is TranscriptItem.TurnBreak }, "$whole")
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,11 @@ GLYPHS=(
|
||||
U+F0193 # md-content_save
|
||||
U+F0224 # md-file_outline
|
||||
U+F201 # fa-line_chart -- Font Awesome's, asked for by name
|
||||
U+F035C # md-menu -- the burger, as a row's drag handle
|
||||
U+F07B7 # md-console_line -- a backgrounded command
|
||||
U+F06A9 # md-robot -- a subagent
|
||||
U+F04AA # md-sitemap -- a workflow
|
||||
U+F0625 # md-help_circle_outline -- a background task of a kind this build does not know
|
||||
)
|
||||
|
||||
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
|
||||
|
||||
@@ -120,10 +120,10 @@ TOKEN=$(grep -o 'token=[A-Za-z0-9_-]*' "$WORK/server.log" | head -1 | cut -d= -f
|
||||
api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; }
|
||||
|
||||
echo "==> Importing"
|
||||
SETUP=$(api "https://127.0.0.1:$PORT/setups" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
|
||||
MACHINE=$(api "https://127.0.0.1:$PORT/machines" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
|
||||
SESSION=$(api -H 'Content-Type: application/json' -X POST \
|
||||
"https://127.0.0.1:$PORT/sessions" \
|
||||
-d "{\"setup\":\"$SETUP\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \
|
||||
-d "{\"machine\":\"$MACHINE\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \
|
||||
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
|
||||
echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events"
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# emulator" is three places for the memory check that was missing from all of
|
||||
# them.
|
||||
#
|
||||
# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh,
|
||||
# Environment machine (SDK location, PATH, ...) lives in ./android-env.sh,
|
||||
# which can also be sourced directly for one-off commands.
|
||||
set -eu
|
||||
|
||||
|
||||
+17
-6
@@ -110,7 +110,7 @@ api) # ./ui-sandbox.sh api /path [curl args...]
|
||||
;;
|
||||
spawn) # ./ui-sandbox.sh spawn [title] -- an echo session; prints its id
|
||||
api /sessions -X POST -H 'content-type: application/json' \
|
||||
-d "{\"setup\":\"local\",\"provider\":\"echo\",\"title\":\"${2:-test}\"}" |
|
||||
-d "{\"machine\":\"local\",\"provider\":\"echo\",\"title\":\"${2:-test}\"}" |
|
||||
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'
|
||||
exit 0
|
||||
;;
|
||||
@@ -150,7 +150,7 @@ if [ -f "$ROOT/config.ron" ]; then
|
||||
/^tokens: \[/ { in_tokens = 1; next }
|
||||
# The server writes the list back compactly, with the last entry
|
||||
# and the close on one line: " ),],". Reading the close only
|
||||
# at a line start ran past it into `setups`, and the salvage then
|
||||
# at a line start ran past it into `machines`, and the salvage then
|
||||
# carried a second copy of that block into the new config.
|
||||
in_tokens && /\],/ {
|
||||
sub(/\],.*/, "")
|
||||
@@ -213,13 +213,24 @@ while [ "$i" -le 8 ]; do
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# A CLI that does nothing, so importing one of these is free and safe.
|
||||
# Everything the spawn path cares about is here: it holds the fifo open,
|
||||
# records a real pid, writes nothing, and dies on a signal. A real
|
||||
# A CLI that does nothing during a session and offers one deterministic login
|
||||
# during `auth login`, so both paths are free and safe. Everything the spawn
|
||||
# path cares about is here: it holds the fifo open, records a real pid, writes
|
||||
# nothing, and dies on a signal. A real
|
||||
# `claude --resume` against an invented session id would either fail in a
|
||||
# way that tests nothing or start a turn on somebody's account.
|
||||
cat >"$ROOT/fake-claude" <<FAKE
|
||||
#!/bin/sh
|
||||
if [ "\${1:-}" = auth ] && [ "\${2:-}" = login ]; then
|
||||
echo 'https://claude.com/cai/oauth/authorize?state=ai-app-sandbox'
|
||||
while IFS= read -r code; do
|
||||
if [ "\$code" = sandbox-code ]; then
|
||||
exit 0
|
||||
fi
|
||||
echo 'Invalid code' >&2
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
# Slow to start, on purpose. An import against this finishes in
|
||||
# milliseconds otherwise, so every state on the way -- the row marked
|
||||
# "importing", the queue behind it, the event that clears them -- is over
|
||||
@@ -346,7 +357,7 @@ tokens: [
|
||||
sha256: "$hash",
|
||||
),
|
||||
$salvaged],
|
||||
setups: [
|
||||
machines: [
|
||||
(
|
||||
id: "local",
|
||||
name: "sandbox",
|
||||
|
||||
Generated
+1
@@ -35,6 +35,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
|
||||
@@ -53,6 +53,12 @@ ureq = { version = "3", features = ["json"] }
|
||||
# both in the graph rustls refuses to auto-select one.
|
||||
rustls = "0.23"
|
||||
libc = "0.2.189"
|
||||
# One ISO-8601 timestamp: the reset time on the invented rate-limit window
|
||||
# an echo session's `/usage` puts up. Already in the tree behind the
|
||||
# certificate machinery, so this is a direct name for what is compiled
|
||||
# anyway rather than a new crate -- and the alternative was hand-rolling a
|
||||
# civil-from-days conversion to print one line.
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -207,6 +207,20 @@ mod tests {
|
||||
/// like any other.
|
||||
#[tokio::test]
|
||||
async fn a_spooled_enrollment_is_adopted_on_first_use() {
|
||||
// Under a subscriber, like every other exercise of this middleware.
|
||||
// `tracing` caches a callsite's interest process-wide the first time it
|
||||
// is reached, so the refusal at the end of this test -- reached with no
|
||||
// subscriber on this thread -- could cache the rejection warning as
|
||||
// never-enabled and make the tripwire above see an empty log. That
|
||||
// failed about one full-suite run in ten, in the test that exists to
|
||||
// notice a credential leak, which is the worst place for a flake.
|
||||
let _guard = tracing::subscriber::set_default(
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::TRACE)
|
||||
.with_writer(std::io::sink)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let manager = manager_with_token(dir.path(), "first");
|
||||
let spooled = generate_token();
|
||||
|
||||
+630
-47
@@ -21,6 +21,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use wg_app_link::format;
|
||||
|
||||
use crate::session::driver::Images;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Config {
|
||||
@@ -28,8 +30,28 @@ pub struct Config {
|
||||
/// credential. A list (of one, today) so per-device tokens with individual
|
||||
/// revocation are a config entry later, not a migration.
|
||||
pub tokens: Vec<TokenEntry>,
|
||||
pub setups: Vec<SetupConfig>,
|
||||
/// `setups` is the persisted spelling before the machine/provider boundary
|
||||
/// was named correctly. Read it once so an update does not discard the
|
||||
/// machines already configured; every subsequent write uses `machines`.
|
||||
#[serde(alias = "setups")]
|
||||
pub machines: Vec<MachineConfig>,
|
||||
/// Every session, in the order the reader has put them in -- what `GET
|
||||
/// /sessions` answers in and what every screen draws. A drag on the phone
|
||||
/// rewrites this (`POST /sessions/order`), and a spawn appends, so a new
|
||||
/// session arrives at the bottom rather than displacing anything.
|
||||
pub sessions: Vec<SessionConfig>,
|
||||
/// What a new session's thinking level is when nothing chose one.
|
||||
///
|
||||
/// Here rather than on a provider because providers are *discovered*: a
|
||||
/// default written onto one would be erased by the next rediscovery, which
|
||||
/// is the kind of setting that looks like it stuck until the day it did
|
||||
/// not. Here rather than on the phone because a second device would then
|
||||
/// spawn sessions the first one's owner did not expect.
|
||||
///
|
||||
/// `None` is the CLI's own default, and stays reachable: this is a level
|
||||
/// somebody chose, not a level this app picked for them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_effort: Option<String>,
|
||||
}
|
||||
|
||||
/// A machine, and the things it can run.
|
||||
@@ -40,8 +62,8 @@ pub struct Config {
|
||||
/// host and offered the whole cross-product.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetupConfig {
|
||||
/// Stable identifier, minted when the setup is added and never
|
||||
pub struct MachineConfig {
|
||||
/// Stable identifier, minted when the machine is added and never
|
||||
/// changed. Sessions reference this rather than the label, so
|
||||
/// renaming a machine on the phone does not orphan its sessions --
|
||||
/// which is the whole reason the two are separate fields.
|
||||
@@ -50,19 +72,19 @@ pub struct SetupConfig {
|
||||
/// How to reach it, absent for this machine.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ssh: Option<SshConfig>,
|
||||
/// What can be spawned here. Names are unique within a setup, and only
|
||||
/// What can be spawned here. Names are unique within a machine, and only
|
||||
/// within it: two machines may each have a `claude-cli`, which is the point.
|
||||
#[serde(default)]
|
||||
pub providers: Vec<ProviderConfig>,
|
||||
}
|
||||
|
||||
impl SetupConfig {
|
||||
impl MachineConfig {
|
||||
pub fn provider(&self, name: &str) -> Option<&ProviderConfig> {
|
||||
self.providers.iter().find(|provider| provider.name == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// One thing a setup can run: which driver, and how to invoke it.
|
||||
/// One thing a machine can run: which driver, and how to invoke it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderConfig {
|
||||
@@ -76,9 +98,59 @@ pub struct ProviderConfig {
|
||||
/// too; this is a shortcut list, not a restriction.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<String>,
|
||||
/// MCP servers whose tools this provider's sessions can use, on top of
|
||||
/// whatever the provider runs itself.
|
||||
///
|
||||
/// On the provider rather than the machine, because it is a statement
|
||||
/// about what a session can do rather than about where it runs -- and
|
||||
/// because only a driver that runs its own agent loop can use one. Today
|
||||
/// that is llama.cpp; the coding CLIs have their own MCP configuration
|
||||
/// and this would be a second, quieter answer to the same question.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub mcp_servers: Vec<McpServerConfig>,
|
||||
/// How each model this provider serves is loaded, by model key -- the
|
||||
/// settings in [`LLAMA_MODEL_PARAMS`].
|
||||
///
|
||||
/// On the model rather than on the session because one loaded model is
|
||||
/// what several sessions talk to: a machine's `llama-server` holds it
|
||||
/// once, and a context size or a layer count that two sessions disagreed
|
||||
/// about would be one of them being ignored. See `session::llama::router`.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub model_settings: BTreeMap<String, BTreeMap<String, String>>,
|
||||
/// How many models this provider's server keeps loaded at once before it
|
||||
/// evicts the least recently used. `None` is one, which is the right
|
||||
/// answer for a machine with one GPU -- see `router`'s `DEFAULT_MAX_LOADED`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_loaded: Option<u32>,
|
||||
}
|
||||
|
||||
/// How to reach a setup that isn't this machine, with the system `ssh` client
|
||||
/// An MCP server reached over HTTP.
|
||||
///
|
||||
/// A URL and nothing else: this backend connects to remote servers rather than
|
||||
/// spawning local ones, so there is no command, no arguments and no
|
||||
/// environment to configure. See `session::llama::mcp` for why that is the
|
||||
/// shape -- in short, it is what llama.cpp's own web UI does, and it keeps the
|
||||
/// tools on the machine with a route out rather than the machine with the GPU.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpServerConfig {
|
||||
/// Prefixes every tool this server offers, so two servers with a `search`
|
||||
/// are two tools. Also what a failure to connect is named by.
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl ProviderConfig {
|
||||
/// The executable to run for this provider: its override, or its kind's
|
||||
/// default.
|
||||
pub fn program(&self) -> &str {
|
||||
self.command
|
||||
.as_deref()
|
||||
.unwrap_or(self.kind.default_program())
|
||||
}
|
||||
}
|
||||
|
||||
/// How to reach a machine that isn't this machine, with the system `ssh` client
|
||||
/// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
|
||||
/// one place to configure connections. A remote session is the identical
|
||||
/// command with `ssh host …` in front, and nothing downstream knows.
|
||||
@@ -94,6 +166,19 @@ pub struct SshConfig {
|
||||
/// Extra `-o` settings, each written as `Key=value`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub options: Vec<String>,
|
||||
/// Where this machine keeps the GGUF models it can serve, absent for
|
||||
/// the same default this backend uses (`~/.local/share/ai-app/models`
|
||||
/// -- `$XDG_DATA_HOME` is not read on the far side, since it is this
|
||||
/// machine's environment that would answer). A `~` prefix is the
|
||||
/// remote home.
|
||||
///
|
||||
/// Here rather than on the provider because it is a fact about the
|
||||
/// machine, and because a machine reached over ssh is where the model
|
||||
/// has to be: a llama.cpp session serves the file from the machine
|
||||
/// that runs `llama-server`, and this backend's own downloads are on
|
||||
/// whichever machine that is only when they are the same one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub models_dir: Option<PathBuf>,
|
||||
/// Where a file attached from the phone is put on this machine so the
|
||||
/// session can read it. Absent means the session's own working directory,
|
||||
/// or the login home for a session that has none. A `~` prefix is the
|
||||
@@ -121,6 +206,8 @@ pub enum DriverKind {
|
||||
/// The Claude Code CLI over stream-json. Named for the CLI specifically:
|
||||
/// bare "claude" would suggest the credit-billed API, which this is not.
|
||||
ClaudeCli,
|
||||
/// The Codex CLI's persistent app-server JSONL protocol.
|
||||
CodexCli,
|
||||
}
|
||||
|
||||
impl DriverKind {
|
||||
@@ -141,29 +228,390 @@ impl DriverKind {
|
||||
pub fn max_image_edge(self) -> Option<u32> {
|
||||
match self {
|
||||
DriverKind::ClaudeCli => Some(1568),
|
||||
DriverKind::Echo | DriverKind::LlamaCpp => None,
|
||||
DriverKind::Echo | DriverKind::LlamaCpp | DriverKind::CodexCli => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a session of this kind makes of an image attached to a message.
|
||||
///
|
||||
/// The provider's own answer, which for every CLI here is that it takes
|
||||
/// them. llama.cpp is [`Images::Unknown`] because the question is not the
|
||||
/// provider's to answer: a local model reads images only if its file has a
|
||||
/// projector loaded beside it, and the server that loaded it is the only
|
||||
/// thing that knows -- `LlamaDriver::images` gives the live answer once
|
||||
/// there is one, and this is what a session with no process yet reports.
|
||||
pub fn images(self) -> Images {
|
||||
match self {
|
||||
DriverKind::Echo | DriverKind::ClaudeCli | DriverKind::CodexCli => Images::Accepted,
|
||||
DriverKind::LlamaCpp => Images::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which paid service meters a session of this kind, and `None` for one
|
||||
/// that costs nothing.
|
||||
///
|
||||
/// What decides which account -- if any -- a rate-limit bar is about is the
|
||||
/// provider a session runs, not the machine it runs on: an echo session on
|
||||
/// a machine that also has the Claude CLI was drawn with that CLI's
|
||||
/// five-hour window, a quota it cannot spend.
|
||||
///
|
||||
/// Echo names a meter of its own that exists only when a test has asked for
|
||||
/// one (`/usage` in `session::echo`), which is how the bar's states are
|
||||
/// reached without an account. With none set there is no snapshot, and the
|
||||
/// phone draws nothing.
|
||||
///
|
||||
/// The string is a [`crate::usage::UsageProvider::name`], and it is what
|
||||
/// pairs a session with one of `GET /usage`'s snapshots -- so
|
||||
/// `usage::providers_for` reads this rather than matching on kinds again.
|
||||
pub fn usage_provider(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::ClaudeCli => Some(crate::usage::CLAUDE),
|
||||
Self::CodexCli => Some(crate::usage::CODEX),
|
||||
Self::Echo => Some(crate::usage::ECHO),
|
||||
Self::LlamaCpp => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The executable a provider of this kind runs when it names none.
|
||||
///
|
||||
/// Here rather than at each spawn site because it is not only the spawn
|
||||
/// that runs it: `usage` runs provider CLIs too, so a default that
|
||||
/// disagreed with the driver's would ask the wrong binary on a machine
|
||||
/// with two installs.
|
||||
pub fn default_program(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClaudeCli => "claude",
|
||||
Self::CodexCli => "codex",
|
||||
Self::LlamaCpp => "llama-server",
|
||||
// Echo is translated in-process; nothing is spawned for it.
|
||||
Self::Echo => "echo",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the conversation exists outside this app, so that deleting the
|
||||
/// session here does not end it.
|
||||
///
|
||||
/// The Claude Code CLI owns its own transcript and is resumable from it
|
||||
/// whatever started it, so a session this app spawned is every bit as
|
||||
/// recoverable as one it imported. Echo has nothing to keep, and a llama
|
||||
/// Claude Code and Codex own their transcripts and can resume them
|
||||
/// independently of this app. Echo has nothing to keep, and a llama
|
||||
/// session's conversation is folded out of *this* app's transcript.
|
||||
///
|
||||
/// Asked before warning somebody that a deletion cannot be undone, which is
|
||||
/// the one sentence that has to be true: said of a session that can in fact
|
||||
/// be brought back, it spends the credibility the warning needs.
|
||||
pub fn keeps_own_transcript(self) -> bool {
|
||||
self.own_transcript_name().is_some()
|
||||
}
|
||||
|
||||
/// The product whose durable transcript survives an ordinary app delete.
|
||||
/// Reported to the phone because a provider's configured name is not the
|
||||
/// name of its storage, and calling Codex's rollout a Claude transcript is
|
||||
/// especially misleading on an irreversible switch.
|
||||
pub fn own_transcript_name(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::ClaudeCli => true,
|
||||
Self::ClaudeCli => Some("Claude Code"),
|
||||
Self::CodexCli => Some("Codex"),
|
||||
Self::Echo | Self::LlamaCpp => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a thinking level means anything to this kind, so the phone can
|
||||
/// offer the control only where it does something.
|
||||
///
|
||||
/// Reported from here rather than decided on the phone, and asked of the
|
||||
/// *kind* rather than branched on: the alternative is the session-type
|
||||
/// `if` this app does not have anywhere else. Coding CLIs take an effort
|
||||
/// setting; a llama session's sampling is `params`, and echo does not think.
|
||||
///
|
||||
/// It matters more than a control that would simply do nothing, because
|
||||
/// choosing a level stops the process -- so on a session that cannot use
|
||||
/// one it is a button whose only effect is the cost.
|
||||
pub fn takes_effort(self) -> bool {
|
||||
match self {
|
||||
Self::ClaudeCli | Self::CodexCli => true,
|
||||
Self::Echo | Self::LlamaCpp => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Permission choices the phone can offer for this kind, in display order.
|
||||
/// The driver remains responsible for translating these stable values into
|
||||
/// its CLI's arguments or control protocol.
|
||||
pub fn permission_modes(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::ClaudeCli => &["manual", "acceptEdits", "auto", "bypassPermissions", "plan"],
|
||||
Self::CodexCli => &["workspace-write", "read-only", "danger-full-access"],
|
||||
// Named by the driver that enforces them rather than repeated
|
||||
// here: this list and the one the gate matches on being two
|
||||
// literals is how a mode comes to be offered and then refused.
|
||||
Self::LlamaCpp => crate::session::llama::PERMISSION_MODES,
|
||||
Self::Echo => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings this kind of session takes beyond the shared ones, for
|
||||
/// the phone to offer.
|
||||
///
|
||||
/// Declared rather than drawn: a spawn screen with a field per llama
|
||||
/// setting is a screen that has to be edited every time a driver grows
|
||||
/// one, and this app already has the `params` map to carry them. So the
|
||||
/// server says what a provider takes and the phone renders it, which is
|
||||
/// the same arrangement `permission_modes` uses and for the same reason
|
||||
/// -- the alternative is two lists that disagree, one of them in Kotlin.
|
||||
///
|
||||
/// It is also what keeps these *reachable at all*. Several were hardcoded
|
||||
/// to the values measured on one machine, which is fine as a default and
|
||||
/// wrong as a constant: the next machine has a different GPU and a
|
||||
/// different number of cores, and nobody running this app can edit the
|
||||
/// source.
|
||||
pub fn params(self) -> &'static [ParamSpec] {
|
||||
match self {
|
||||
Self::LlamaCpp => LLAMA_PARAMS,
|
||||
Self::Echo | Self::ClaudeCli | Self::CodexCli => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// What a *model* this kind serves takes, which is nothing for a kind
|
||||
/// that does not load models of its own. Separate from [`params`] because
|
||||
/// the two have different owners, not different shapes: a session's ride
|
||||
/// on its requests, a model's decide how the machine loads it.
|
||||
pub fn model_params(self) -> &'static [ParamSpec] {
|
||||
match self {
|
||||
Self::LlamaCpp => LLAMA_MODEL_PARAMS,
|
||||
Self::Echo | Self::ClaudeCli | Self::CodexCli => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode used when a new-session form first selects this kind.
|
||||
pub fn default_permission_mode(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::ClaudeCli => Some("auto"),
|
||||
Self::CodexCli => Some("workspace-write"),
|
||||
Self::LlamaCpp => Some(crate::session::llama::DEFAULT_PERMISSION_MODE),
|
||||
Self::Echo => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One setting a provider takes, and enough about it to draw a control.
|
||||
///
|
||||
/// Deliberately thin: a key, words for a person, and which shape the value
|
||||
/// has. Anything richer -- units, validation, dependencies between settings --
|
||||
/// would be a schema language, and what the phone needs is a text field or a
|
||||
/// row of chips.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ParamSpec {
|
||||
/// The `SessionConfig::params` key this writes.
|
||||
pub key: &'static str,
|
||||
pub label: &'static str,
|
||||
/// What happens when it is not set, in words. Shown where a control shows
|
||||
/// its placeholder, so "blank" always means something specific rather than
|
||||
/// leaving the reader to guess whether it means zero.
|
||||
pub unset: &'static str,
|
||||
/// Flattened, so a spec is one flat object: `kind` beside the rest rather
|
||||
/// than an object of its own with `kind` inside it.
|
||||
#[serde(flatten)]
|
||||
pub kind: ParamKind,
|
||||
/// Whether changing it waits for the process to start again.
|
||||
///
|
||||
/// The honest half of offering these live. A sampling setting rides on the
|
||||
/// next request; a server flag was decided when the model was loaded, and
|
||||
/// a control that silently did nothing until some later restart would be
|
||||
/// worse than one that is not there.
|
||||
pub restart: bool,
|
||||
}
|
||||
|
||||
/// What shape a [`ParamSpec`]'s value has.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum ParamKind {
|
||||
Integer,
|
||||
Decimal,
|
||||
Text,
|
||||
/// Text that is a paragraph rather than a value: several lines of it, and
|
||||
/// the reader is writing rather than filling in. Its own kind because the
|
||||
/// control genuinely differs -- a system prompt in a one-line box shows
|
||||
/// six words of itself -- and because that is a fact about the setting
|
||||
/// rather than a styling choice for the phone to guess at.
|
||||
Prose,
|
||||
/// A fixed set. **The first option is what leaving it unset means**, and
|
||||
/// choosing it clears the setting rather than storing a value -- so the
|
||||
/// default is a state the picker can return to, and the stored config
|
||||
/// does not fill up with values nobody chose.
|
||||
Choice {
|
||||
options: &'static [&'static str],
|
||||
},
|
||||
}
|
||||
|
||||
/// What a llama.cpp **session** takes: everything that rides on a request.
|
||||
///
|
||||
/// Nothing here waits for a restart, and that is a property of the split
|
||||
/// rather than a coincidence. A machine's `llama-server` holds one copy of a
|
||||
/// model for every session using it, so how that model is *loaded* cannot be
|
||||
/// one session's to decide -- those settings are [`LLAMA_MODEL_PARAMS`],
|
||||
/// against the model on its machine.
|
||||
const LLAMA_PARAMS: &[ParamSpec] = &[
|
||||
ParamSpec {
|
||||
key: "tools",
|
||||
label: "Tools",
|
||||
// Worth a control rather than a constant because of what it costs:
|
||||
// a prompt measured 2,181 tokens with all seven and 698 with none,
|
||||
// every turn, before anything is said. A filter this backend applies
|
||||
// to what the machine's server offers, so unlike the flag it replaced
|
||||
// it takes effect on the next message.
|
||||
unset: "all of them -- or a comma-separated list, or \"none\"",
|
||||
kind: ParamKind::Text,
|
||||
restart: false,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "thinking",
|
||||
label: "Thinking",
|
||||
// Rides on the request, like the sampling settings below it: the level
|
||||
// is a chat-template argument rather than a server flag, so a session
|
||||
// changes how hard it thinks without reloading its model.
|
||||
unset: "however hard the model thinks by default",
|
||||
// Every level any of these templates uses, because which of them a
|
||||
// *particular* model takes is the model's business and only the loaded
|
||||
// one can answer it -- the driver asks it (`thinking_options`) and says
|
||||
// what it takes when a level it cannot is chosen. "off" is the one that
|
||||
// is not a level: it asks the template for no thinking at all.
|
||||
kind: ParamKind::Choice {
|
||||
options: &["auto", "off", "low", "medium", "high", "xhigh", "max"],
|
||||
},
|
||||
restart: false,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "systemPrompt",
|
||||
label: "System prompt",
|
||||
// In front of the conversation on every request rather than recorded
|
||||
// in it: it is a setting, so changing it takes effect on the next
|
||||
// message rather than leaving a transcript that says otherwise. The
|
||||
// model's own template still supplies whatever it supplies; this is
|
||||
// added to that, which is why leaving it blank is not "no system
|
||||
// prompt" but "nothing of ours".
|
||||
unset: "nothing beyond what the model's own template says",
|
||||
kind: ParamKind::Prose,
|
||||
restart: false,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
unset: "llama.cpp's default",
|
||||
kind: ParamKind::Decimal,
|
||||
restart: false,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "topP",
|
||||
label: "Top P",
|
||||
unset: "llama.cpp's default",
|
||||
kind: ParamKind::Decimal,
|
||||
restart: false,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "topK",
|
||||
label: "Top K",
|
||||
unset: "llama.cpp's default",
|
||||
kind: ParamKind::Integer,
|
||||
restart: false,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "maxTokens",
|
||||
label: "Reply limit",
|
||||
unset: "no limit",
|
||||
kind: ParamKind::Integer,
|
||||
restart: false,
|
||||
},
|
||||
];
|
||||
|
||||
/// What a llama.cpp **model** takes: everything that decides how it is loaded.
|
||||
///
|
||||
/// Per model on its machine rather than per session, because one loaded copy
|
||||
/// is what every session on that model is talking to. Changing one reloads
|
||||
/// that model -- for everybody using it, which is the honest consequence of
|
||||
/// sharing it and is what the machine's provider view says before saving.
|
||||
///
|
||||
/// Every key here is written into the router's preset file as
|
||||
/// `llama-server`'s own argument name, so adding a setting is a row here and a
|
||||
/// row in `session::llama::router`'s `section`.
|
||||
pub const LLAMA_MODEL_PARAMS: &[ParamSpec] = &[
|
||||
ParamSpec {
|
||||
key: "contextSize",
|
||||
label: "Context size",
|
||||
unset: "the model's own trained context",
|
||||
kind: ParamKind::Integer,
|
||||
// Every one of these does, which is what makes them the model's: see
|
||||
// the doc comment above.
|
||||
restart: true,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "gpuLayers",
|
||||
label: "Layers on the GPU",
|
||||
unset: "as many as fit",
|
||||
kind: ParamKind::Integer,
|
||||
restart: true,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "threads",
|
||||
label: "Threads",
|
||||
unset: "one per core",
|
||||
kind: ParamKind::Integer,
|
||||
restart: true,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "slots",
|
||||
label: "Sessions answered at once",
|
||||
// One, so that a second session's turn waits rather than splitting the
|
||||
// model's cache. Measured 2026-09-19 on the 27B here: 41.5 tok/s
|
||||
// plain, 61.4 with the draft head at one slot, and 28 with the head at
|
||||
// four -- speculating against a split cache is slower than not
|
||||
// speculating at all. Worth raising on a machine where several
|
||||
// sessions really are used together and drafting does not pay.
|
||||
unset: "one -- a second session's turn waits for the first",
|
||||
kind: ParamKind::Integer,
|
||||
restart: true,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "flashAttention",
|
||||
label: "Flash attention",
|
||||
// llama.cpp's `auto` is the right default and stays it. The control
|
||||
// is here because a model's publisher can ask for `on` outright --
|
||||
// Prism ML's ternary Bonsai does -- which is a statement about the
|
||||
// file that `auto` would be taking from the backend instead.
|
||||
unset: "llama.cpp's own choice",
|
||||
kind: ParamKind::Choice {
|
||||
options: &["auto", "on", "off"],
|
||||
},
|
||||
restart: true,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "speculative",
|
||||
label: "Speculative decoding",
|
||||
unset: "on, for a model whose file carries a draft head",
|
||||
kind: ParamKind::Choice {
|
||||
options: &["auto", "off"],
|
||||
},
|
||||
restart: true,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "mmproj",
|
||||
label: "Vision projector",
|
||||
// Found rather than asked for, because a repository publishing a
|
||||
// vision model publishes the projector beside it -- so this is for the
|
||||
// two cases finding it cannot cover: a repository that published more
|
||||
// than one, and a model whose projector somebody would rather not
|
||||
// spend the memory on.
|
||||
unset: "the mmproj file beside the model -- a file name, or \"off\" for none",
|
||||
kind: ParamKind::Text,
|
||||
restart: true,
|
||||
},
|
||||
ParamSpec {
|
||||
key: "specDraftNMax",
|
||||
label: "Tokens drafted ahead",
|
||||
unset: "llama.cpp's own default",
|
||||
kind: ParamKind::Integer,
|
||||
restart: true,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TokenEntry {
|
||||
@@ -178,12 +626,15 @@ pub struct TokenEntry {
|
||||
pub struct SessionConfig {
|
||||
/// Stable identifier; names the session's directory and its routes.
|
||||
pub id: String,
|
||||
/// Id of the [`SetupConfig`] this session runs on -- the id, not the label,
|
||||
/// Id of the [`MachineConfig`] this session runs on -- the id, not the label,
|
||||
/// so the machine can be renamed without losing its sessions.
|
||||
pub setup: String,
|
||||
/// Name of the provider within that setup. Both stored by name rather than
|
||||
/// resolved, so an edited setup takes effect on the next relaunch; a session
|
||||
/// whose setup or provider is gone reports as exited and can still be
|
||||
/// `setup` is accepted only as the on-disk migration from builds that used
|
||||
/// that word for a machine. The API and newly written records say `machine`.
|
||||
#[serde(alias = "setup")]
|
||||
pub machine: String,
|
||||
/// Name of the provider within that machine. Both stored by name rather than
|
||||
/// resolved, so an edited machine takes effect on the next relaunch; a session
|
||||
/// whose machine or provider is gone reports as exited and can still be
|
||||
/// deleted.
|
||||
pub provider: String,
|
||||
pub title: String,
|
||||
@@ -191,11 +642,19 @@ pub struct SessionConfig {
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// Claude permission mode chosen at spawn. Kept as a string because it is
|
||||
/// passed straight to `--permission-mode` rather than interpreted here, so
|
||||
/// the CLI stays the one authority on which modes exist.
|
||||
/// Provider permission mode chosen at spawn. Kept as a string because the
|
||||
/// driver maps it onto its CLI rather than shared code interpreting it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub permission_mode: Option<String>,
|
||||
/// How hard the model thinks. A string for the same reason
|
||||
/// `permission_mode` is: the CLI owns which levels exist.
|
||||
///
|
||||
/// This is settled at launch and `None` means whatever the CLI's own
|
||||
/// default is. That is a state the phone has to be able to *choose*, not
|
||||
/// just start in, which is why it is an option rather than a level with a
|
||||
/// default written here.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<String>,
|
||||
/// Settings the driver interprets, chosen at spawn.
|
||||
///
|
||||
/// Deliberately untyped: what a temperature or a context size means is the
|
||||
@@ -215,6 +674,30 @@ pub struct SessionConfig {
|
||||
/// turned off in one tap where one that never arrived is not diagnosable.
|
||||
#[serde(default = "notify_default")]
|
||||
pub notify: bool,
|
||||
/// Whether a session stopped by the account's usage limit sends itself a
|
||||
/// message once the limit lifts, instead of waiting for a person.
|
||||
///
|
||||
/// Off unless somebody asked for it. It spends quota the moment it becomes
|
||||
/// available and it does so while nobody is looking, which is exactly the
|
||||
/// kind of thing that must not happen because a default said so.
|
||||
#[serde(default, skip_serializing_if = "not_set")]
|
||||
pub auto_resume: bool,
|
||||
/// What that message says. `None` is [`DEFAULT_RESUME_MESSAGE`], and stays
|
||||
/// reachable: it is this app's word, not one somebody chose, so clearing
|
||||
/// the field goes back to it rather than sending an empty message.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_resume_message: Option<String>,
|
||||
/// The message this session owes itself once the limit lifts, and when to
|
||||
/// try. Written when a limit is hit, moved when the wait turns out to be
|
||||
/// wrong, and cleared when the message goes out or auto-resume is turned
|
||||
/// off -- see [`ScheduledResume`].
|
||||
///
|
||||
/// Persisted rather than held in memory because the wait outlives the
|
||||
/// process doing it: a five-hour window and a weekly one both routinely
|
||||
/// outlast a backend restart, and a resume forgotten across one is a
|
||||
/// session that silently never comes back.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resume: Option<ScheduledResume>,
|
||||
/// Whether this session's process is stopped when the server exits, instead
|
||||
/// of being left running for the next start to adopt.
|
||||
///
|
||||
@@ -231,6 +714,28 @@ pub struct SessionConfig {
|
||||
pub created: f64,
|
||||
}
|
||||
|
||||
/// A message owed to a session whose account ran out, and when to try sending
|
||||
/// it.
|
||||
///
|
||||
/// `since` is the whole reason this is a struct: the wait is rescheduled every
|
||||
/// time the meter is asked and still says no, so `at` alone cannot say how long
|
||||
/// this has been going on -- and something has to, or a machine that can never
|
||||
/// be asked is retried until somebody notices. See `crate::resume`.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScheduledResume {
|
||||
/// Epoch seconds: when the limit is next worth checking. Never a promise
|
||||
/// that the message goes out then -- the meter is asked first.
|
||||
pub at: f64,
|
||||
/// Epoch seconds the limit was hit.
|
||||
pub since: f64,
|
||||
}
|
||||
|
||||
/// What an auto-resume says when nothing else was chosen. One word, because
|
||||
/// the session already knows what it was doing and this is only the nudge that
|
||||
/// lets it carry on.
|
||||
pub const DEFAULT_RESUME_MESSAGE: &str = "continue";
|
||||
|
||||
fn notify_default() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -241,17 +746,17 @@ fn not_set(flag: &bool) -> bool {
|
||||
!*flag
|
||||
}
|
||||
|
||||
/// The name of the echo provider, and of the setup this machine gets on first
|
||||
/// The name of the echo provider, and of the local machine created on first
|
||||
/// run.
|
||||
///
|
||||
/// Echo is seeded into the config rather than conjured at read time. An
|
||||
/// implicit provider is one a person cannot see in the file or edit from the
|
||||
/// phone; if somebody deletes it, that was a choice.
|
||||
pub const ECHO_PROVIDER: &str = "echo";
|
||||
pub const LOCAL_SETUP: &str = "this machine";
|
||||
/// The id of the setup a fresh install seeds. Fixed rather than random so a
|
||||
pub const LOCAL_MACHINE: &str = "this machine";
|
||||
/// The id of the machine a fresh install seeds. Fixed rather than random so a
|
||||
/// hand-written config can name it without looking one up.
|
||||
pub const LOCAL_SETUP_ID: &str = "local";
|
||||
pub const LOCAL_MACHINE_ID: &str = "local";
|
||||
|
||||
/// Where `ai-server --enroll-link` leaves a token for the running server to
|
||||
/// adopt: beside the config, since it is config in transit.
|
||||
@@ -260,15 +765,15 @@ pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn setup(&self, id: &str) -> Option<&SetupConfig> {
|
||||
self.setups.iter().find(|setup| setup.id == id)
|
||||
pub fn machine(&self, id: &str) -> Option<&MachineConfig> {
|
||||
self.machines.iter().find(|machine| machine.id == id)
|
||||
}
|
||||
|
||||
/// A setup by the label a person sees, for messages and for the one place a
|
||||
/// A machine by the label a person sees, for messages and for the one place a
|
||||
/// name still arrives from outside. Nothing else should look one up this
|
||||
/// way, since labels are editable and ids are not.
|
||||
pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> {
|
||||
self.setups.iter().find(|setup| setup.name == name)
|
||||
pub fn machine_named(&self, name: &str) -> Option<&MachineConfig> {
|
||||
self.machines.iter().find(|machine| machine.name == name)
|
||||
}
|
||||
|
||||
/// This machine, offering whatever was found on it.
|
||||
@@ -277,10 +782,10 @@ impl Config {
|
||||
/// be *discovered*: a hardcoded list is a claim about what is installed, and
|
||||
/// this one was wrong -- every fresh install asserted a `claude-cli`
|
||||
/// provider whether or not `claude` existed.
|
||||
pub fn seed(providers: Vec<ProviderConfig>) -> SetupConfig {
|
||||
SetupConfig {
|
||||
id: LOCAL_SETUP_ID.to_string(),
|
||||
name: LOCAL_SETUP.to_string(),
|
||||
pub fn seed(providers: Vec<ProviderConfig>) -> MachineConfig {
|
||||
MachineConfig {
|
||||
id: LOCAL_MACHINE_ID.to_string(),
|
||||
name: LOCAL_MACHINE.to_string(),
|
||||
ssh: None,
|
||||
providers,
|
||||
}
|
||||
@@ -295,13 +800,20 @@ impl Config {
|
||||
kind: DriverKind::Echo,
|
||||
command: None,
|
||||
models: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
model_settings: BTreeMap::new(),
|
||||
max_loaded: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => format::parse(&text)
|
||||
.with_context(|| format!("{} is not valid config RON", path.display())),
|
||||
.with_context(|| format!("{} is not valid config RON", path.display()))
|
||||
.map(|mut config| {
|
||||
Self::adopt_mcp_defaults(&mut config);
|
||||
config
|
||||
}),
|
||||
// A first run has no config -- the normal starting state; a token is
|
||||
// generated and saved on that first start.
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
@@ -312,6 +824,29 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives a provider written before `mcp_servers` existed the defaults a
|
||||
/// probe would give it now.
|
||||
///
|
||||
/// A migration, and a temporary one: the field arrived on 2026-09-19 and a
|
||||
/// machine discovered before then has an empty list, which reads on screen
|
||||
/// exactly like a machine somebody chose to give no MCP servers -- so a
|
||||
/// llama session silently had no web search and nothing said why. Pressing
|
||||
/// Rediscover fixes it, which is not something a person can be expected to
|
||||
/// know. Delete this once every config here has been through it.
|
||||
///
|
||||
/// Empty rather than absent is the condition, because there is no way to
|
||||
/// remove one from the phone: nothing can have chosen the empty list yet.
|
||||
fn adopt_mcp_defaults(&mut self) {
|
||||
for provider in self
|
||||
.machines
|
||||
.iter_mut()
|
||||
.flat_map(|machine| machine.providers.iter_mut())
|
||||
.filter(|provider| provider.mcp_servers.is_empty())
|
||||
{
|
||||
provider.mcp_servers = crate::machines::mcp_defaults(provider.kind);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the config, owner-readable only.
|
||||
///
|
||||
/// The token hashes here are verifiers rather than secrets, but the file
|
||||
@@ -355,11 +890,11 @@ mod tests {
|
||||
let path = dir.path().join("config.ron");
|
||||
|
||||
// A missing file is the ordinary first-run state, not an error.
|
||||
// Nothing is conjured to fill it: the seed setup is written by the
|
||||
// Nothing is conjured to fill it: the seed machine is written by the
|
||||
// manager, so the file always says what there is.
|
||||
let first_run = Config::load(&path).expect("load");
|
||||
assert!(first_run.tokens.is_empty());
|
||||
assert!(first_run.setups.is_empty());
|
||||
assert!(first_run.machines.is_empty());
|
||||
assert!(first_run.sessions.is_empty());
|
||||
|
||||
let config = Config {
|
||||
@@ -367,7 +902,7 @@ mod tests {
|
||||
name: "phone".to_string(),
|
||||
sha256: "ab".repeat(32),
|
||||
}],
|
||||
setups: vec![
|
||||
machines: vec![
|
||||
Config::seed(vec![
|
||||
Config::echo_provider(),
|
||||
ProviderConfig {
|
||||
@@ -375,9 +910,12 @@ mod tests {
|
||||
kind: DriverKind::ClaudeCli,
|
||||
command: Some("/usr/bin/claude".to_string()),
|
||||
models: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
model_settings: BTreeMap::new(),
|
||||
max_loaded: None,
|
||||
},
|
||||
]),
|
||||
SetupConfig {
|
||||
MachineConfig {
|
||||
id: "vm".to_string(),
|
||||
name: "the vm".to_string(),
|
||||
ssh: Some(SshConfig {
|
||||
@@ -385,6 +923,7 @@ mod tests {
|
||||
port: Some(2222),
|
||||
identity_file: None,
|
||||
options: Vec::new(),
|
||||
models_dir: None,
|
||||
attachments_dir: None,
|
||||
}),
|
||||
providers: vec![ProviderConfig {
|
||||
@@ -392,19 +931,27 @@ mod tests {
|
||||
kind: DriverKind::ClaudeCli,
|
||||
command: None,
|
||||
models: vec!["haiku".to_string()],
|
||||
mcp_servers: Vec::new(),
|
||||
model_settings: BTreeMap::new(),
|
||||
max_loaded: None,
|
||||
}],
|
||||
},
|
||||
],
|
||||
default_effort: Some("low".to_string()),
|
||||
sessions: vec![SessionConfig {
|
||||
id: "abc123".to_string(),
|
||||
setup: "vm".to_string(),
|
||||
machine: "vm".to_string(),
|
||||
provider: "claude-cli".to_string(),
|
||||
title: "test".to_string(),
|
||||
model: None,
|
||||
cwd: None,
|
||||
permission_mode: None,
|
||||
effort: None,
|
||||
params: BTreeMap::new(),
|
||||
notify: true,
|
||||
auto_resume: false,
|
||||
auto_resume_message: None,
|
||||
resume: None,
|
||||
throwaway: false,
|
||||
created: 1234.5,
|
||||
}],
|
||||
@@ -413,14 +960,14 @@ mod tests {
|
||||
|
||||
let loaded = Config::load(&path).expect("reload");
|
||||
assert_eq!(loaded.tokens[0].name, "phone");
|
||||
assert_eq!(loaded.sessions[0].setup, "vm");
|
||||
assert_eq!(loaded.sessions[0].machine, "vm");
|
||||
// The label and the id are separate, and the session holds the id.
|
||||
assert_eq!(loaded.setup("vm").expect("setup").name, "the vm");
|
||||
assert_eq!(loaded.machine("vm").expect("machine").name, "the vm");
|
||||
assert_eq!(loaded.sessions[0].provider, "claude-cli");
|
||||
assert_eq!(
|
||||
loaded
|
||||
.setup("vm")
|
||||
.expect("setup")
|
||||
.machine("vm")
|
||||
.expect("machine")
|
||||
.ssh
|
||||
.as_ref()
|
||||
.expect("ssh")
|
||||
@@ -428,15 +975,21 @@ mod tests {
|
||||
Some(2222),
|
||||
);
|
||||
// The same provider name on two machines is the point, not a
|
||||
// collision: names are unique within a setup and only within one.
|
||||
// collision: names are unique within a machine and only within one.
|
||||
assert!(
|
||||
loaded
|
||||
.setup(LOCAL_SETUP_ID)
|
||||
.machine(LOCAL_MACHINE_ID)
|
||||
.expect("local")
|
||||
.provider("claude-cli")
|
||||
.is_some()
|
||||
);
|
||||
assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none());
|
||||
assert!(
|
||||
loaded
|
||||
.machine(LOCAL_MACHINE_ID)
|
||||
.expect("local")
|
||||
.ssh
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// The house rule both halves of `format` depend on: what is written
|
||||
// is the *body* of the struct, with no outer parentheses and
|
||||
@@ -458,6 +1011,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_setup_spelling_from_existing_configs_but_writes_machine_spelling() {
|
||||
let old = r#"
|
||||
setups: [(
|
||||
id: "vm",
|
||||
name: "the vm",
|
||||
providers: [],
|
||||
)],
|
||||
sessions: [(
|
||||
id: "abc123",
|
||||
setup: "vm",
|
||||
provider: "echo",
|
||||
title: "old words",
|
||||
created: 1234.5,
|
||||
)],
|
||||
"#;
|
||||
let config: Config = format::parse(old).expect("old setup spelling still loads");
|
||||
assert_eq!(config.machines[0].id, "vm");
|
||||
assert_eq!(config.sessions[0].machine, "vm");
|
||||
|
||||
let written = format::render(&config).expect("render migrated config");
|
||||
assert!(written.contains("machines:"), "{written}");
|
||||
assert!(written.contains("machine: \"vm\""), "{written}");
|
||||
assert!(!written.contains("setups:"), "{written}");
|
||||
assert!(!written.contains("setup: \"vm\""), "{written}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The seed is this machine and nothing more: a name, no ssh, and
|
||||
/// exactly the providers it was handed.
|
||||
@@ -469,7 +1049,7 @@ mod tests {
|
||||
/// this can check is that the seed does not invent anything.
|
||||
fn the_seed_is_this_machine_and_claims_only_what_it_was_given() {
|
||||
let seed = Config::seed(vec![Config::echo_provider()]);
|
||||
assert_eq!(seed.name, LOCAL_SETUP);
|
||||
assert_eq!(seed.name, LOCAL_MACHINE);
|
||||
assert!(seed.ssh.is_none());
|
||||
assert_eq!(
|
||||
seed.provider(ECHO_PROVIDER).expect("echo").kind,
|
||||
@@ -488,6 +1068,9 @@ mod tests {
|
||||
kind: DriverKind::ClaudeCli,
|
||||
command: Some("/usr/bin/claude".to_string()),
|
||||
models: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
model_settings: BTreeMap::new(),
|
||||
max_loaded: None,
|
||||
},
|
||||
]);
|
||||
assert_eq!(
|
||||
|
||||
+54
-2
@@ -1,7 +1,7 @@
|
||||
//! Reading and changing files on the machine a setup names.
|
||||
//! Reading and changing files on a configured machine.
|
||||
//!
|
||||
//! Every operation here is one small POSIX shell script handed to `Transport`,
|
||||
//! the way `setups::discover` and `import::list` already ask a machine a
|
||||
//! the way `machines::discover` and `import::list` already ask a machine a
|
||||
//! question. That is what makes the local and the ssh case one implementation:
|
||||
//! a second one written against `std::fs` would be the one that gets tested,
|
||||
//! and the remote half -- the ordering of entries, what a symlink reports, how
|
||||
@@ -176,6 +176,34 @@ pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The absolute path `path` names on that machine, with a leading `~` expanded
|
||||
/// *there*.
|
||||
///
|
||||
/// What [`list`] answers as its `path`, asked on its own: a caller that has to
|
||||
/// hand a directory to something with no shell in front of it needs the
|
||||
/// resolved form and nothing else. `llama-server` is the one such caller --
|
||||
/// its tools take a working directory as a request header and `chdir` to it
|
||||
/// literally, so the `~` that every other path in this server carries through
|
||||
/// to the far side's shell arrived there as a directory called `~`, and every
|
||||
/// tool that uses one failed with "failed to spawn process".
|
||||
///
|
||||
/// Blocking because a driver's launch is, and this is a question for the
|
||||
/// machine that will serve the session rather than for this one: a remote
|
||||
/// `~` is the remote home, and expanding it here would name a directory on
|
||||
/// the wrong machine -- which is also why the answer is not cached anywhere
|
||||
/// but on the driver that asked.
|
||||
pub fn resolve_blocking(transport: &Transport, path: &str) -> Result<String> {
|
||||
let script = format!("{PATH_PRELUDE}cd -- \"$p\" && pwd -P");
|
||||
let out = transport.capture_blocking(&launch(script, path, None))?;
|
||||
// Only the newline `pwd` ends with: a directory name may legitimately end
|
||||
// in a space, and trimming whitespace would rename it.
|
||||
let resolved = out.trim_end_matches('\n');
|
||||
if resolved.is_empty() {
|
||||
anyhow::bail!("the machine did not say what {path} resolves to");
|
||||
}
|
||||
Ok(resolved.to_string())
|
||||
}
|
||||
|
||||
/// The `find` output above, as rows. A record without all five fields is
|
||||
/// dropped rather than guessed at: it can only come from a `find` that printed
|
||||
/// something else, and half a row is worse than no row.
|
||||
@@ -436,6 +464,30 @@ mod tests {
|
||||
assert_eq!(names, ["binary.bin", "hello.txt", "it's a file", "sub"]);
|
||||
}
|
||||
|
||||
/// The regression `resolve_blocking` exists for: a working directory typed
|
||||
/// as `~/…` reaches `llama-server` as a header it `chdir`s to, so it has to
|
||||
/// arrive absolute or every tool that uses one fails to spawn.
|
||||
#[test]
|
||||
fn a_tilde_resolves_to_that_machine_s_home() {
|
||||
let Some(home) = std::env::home_dir() else {
|
||||
return;
|
||||
};
|
||||
let resolved = resolve_blocking(&Transport::Here, "~").unwrap();
|
||||
assert!(resolved.starts_with('/'), "{resolved}");
|
||||
assert_eq!(
|
||||
std::fs::canonicalize(&resolved).unwrap(),
|
||||
std::fs::canonicalize(&home).unwrap(),
|
||||
);
|
||||
let dir = tree();
|
||||
// An absolute path is answered as itself, resolved.
|
||||
let full = dir.path().to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
resolve_blocking(&Transport::Here, &full).unwrap(),
|
||||
std::fs::canonicalize(&full).unwrap().to_string_lossy(),
|
||||
);
|
||||
assert!(resolve_blocking(&Transport::Here, &at(&dir, "nope")).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_directory_fails_with_the_machine_s_own_message() {
|
||||
let dir = tree();
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Just enough of the GGUF container to read a model's own name out of it.
|
||||
//!
|
||||
//! A `.gguf` file opens with a key/value table, and `general.name` in it is
|
||||
//! what the people who published the model called it -- "Qwen3-0.6B",
|
||||
//! "Qwen3.8-27B GSQ-RCO". Everything else this server knows a model by is
|
||||
//! filesystem trivia: `owner/repo/file.gguf` is where it was downloaded from,
|
||||
//! which is an address rather than a name, and on a phone it is a line of path
|
||||
//! where a word would do.
|
||||
//!
|
||||
//! **Read as far as the answer and no further.** The same table holds the
|
||||
//! tokenizer, which for a modern model is a 150,000-entry string array and
|
||||
//! most of several megabytes; `general.*` is written first by every converter
|
||||
//! in practice, so stopping at the name costs a few kilobytes instead. That is
|
||||
//! what makes this affordable to run over every model in a directory, and what
|
||||
//! lets the remote case work from a bounded prefix of the file rather than the
|
||||
//! whole of it.
|
||||
//!
|
||||
//! Anything unreadable is [`None`] rather than an error, at every level. A
|
||||
//! model with no name, a truncated prefix, a container version this does not
|
||||
//! know and a file that is not GGUF at all are one answer here -- "this file
|
||||
//! does not tell us" -- and the caller has a file name to fall back on. There
|
||||
//! is nothing a reader could do with the distinction.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
/// How many bytes of a model file are worth fetching to look for its name.
|
||||
///
|
||||
/// Only the remote path needs a number: a local read stops when it finds the
|
||||
/// key, but a file on another machine has to be asked for a fixed amount
|
||||
/// before anything can be parsed. Measured 2026-09-19 against the two models
|
||||
/// on this machine, `general.name` ends at byte **130** and **94** -- every
|
||||
/// converter writes `general.*` before the tokenizer arrays that make up the
|
||||
/// rest of the table. 8 KiB is two orders of magnitude of slack for that and
|
||||
/// still makes listing a directory of models one round trip's worth of bytes
|
||||
/// rather than a download, which is what decides the number: this is paid per
|
||||
/// model every time a spawn screen opens.
|
||||
pub const PREFIX_BYTES: u64 = 8 * 1024;
|
||||
|
||||
/// The longest string this will allocate for, so a corrupt length field
|
||||
/// cannot ask for a gigabyte. Longer than any key or `general.*` value.
|
||||
const MAX_STRING: u64 = 64 * 1024;
|
||||
|
||||
/// What `general.name` says, or `None` for every way of not finding out.
|
||||
///
|
||||
/// `read` is consumed only as far as the key: pass a file to read a local
|
||||
/// model, or a cursor over a prefix to read one whose bytes came from
|
||||
/// somewhere else.
|
||||
pub fn name(read: &mut impl Read) -> Option<String> {
|
||||
match find(read, |key| key == "general.name") {
|
||||
Some((STRING, read)) => string(read),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this model carries a multi-token-prediction head.
|
||||
///
|
||||
/// Worth asking because `llama-server` **exits** when told to use one that is
|
||||
/// not there -- `--spec-type draft-mtp` on a plain model is "context type MTP
|
||||
/// requested but model doesn't contain MTP layers" and then a server that
|
||||
/// never comes up. So the flag can only be passed once this has said yes, and
|
||||
/// a `false` here is the same answer as an unreadable file: don't ask for it.
|
||||
///
|
||||
/// Matched on the key's tail rather than its whole name, because the key is
|
||||
/// prefixed with the architecture (`qwen35.nextn_predict_layers`) and the
|
||||
/// architecture is whatever the next model is. The value is not read: a model
|
||||
/// that declares the key at all is one whose tensors carry the head, and the
|
||||
/// two disagreeing is a broken file rather than a state to handle.
|
||||
pub fn has_mtp_head(read: &mut impl Read) -> bool {
|
||||
find(read, |key| key.ends_with(".nextn_predict_layers")).is_some()
|
||||
}
|
||||
|
||||
/// Steps through the metadata table to the first key `wanted` accepts,
|
||||
/// returning its value's type tag and the reader positioned at the value.
|
||||
fn find<R: Read>(read: &mut R, wanted: impl Fn(&str) -> bool) -> Option<(u32, &mut R)> {
|
||||
let mut magic = [0u8; 4];
|
||||
read.read_exact(&mut magic).ok()?;
|
||||
if &magic != b"GGUF" {
|
||||
return None;
|
||||
}
|
||||
let _version = u32s(read)?;
|
||||
let _tensors = u64s(read)?;
|
||||
let count = u64s(read)?;
|
||||
for _ in 0..count {
|
||||
let found = string(read)?;
|
||||
let kind = u32s(read)?;
|
||||
if wanted(&found) {
|
||||
return Some((kind, read));
|
||||
}
|
||||
skip_value(kind, read)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// The value type tags, in the container's own numbering. Only the two this
|
||||
// has to act on are named; the rest are widths, and `scalar_width` is where
|
||||
// the numbering is written down once.
|
||||
const STRING: u32 = 8;
|
||||
const ARRAY: u32 = 9;
|
||||
|
||||
/// How many bytes a scalar of this type occupies, or `None` for a type that
|
||||
/// is not a scalar -- which includes a tag this build does not know, since a
|
||||
/// value of unknown length cannot be stepped over.
|
||||
fn scalar_width(kind: u32) -> Option<u64> {
|
||||
match kind {
|
||||
// u8, i8, bool
|
||||
0 | 1 | 7 => Some(1),
|
||||
// u16, i16
|
||||
2 | 3 => Some(2),
|
||||
// u32, i32, f32
|
||||
4..=6 => Some(4),
|
||||
// u64, i64, f64
|
||||
10..=12 => Some(8),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps over one value of `kind` without keeping it.
|
||||
///
|
||||
/// Recursive only in the sense that an array's elements are values; GGUF
|
||||
/// arrays do not nest, so the recursion is one level deep by construction.
|
||||
fn skip_value(kind: u32, read: &mut impl Read) -> Option<()> {
|
||||
match kind {
|
||||
STRING => {
|
||||
let len = u64s(read)?;
|
||||
skip(len, read)
|
||||
}
|
||||
ARRAY => {
|
||||
let element = u32s(read)?;
|
||||
let count = u64s(read)?;
|
||||
match scalar_width(element) {
|
||||
// The whole array at once: this is the tokenizer's scores and
|
||||
// token types, and stepping over them one at a time is a
|
||||
// syscall per token.
|
||||
Some(width) => skip(count.checked_mul(width)?, read),
|
||||
None if element == STRING => {
|
||||
for _ in 0..count {
|
||||
let len = u64s(read)?;
|
||||
skip(len, read)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
// An array of arrays, or of something this build has no width
|
||||
// for: the rest of the table can no longer be located.
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
_ => skip(scalar_width(kind)?, read),
|
||||
}
|
||||
}
|
||||
|
||||
/// Discards `count` bytes, failing if the input ends first.
|
||||
///
|
||||
/// Chunked against a bounded buffer rather than read into a `Vec` of the
|
||||
/// stated size: the sizes here come out of the file, and the file may be a
|
||||
/// truncated prefix or not a GGUF at all.
|
||||
fn skip(count: u64, read: &mut impl Read) -> Option<()> {
|
||||
let mut scratch = [0u8; 8192];
|
||||
let mut left = count;
|
||||
while left > 0 {
|
||||
let want = left.min(scratch.len() as u64) as usize;
|
||||
read.read_exact(&mut scratch[..want]).ok()?;
|
||||
left -= want as u64;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn string(read: &mut impl Read) -> Option<String> {
|
||||
let len = u64s(read)?;
|
||||
if len > MAX_STRING {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = vec![0u8; len as usize];
|
||||
read.read_exact(&mut bytes).ok()?;
|
||||
String::from_utf8(bytes).ok()
|
||||
}
|
||||
|
||||
fn u32s(read: &mut impl Read) -> Option<u32> {
|
||||
let mut bytes = [0u8; 4];
|
||||
read.read_exact(&mut bytes).ok()?;
|
||||
Some(u32::from_le_bytes(bytes))
|
||||
}
|
||||
|
||||
fn u64s(read: &mut impl Read) -> Option<u64> {
|
||||
let mut bytes = [0u8; 8];
|
||||
read.read_exact(&mut bytes).ok()?;
|
||||
Some(u64::from_le_bytes(bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Builds a GGUF header holding exactly these keys, so the parser is
|
||||
/// tested against the layout rather than against a fixture nobody here
|
||||
/// can regenerate.
|
||||
fn header(entries: &[(&str, Value)]) -> Vec<u8> {
|
||||
let mut out = Vec::from(*b"GGUF");
|
||||
out.extend(3u32.to_le_bytes());
|
||||
out.extend(0u64.to_le_bytes());
|
||||
out.extend((entries.len() as u64).to_le_bytes());
|
||||
for (key, value) in entries {
|
||||
put_string(&mut out, key);
|
||||
value.write(&mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
enum Value {
|
||||
Str(&'static str),
|
||||
U32(u32),
|
||||
Strings(Vec<&'static str>),
|
||||
Floats(Vec<f32>),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
fn write(&self, out: &mut Vec<u8>) {
|
||||
match self {
|
||||
Self::Str(text) => {
|
||||
out.extend(STRING.to_le_bytes());
|
||||
put_string(out, text);
|
||||
}
|
||||
Self::U32(number) => {
|
||||
out.extend(4u32.to_le_bytes());
|
||||
out.extend(number.to_le_bytes());
|
||||
}
|
||||
Self::Strings(items) => {
|
||||
out.extend(ARRAY.to_le_bytes());
|
||||
out.extend(STRING.to_le_bytes());
|
||||
out.extend((items.len() as u64).to_le_bytes());
|
||||
for item in items {
|
||||
put_string(out, item);
|
||||
}
|
||||
}
|
||||
Self::Floats(items) => {
|
||||
out.extend(ARRAY.to_le_bytes());
|
||||
out.extend(6u32.to_le_bytes());
|
||||
out.extend((items.len() as u64).to_le_bytes());
|
||||
for item in items {
|
||||
out.extend(item.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn put_string(out: &mut Vec<u8>, text: &str) {
|
||||
out.extend((text.len() as u64).to_le_bytes());
|
||||
out.extend(text.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_name_is_read_past_every_other_kind_of_value() {
|
||||
let bytes = header(&[
|
||||
("general.architecture", Value::Str("qwen3")),
|
||||
("general.file_type", Value::U32(7)),
|
||||
("qwen3.attention.head_count", Value::U32(16)),
|
||||
("tokenizer.ggml.scores", Value::Floats(vec![0.5; 64])),
|
||||
("tokenizer.ggml.tokens", Value::Strings(vec!["a", "b", "c"])),
|
||||
("general.name", Value::Str("Qwen3-0.6B")),
|
||||
]);
|
||||
assert_eq!(
|
||||
name(&mut bytes.as_slice()),
|
||||
Some("Qwen3-0.6B".to_string()),
|
||||
"every value before the name has to be steppable over",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The remote case: a prefix is all there is, and running off the end of
|
||||
/// it is "we don't know" rather than a failure worth reporting. The
|
||||
/// caller has the file name.
|
||||
fn a_truncated_file_has_no_name_rather_than_failing() {
|
||||
let bytes = header(&[
|
||||
("tokenizer.ggml.tokens", Value::Strings(vec!["a", "b", "c"])),
|
||||
("general.name", Value::Str("Qwen3-0.6B")),
|
||||
]);
|
||||
for cut in [4, 12, 24, bytes.len() - 4] {
|
||||
assert_eq!(name(&mut &bytes[..cut]), None, "cut at {cut}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_gguf_has_no_name() {
|
||||
assert_eq!(name(&mut b"not a model at all".as_slice()), None);
|
||||
assert_eq!(name(&mut b"".as_slice()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A name that is not a string is not a name. The alternative is
|
||||
/// rendering a number as one, which reads as a model called "7".
|
||||
fn a_name_of_the_wrong_type_is_not_read() {
|
||||
let bytes = header(&[("general.name", Value::U32(7))]);
|
||||
assert_eq!(name(&mut bytes.as_slice()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The head is found by the tail of the key, because the whole key is
|
||||
/// prefixed with whatever architecture the model is.
|
||||
fn an_mtp_head_is_found_whatever_the_architecture_is_called() {
|
||||
let with = header(&[
|
||||
("general.architecture", Value::Str("qwen35")),
|
||||
("qwen35.block_count", Value::U32(64)),
|
||||
("qwen35.nextn_predict_layers", Value::U32(1)),
|
||||
]);
|
||||
assert!(has_mtp_head(&mut with.as_slice()));
|
||||
|
||||
let without = header(&[
|
||||
("general.architecture", Value::Str("qwen3")),
|
||||
("qwen3.block_count", Value::U32(28)),
|
||||
]);
|
||||
assert!(!has_mtp_head(&mut without.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A prefix that stops short says no, and that is the direction it has to
|
||||
/// fail in: `--spec-type draft-mtp` on a model with no head is a server
|
||||
/// that exits, so "we could not tell" and "it has none" both mean don't
|
||||
/// ask for it.
|
||||
fn a_truncated_file_reports_no_mtp_head() {
|
||||
let bytes = header(&[("qwen35.nextn_predict_layers", Value::U32(1))]);
|
||||
assert!(!has_mtp_head(&mut &bytes[..12]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The real thing, when this machine happens to have one. Skipped rather
|
||||
/// than failed where it does not: the models directory is not part of the
|
||||
/// checkout, and a test that needs gigabytes to run is one nobody runs.
|
||||
fn a_real_model_on_this_machine_reads_back_its_name() {
|
||||
let Some(home) = std::env::var_os("HOME") else {
|
||||
return;
|
||||
};
|
||||
let dir = std::path::Path::new(&home).join(".local/share/ai-app/models");
|
||||
let mut found = Vec::new();
|
||||
collect_gguf(&dir, &mut found);
|
||||
for path in found {
|
||||
let mut file = std::fs::File::open(&path).expect("open");
|
||||
let read = name(&mut file);
|
||||
assert!(
|
||||
read.is_some_and(|name| !name.trim().is_empty()),
|
||||
"{} has a name in it and this did not read one",
|
||||
path.display(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_gguf(dir: &std::path::Path, found: &mut Vec<std::path::PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_gguf(&path, found);
|
||||
} else if path.extension().is_some_and(|e| e == "gguf") {
|
||||
found.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
//! Finding out what a machine can run, rather than being told.
|
||||
//!
|
||||
//! The phone adds a machine by giving connection details; this asks the machine
|
||||
//! itself which of the known programs it has, and the answer becomes its
|
||||
//! providers. That is a security property, not a convenience: **no route accepts
|
||||
//! a command from the phone.** If it did, the enrolled token could introduce
|
||||
//! arbitrary programs to run on every machine already configured here.
|
||||
//!
|
||||
//! It is also the better interface: nobody wants to type an absolute path on a
|
||||
//! phone keyboard, and a machine that has moved its binaries answers correctly
|
||||
//! on the next probe.
|
||||
//!
|
||||
//! The cost is that a program somewhere unusual is invisible. The escape hatch
|
||||
//! is editing `config.ron` on the backend, which is exactly the authority the
|
||||
//! phone is not being given -- and for the case that keeps arising, a second
|
||||
//! llama.cpp built to serve a model the ordinary one cannot, there is a
|
||||
//! directory a build is put in to be found: [`LLAMA_BUILDS`].
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::config::{DriverKind, ProviderConfig};
|
||||
use crate::session::transport::{Launch, Transport};
|
||||
|
||||
/// What is looked for, and what finding it makes. Extending this is how a new
|
||||
/// driver becomes discoverable -- one row, not a branch anywhere. The name is
|
||||
/// what the provider gets called, so it is what the phone shows and what a
|
||||
/// session stores.
|
||||
const PROBES: &[(&str, &str, DriverKind)] = &[
|
||||
("claude-cli", "claude", DriverKind::ClaudeCli),
|
||||
("codex-cli", "codex", DriverKind::CodexCli),
|
||||
// Named for the program rather than for where it runs: it runs
|
||||
// wherever the machine is, and "local" was true only while a llama
|
||||
// session could not be spawned on another machine.
|
||||
("llama-cpp", "llama-server", DriverKind::LlamaCpp),
|
||||
];
|
||||
|
||||
/// Models offered for a discovered Claude CLI. A shortcut list for the spawn
|
||||
/// screen, not a restriction -- the field stays free text.
|
||||
const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"];
|
||||
|
||||
/// Where a machine keeps the llama.cpp builds it has besides the one on its
|
||||
/// PATH: one directory per build, holding either `llama-server` itself or the
|
||||
/// `bin/llama-server` that `cmake --install` puts there.
|
||||
///
|
||||
/// A model whose kernels are not upstream -- Prism ML's ternary Bonsai
|
||||
/// packings are the case this was built for -- needs the fork that has them,
|
||||
/// while the ordinary models still want the ordinary build. So each directory
|
||||
/// found here is a provider of its own, with its own router process, its own
|
||||
/// model settings and its own sessions.
|
||||
///
|
||||
/// **A directory rather than a path the phone could type**, which is the whole
|
||||
/// design of this module: no route accepts a command to run, so putting a
|
||||
/// build here is a decision somebody makes on the machine itself. The name of
|
||||
/// the directory is what the provider is called, so it is worth choosing.
|
||||
const LLAMA_BUILDS: &str = "$HOME/.local/share/ai-app/llama";
|
||||
|
||||
/// The word a [`LLAMA_BUILDS`] line is tagged with, which is not a program
|
||||
/// name and so cannot collide with one.
|
||||
const BUILD: &str = "build";
|
||||
|
||||
/// Asks `transport`'s machine which of [`PROBES`] it has, and which llama.cpp
|
||||
/// builds are in [`LLAMA_BUILDS`].
|
||||
///
|
||||
/// One round trip rather than one per program: over ssh each would be a separate
|
||||
/// connection and handshake. `command -v` is POSIX and a shell builtin, so it
|
||||
/// works whatever is installed -- and each answer is tagged with what was asked
|
||||
/// for, since two of these are the same program under different paths.
|
||||
///
|
||||
/// Each test is an `if`'s condition rather than the loop body's last command,
|
||||
/// so that finding nothing is not the script's exit status -- the caller reads
|
||||
/// a non-zero exit as a machine it could not reach. And a build has to be a
|
||||
/// *file*, since a directory carries the executable bit too.
|
||||
pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
||||
let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect();
|
||||
let script = format!(
|
||||
"for p in {wanted}; do \
|
||||
if q=$(command -v \"$p\"); then printf '%s\\t%s\\n' \"$p\" \"$q\"; fi; \
|
||||
done; \
|
||||
d={LLAMA_BUILDS}; \
|
||||
for p in \"$d\"/*/llama-server \"$d\"/*/bin/llama-server; do \
|
||||
if [ -f \"$p\" ] && [ -x \"$p\" ]; then printf '{BUILD}\\t%s\\n' \"$p\"; fi; \
|
||||
done",
|
||||
wanted = wanted.join(" "),
|
||||
);
|
||||
let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
|
||||
let found = transport.capture(&launch).await.map_err(explain)?;
|
||||
|
||||
let mut providers = Vec::new();
|
||||
// Echo runs inside this server, so it exists exactly where this server does
|
||||
// and nowhere else. Offering it on a remote machine would be a choice that
|
||||
// changes nothing.
|
||||
if matches!(transport, Transport::Here) {
|
||||
providers.push(crate::config::Config::echo_provider());
|
||||
}
|
||||
providers.extend(probed(&found));
|
||||
Ok(providers)
|
||||
}
|
||||
|
||||
/// What the probe script's output says is installed.
|
||||
///
|
||||
/// Separated from the round trip so it can be exercised without a machine.
|
||||
fn probed(found: &str) -> Vec<ProviderConfig> {
|
||||
let mut providers: Vec<ProviderConfig> = Vec::new();
|
||||
for line in found.lines() {
|
||||
let Some((key, path)) = line.trim().split_once('\t') else {
|
||||
continue;
|
||||
};
|
||||
let (name, kind) = if key == BUILD {
|
||||
let Some(name) = build_name(path) else {
|
||||
continue;
|
||||
};
|
||||
(name, DriverKind::LlamaCpp)
|
||||
} else {
|
||||
let Some((name, _, kind)) = PROBES.iter().find(|(_, binary, _)| *binary == key) else {
|
||||
continue;
|
||||
};
|
||||
((*name).to_string(), *kind)
|
||||
};
|
||||
// A directory holding both shapes is matched by both globs. Two
|
||||
// providers of one name is a config that silently loses one of them.
|
||||
if providers.iter().any(|already| already.name == name) {
|
||||
continue;
|
||||
}
|
||||
providers.push(ProviderConfig {
|
||||
name,
|
||||
kind,
|
||||
// The resolved path rather than the bare name: PATH under a
|
||||
// non-interactive ssh session is not the one a person sees when they
|
||||
// log in, so "it is on my PATH" is not enough.
|
||||
command: Some(path.to_string()),
|
||||
models: match kind {
|
||||
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
mcp_servers: mcp_defaults(kind),
|
||||
// What a probe cannot know: how this machine's models are loaded
|
||||
// is configured after the fact, and a re-probe keeps it -- see
|
||||
// `SessionManager::update_machine`.
|
||||
model_settings: Default::default(),
|
||||
max_loaded: None,
|
||||
});
|
||||
}
|
||||
providers
|
||||
}
|
||||
|
||||
/// What to call the provider for a build found at `path`: the name of its own
|
||||
/// directory, under the `llama-cpp` the plain one already has.
|
||||
///
|
||||
/// The `bin/` a `cmake --install` produces is not part of the name -- a build
|
||||
/// installed as a prefix and one that is a single binary in a directory are
|
||||
/// the same build, and naming them differently would make moving between them
|
||||
/// lose the model settings kept against the name.
|
||||
fn build_name(path: &str) -> Option<String> {
|
||||
let dir = path.rsplit_once('/')?.0;
|
||||
let dir = dir.strip_suffix("/bin").unwrap_or(dir);
|
||||
let name = dir.rsplit('/').next()?;
|
||||
(!name.is_empty()).then(|| format!("llama-cpp-{name}"))
|
||||
}
|
||||
|
||||
/// One model a picker can offer, and what to call it there.
|
||||
///
|
||||
/// Two fields rather than one string because for one provider they differ:
|
||||
/// a llama.cpp model is chosen by the path it lives at and read as the name
|
||||
/// its own metadata gives it. Every other provider's id is already the name,
|
||||
/// and says so by repeating it -- which is what keeps the picker free of a
|
||||
/// branch on the session kind.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OfferedModel {
|
||||
/// What a spawn or a model change is given. Opaque to the phone.
|
||||
pub id: String,
|
||||
/// What a person reads on the chip.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl OfferedModel {
|
||||
/// A model whose id is its own name, which is every provider but llama.
|
||||
fn plain(id: impl Into<String>) -> Self {
|
||||
let id = id.into();
|
||||
Self {
|
||||
label: id.clone(),
|
||||
id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The MCP servers a newly discovered provider of this kind starts with.
|
||||
///
|
||||
/// A default rather than something to be typed in: a llama session with no web
|
||||
/// search is the state somebody would then have to find out how to leave, and
|
||||
/// Exa is what llama.cpp's own web UI offers under the same name. It is an
|
||||
/// ordinary config entry once written, so removing it is deleting a line.
|
||||
///
|
||||
/// Only llama.cpp, because only a driver that runs its own agent loop can use
|
||||
/// one -- the coding CLIs configure MCP themselves and a second answer here
|
||||
/// would quietly disagree with theirs.
|
||||
pub fn mcp_defaults(kind: DriverKind) -> Vec<crate::config::McpServerConfig> {
|
||||
match kind {
|
||||
DriverKind::LlamaCpp => vec![crate::config::McpServerConfig {
|
||||
name: "exa".to_string(),
|
||||
url: crate::session::llama::EXA_MCP_URL.to_string(),
|
||||
}],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Models the selected provider currently offers on this machine.
|
||||
///
|
||||
/// Codex's catalog is account- and CLI-version-specific, so it is asked at the
|
||||
/// moment the picker opens rather than copied into `config.ron`. A llama.cpp
|
||||
/// provider offers the GGUFs on the machine it runs on, through this same call
|
||||
/// -- there was a second route answering that alone, and it went when this one
|
||||
/// learned to, because a picker offering a model the spawn screen does not, or
|
||||
/// naming it differently, is two answers to one question. Other providers
|
||||
/// retain the shortcut list discovery stored for them.
|
||||
pub async fn provider_models(
|
||||
transport: &Transport,
|
||||
provider: &ProviderConfig,
|
||||
models_dir: &std::path::Path,
|
||||
) -> Result<Vec<OfferedModel>> {
|
||||
if provider.kind == DriverKind::LlamaCpp {
|
||||
let dir = crate::models::dir_on(transport, models_dir);
|
||||
let mut found = crate::models::on_machine(transport, &dir).await?;
|
||||
// A vision model's projector is a file beside it rather than a model,
|
||||
// and the session that reads pictures is the one on the model: offered
|
||||
// here it is a chip that starts a server which cannot load it. It is
|
||||
// still in the machine's own model list, which is where a file on a
|
||||
// disk is managed and deleted.
|
||||
found.retain(|model| {
|
||||
!crate::session::llama::is_projector(model.key.rsplit('/').next().unwrap_or_default())
|
||||
});
|
||||
let labels = crate::models::labels(&found);
|
||||
return Ok(found
|
||||
.into_iter()
|
||||
.zip(labels)
|
||||
.map(|(model, label)| OfferedModel {
|
||||
id: model.key,
|
||||
label,
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
if provider.kind != DriverKind::CodexCli {
|
||||
return Ok(provider.models.iter().map(OfferedModel::plain).collect());
|
||||
}
|
||||
let transport = transport.clone();
|
||||
let program = provider.program().to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let launch = Launch::new(program, vec!["app-server".into(), "--stdio".into()], None);
|
||||
let initial = json!({
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {"clientInfo": {"name": "ai-app", "title": "AI Sessions", "version": env!("CARGO_PKG_VERSION")}}
|
||||
});
|
||||
let requests = [
|
||||
json!({"method": "initialized"}),
|
||||
json!({"id": 2, "method": "model/list", "params": {"includeHidden": false, "limit": 100}}),
|
||||
];
|
||||
let answer = transport.request_json_blocking(&launch, &initial, &requests, 2)?;
|
||||
Ok(parse_codex_models(&answer)?
|
||||
.into_iter()
|
||||
.map(OfferedModel::plain)
|
||||
.collect())
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
fn parse_codex_models(answer: &Value) -> Result<Vec<String>> {
|
||||
if let Some(message) = answer.pointer("/error/message").and_then(Value::as_str) {
|
||||
anyhow::bail!("Codex could not list models: {message}");
|
||||
}
|
||||
let entries = answer
|
||||
.pointer("/result/data")
|
||||
.and_then(Value::as_array)
|
||||
.context("Codex returned no model catalog")?;
|
||||
let mut models = entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
!entry
|
||||
.get("hidden")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter_map(|entry| entry.get("model").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
models.dedup();
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// Adds what to do to failures whose own wording does not say.
|
||||
///
|
||||
/// ssh's messages are written for someone at a terminal on the backend, which is
|
||||
/// exactly who is not reading this one. Host key verification is the case that
|
||||
/// matters: **every** machine fails it the first time, so without this, adding a
|
||||
/// machine from the phone looks broken rather than unfinished.
|
||||
///
|
||||
/// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking` stays
|
||||
/// at its default, so a first connection is a decision somebody makes on the
|
||||
/// backend with the key in front of them.
|
||||
fn explain(err: anyhow::Error) -> anyhow::Error {
|
||||
let message = format!("{err:#}");
|
||||
if message.contains("Host key verification failed") {
|
||||
return anyhow::anyhow!(
|
||||
"{message} This machine has not been connected to before, so its key is not \
|
||||
trusted yet. Ssh to it once from the backend -- that is where the decision to \
|
||||
trust a key belongs -- and try again.",
|
||||
);
|
||||
}
|
||||
if message.contains("Permission denied") {
|
||||
return anyhow::anyhow!(
|
||||
"{message} The key named here has to be authorized on that machine, and the path \
|
||||
is read on the backend rather than on the phone.",
|
||||
);
|
||||
}
|
||||
err
|
||||
}
|
||||
|
||||
/// A short, stable, filename-safe id derived from a label. Derived once when a
|
||||
/// machine is added and then fixed, so the label stays editable. Collisions are
|
||||
/// resolved by the caller, which is the only place that knows what exists.
|
||||
pub fn id_from(label: &str) -> String {
|
||||
let slug: String = label
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let slug = slug.trim_matches('-').replace("--", "-");
|
||||
if slug.is_empty() {
|
||||
crate::session::random_hex()
|
||||
} else {
|
||||
slug.chars().take(32).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalises what a phone keyboard produced: trims, and reads a field left
|
||||
/// blank as absent rather than as an empty answer.
|
||||
///
|
||||
/// It deliberately does **not** touch a leading `~`. A path is stored as it was
|
||||
/// typed, because `~` and `/home/someone` are not two spellings of one path --
|
||||
/// the second is a snapshot of where the first pointed, and it is the snapshot
|
||||
/// that breaks when an account is renamed or the value is read on another
|
||||
/// machine. Expansion belongs where the path is used, against the machine it
|
||||
/// belongs to: `ssh::quote_path` and `files::PATH_PRELUDE` for a remote one,
|
||||
/// `ssh::expand_home` for one on this machine.
|
||||
pub fn tidy(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
/// Runs a launch to completion and returns its stdout as text.
|
||||
///
|
||||
/// The common case of [`Transport::capture_with_input`]: nothing on stdin, a
|
||||
/// failure reported as the machine's own words (ssh's "Permission denied" is the
|
||||
/// useful half of why a machine cannot be reached), and the output read as text
|
||||
/// because every caller here is asking a question whose answer is words.
|
||||
impl Transport {
|
||||
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
||||
let captured = self
|
||||
.capture_with_input(launch, super::session::transport::Input::None)
|
||||
.await?;
|
||||
Ok(String::from_utf8_lossy(&captured.ok()?).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A path survives the boundary unchanged, tilde included -- the one thing
|
||||
/// `tidy` must not do is decide where `~` is.
|
||||
#[test]
|
||||
fn a_typed_path_is_stored_as_typed() {
|
||||
assert_eq!(
|
||||
tidy(" ~/repos/ai-app-2 ").as_deref(),
|
||||
Some("~/repos/ai-app-2")
|
||||
);
|
||||
assert_eq!(tidy("/etc/hosts").as_deref(), Some("/etc/hosts"));
|
||||
assert_eq!(tidy(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_llama_build_is_a_provider_named_for_its_directory() {
|
||||
let found = "llama-server\t/usr/bin/llama-server\n\
|
||||
build\t/home/me/.local/share/ai-app/llama/prism/bin/llama-server\n\
|
||||
build\t/home/me/.local/share/ai-app/llama/nightly/llama-server\n";
|
||||
let providers = probed(found);
|
||||
let named: Vec<(&str, &str)> = providers
|
||||
.iter()
|
||||
.map(|p| (p.name.as_str(), p.program()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
named,
|
||||
vec![
|
||||
("llama-cpp", "/usr/bin/llama-server"),
|
||||
(
|
||||
"llama-cpp-prism",
|
||||
"/home/me/.local/share/ai-app/llama/prism/bin/llama-server"
|
||||
),
|
||||
(
|
||||
"llama-cpp-nightly",
|
||||
"/home/me/.local/share/ai-app/llama/nightly/llama-server"
|
||||
),
|
||||
]
|
||||
);
|
||||
assert!(providers.iter().all(|p| p.kind == DriverKind::LlamaCpp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_build_matched_twice_is_one_provider() {
|
||||
let found = "build\t/home/me/.local/share/ai-app/llama/prism/llama-server\n\
|
||||
build\t/home/me/.local/share/ai-app/llama/prism/bin/llama-server\n";
|
||||
let providers = probed(found);
|
||||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(
|
||||
providers[0].program(),
|
||||
"/home/me/.local/share/ai-app/llama/prism/llama-server"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_this_does_not_understand_is_not_a_provider() {
|
||||
let found = "warning: something on stderr\n\
|
||||
\n\
|
||||
ruby\t/usr/bin/ruby\n\
|
||||
codex\t/usr/bin/codex\n";
|
||||
let providers = probed(found);
|
||||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(providers[0].name, "codex-cli");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_models_are_the_selectable_non_hidden_catalog_entries() {
|
||||
let answer = json!({"result": {"data": [
|
||||
{"model": "gpt-small", "hidden": false},
|
||||
{"model": "gpt-hidden", "hidden": true},
|
||||
{"model": "gpt-large", "hidden": false}
|
||||
]}});
|
||||
assert_eq!(
|
||||
parse_codex_models(&answer).unwrap(),
|
||||
vec!["gpt-small".to_string(), "gpt-large".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_codex_catalog_is_not_reported_as_an_empty_one() {
|
||||
let answer = json!({"error": {"message": "login required"}});
|
||||
assert_eq!(
|
||||
parse_codex_models(&answer).unwrap_err().to_string(),
|
||||
"Codex could not list models: login required"
|
||||
);
|
||||
}
|
||||
}
|
||||
+36
-17
@@ -14,11 +14,14 @@
|
||||
mod auth;
|
||||
mod config;
|
||||
mod files;
|
||||
mod gguf;
|
||||
mod machines;
|
||||
mod media;
|
||||
mod models;
|
||||
mod provider_auth;
|
||||
mod resume;
|
||||
mod routes;
|
||||
mod session;
|
||||
mod setups;
|
||||
mod ssh;
|
||||
mod usage;
|
||||
|
||||
@@ -41,7 +44,7 @@ use session::SessionManager;
|
||||
|
||||
const DEFAULT_PORT: u16 = 8443;
|
||||
|
||||
/// Serves AI coding sessions (Claude Code, llama.cpp) to the phone app.
|
||||
/// Serves AI coding sessions (Codex, Claude Code, llama.cpp) to the phone app.
|
||||
#[derive(Parser)]
|
||||
struct Args {
|
||||
/// TLS port for the whole API surface.
|
||||
@@ -142,7 +145,7 @@ async fn main() -> Result<()> {
|
||||
let config_path = args
|
||||
.config
|
||||
.unwrap_or_else(|| config_home("ai-app").join("config.ron"));
|
||||
// Before the manager exists, on purpose: constructing it and seeding setups
|
||||
// Before the manager exists, on purpose: constructing it and seeding machines
|
||||
// touches sessions and subprocesses this invocation has no business with
|
||||
// while another instance is serving. Only the hash reaches disk, in the
|
||||
// spool `auth.rs` reads; the link goes to stdout alone.
|
||||
@@ -172,7 +175,6 @@ async fn main() -> Result<()> {
|
||||
let models_dir = args
|
||||
.models_dir
|
||||
.unwrap_or_else(|| data_home("ai-app").join("models"));
|
||||
let models = Arc::new(models::ModelStore::new(models_dir.clone()));
|
||||
let manager = Arc::new(
|
||||
SessionManager::new(config_path.clone(), data_dir, models_dir.clone())
|
||||
.with_context(|| format!("failed to load {}", config_path.display()))?
|
||||
@@ -187,19 +189,19 @@ async fn main() -> Result<()> {
|
||||
// After construction rather than inside it: seeding asks this machine what
|
||||
// it has, and a constructor that quietly runs a subprocess is a surprise to
|
||||
// every caller including the tests.
|
||||
manager.seed_setup().await?;
|
||||
manager.seed_machine().await?;
|
||||
|
||||
tracing::info!("config: {}", config_path.display());
|
||||
tracing::info!("models: {}", models_dir.display());
|
||||
for setup in manager.setups() {
|
||||
match &setup.ssh {
|
||||
Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address),
|
||||
// No parenthetical naming the local machine: the default setup is
|
||||
// *called* "this machine", and the line read "setup this machine
|
||||
// (this machine)".
|
||||
None => tracing::info!(" setup \"{}\" runs here", setup.name),
|
||||
for machine in manager.machines() {
|
||||
match &machine.ssh {
|
||||
Some(ssh) => tracing::info!(" machine \"{}\" -> {}", machine.name, ssh.address),
|
||||
// No parenthetical naming the local machine: the default machine is
|
||||
// *called* "this machine", so repeating a local qualifier read
|
||||
// like a stutter.
|
||||
None => tracing::info!(" machine \"{}\" runs here", machine.name),
|
||||
}
|
||||
for provider in &setup.providers {
|
||||
for provider in &machine.providers {
|
||||
tracing::info!(" provider {} ({:?})", provider.name, provider.kind);
|
||||
}
|
||||
}
|
||||
@@ -262,15 +264,31 @@ async fn main() -> Result<()> {
|
||||
.context("failed to load TLS cert/key")?;
|
||||
|
||||
// No providers listed here any more: which machines can be asked, and about
|
||||
// what, comes from the setups at the moment the screen is opened -- so a
|
||||
// what, comes from the machines at the moment the screen is opened -- so a
|
||||
// machine added from the phone reports its limits without a restart.
|
||||
let monitor = Arc::new(usage::UsageMonitor::new());
|
||||
// The fixture is the manager's, because that is where the `/usage` command
|
||||
// that sets it is typed; the monitor is what serves it.
|
||||
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture()));
|
||||
let provider_logins = Arc::new(provider_auth::LoginManager::new(Arc::clone(&monitor)));
|
||||
|
||||
// The one thing in here that acts without a request behind it: a session
|
||||
// switched to auto-resume waits out its account's usage limit and picks
|
||||
// itself back up. Started whether or not any session has it on, because
|
||||
// the setting is per session and changes from the phone -- see
|
||||
// `resume::run`.
|
||||
tokio::spawn(resume::run(Arc::clone(&manager), Arc::clone(&monitor)));
|
||||
|
||||
// The bearer-token middleware wraps the entire router -- routes and fallback
|
||||
// alike -- here and only here, so a new route can't forget auth.
|
||||
let app = routes::router(Arc::clone(&manager))
|
||||
.merge(routes::usage_router(monitor, Arc::clone(&manager)))
|
||||
.merge(routes::models_router(Arc::clone(&models)))
|
||||
.merge(routes::usage_router(
|
||||
Arc::clone(&monitor),
|
||||
Arc::clone(&manager),
|
||||
))
|
||||
.merge(routes::provider_auth_router(
|
||||
Arc::clone(&provider_logins),
|
||||
Arc::clone(&manager),
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
Arc::clone(&manager),
|
||||
auth::require_token,
|
||||
@@ -312,6 +330,7 @@ async fn main() -> Result<()> {
|
||||
// above: a throwaway session is one nobody meant to keep, and the whole point
|
||||
// is that nothing has to remember to clean it up.
|
||||
manager.stop_throwaway_sessions();
|
||||
provider_logins.cancel_all();
|
||||
manager.detach_all();
|
||||
|
||||
Ok(())
|
||||
|
||||
+639
-485
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,470 @@
|
||||
//! Interactive provider login carried between a CLI on a configured machine
|
||||
//! and the phone. The CLI remains the only credential writer: this layer keeps
|
||||
//! its short-lived process and relays only the authorization URL and the code
|
||||
//! a person copies back from the browser.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rand::Rng;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::{MachineConfig, ProviderConfig};
|
||||
use crate::session::transport::Transport;
|
||||
use crate::usage::UsageMonitor;
|
||||
|
||||
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const AUTHORIZATION_URL_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const OUTPUT_POLL: Duration = Duration::from_millis(50);
|
||||
const START_WAIT: Duration = Duration::from_secs(16);
|
||||
|
||||
type Key = (String, String);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "camelCase")]
|
||||
pub enum LoginState {
|
||||
Starting,
|
||||
WaitingForCode {
|
||||
#[serde(rename = "authorizationUrl")]
|
||||
authorization_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
},
|
||||
Submitting,
|
||||
Succeeded,
|
||||
Failed {
|
||||
detail: String,
|
||||
},
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl LoginState {
|
||||
fn terminal(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Succeeded | Self::Failed { .. } | Self::Cancelled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginInfo {
|
||||
pub attempt: String,
|
||||
#[serde(flatten)]
|
||||
pub state: LoginState,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Attempt {
|
||||
id: String,
|
||||
state: Arc<Mutex<LoginState>>,
|
||||
input: mpsc::Sender<Input>,
|
||||
}
|
||||
|
||||
impl Attempt {
|
||||
fn info(&self) -> LoginInfo {
|
||||
LoginInfo {
|
||||
attempt: self.id.clone(),
|
||||
state: self.state.lock().unwrap().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Input {
|
||||
Code(String),
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// The active login per machine and provider. Completed attempts stay until a
|
||||
/// new one replaces them, so a phone that briefly loses its connection can ask
|
||||
/// how the operation ended rather than being handed an ambiguous 404.
|
||||
pub struct LoginManager {
|
||||
attempts: Mutex<HashMap<Key, Attempt>>,
|
||||
usage: Arc<UsageMonitor>,
|
||||
}
|
||||
|
||||
impl LoginManager {
|
||||
pub fn new(usage: Arc<UsageMonitor>) -> Self {
|
||||
Self {
|
||||
attempts: Mutex::new(HashMap::new()),
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&self, machine: MachineConfig, provider: ProviderConfig) -> LoginInfo {
|
||||
let provider_key = provider
|
||||
.kind
|
||||
.usage_provider()
|
||||
.expect("a login route only accepts a metered provider")
|
||||
.to_string();
|
||||
let key = (machine.id.clone(), provider_key);
|
||||
let mut attempts = self.attempts.lock().unwrap();
|
||||
if let Some(attempt) = attempts.get(&key)
|
||||
&& !attempt.state.lock().unwrap().terminal()
|
||||
{
|
||||
return attempt.info();
|
||||
}
|
||||
|
||||
let id = attempt_id();
|
||||
let state = Arc::new(Mutex::new(LoginState::Starting));
|
||||
let (input, commands) = mpsc::channel();
|
||||
let attempt = Attempt {
|
||||
id: id.clone(),
|
||||
state: Arc::clone(&state),
|
||||
input,
|
||||
};
|
||||
attempts.insert(key, attempt.clone());
|
||||
drop(attempts);
|
||||
|
||||
// Seed the cache before the worker takes the gate. A usage request in
|
||||
// that small handoff window sees a fresh, truthful state and cannot
|
||||
// start a second CLI against the same credential.
|
||||
let gate = self.usage.claude_authentication_started(&machine);
|
||||
let usage = Arc::clone(&self.usage);
|
||||
std::thread::spawn(move || {
|
||||
let _guard = gate.lock().unwrap();
|
||||
run_login(&machine, &provider, commands, &state);
|
||||
usage.claude_authentication_finished(&machine.id);
|
||||
});
|
||||
|
||||
attempt.info()
|
||||
}
|
||||
|
||||
pub fn wait_until_ready(&self, machine: &str, provider: &str, attempt: &str) -> LoginInfo {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
let info = self.read(machine, provider, attempt).unwrap_or(LoginInfo {
|
||||
attempt: attempt.to_string(),
|
||||
state: LoginState::Failed {
|
||||
detail: "the sign-in attempt disappeared".to_string(),
|
||||
},
|
||||
});
|
||||
if !matches!(info.state, LoginState::Starting) || started.elapsed() >= START_WAIT {
|
||||
return info;
|
||||
}
|
||||
std::thread::sleep(OUTPUT_POLL);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self, machine: &str, provider: &str, attempt: &str) -> Option<LoginInfo> {
|
||||
let attempts = self.attempts.lock().unwrap();
|
||||
let found = attempts.get(&(machine.to_string(), provider.to_string()))?;
|
||||
(found.id == attempt).then(|| found.info())
|
||||
}
|
||||
|
||||
pub fn submit(
|
||||
&self,
|
||||
machine: &str,
|
||||
provider: &str,
|
||||
attempt: &str,
|
||||
code: &str,
|
||||
) -> Result<LoginInfo> {
|
||||
let code = valid_code(code)?;
|
||||
let attempts = self.attempts.lock().unwrap();
|
||||
let found = attempts
|
||||
.get(&(machine.to_string(), provider.to_string()))
|
||||
.filter(|found| found.id == attempt)
|
||||
.context("no such sign-in attempt")?;
|
||||
if found.state.lock().unwrap().terminal() {
|
||||
return Ok(found.info());
|
||||
}
|
||||
*found.state.lock().unwrap() = LoginState::Submitting;
|
||||
if found.input.send(Input::Code(code.to_string())).is_err() {
|
||||
*found.state.lock().unwrap() = LoginState::Failed {
|
||||
detail: "the sign-in process has stopped".to_string(),
|
||||
};
|
||||
anyhow::bail!("the sign-in process has stopped");
|
||||
}
|
||||
Ok(found.info())
|
||||
}
|
||||
|
||||
pub fn cancel(&self, machine: &str, provider: &str, attempt: &str) -> Result<LoginInfo> {
|
||||
let attempts = self.attempts.lock().unwrap();
|
||||
let found = attempts
|
||||
.get(&(machine.to_string(), provider.to_string()))
|
||||
.filter(|found| found.id == attempt)
|
||||
.context("no such sign-in attempt")?;
|
||||
if !found.state.lock().unwrap().terminal() {
|
||||
let _ = found.input.send(Input::Cancel);
|
||||
}
|
||||
Ok(found.info())
|
||||
}
|
||||
|
||||
/// Interactive helpers are unlike sessions: nothing adopts them after a
|
||||
/// server restart. End every one while the process is still here to reap
|
||||
/// the child it launched.
|
||||
pub fn cancel_all(&self) {
|
||||
let attempts = self.attempts.lock().unwrap();
|
||||
for attempt in attempts.values() {
|
||||
if !attempt.state.lock().unwrap().terminal() {
|
||||
let _ = attempt.input.send(Input::Cancel);
|
||||
}
|
||||
}
|
||||
let pending: Vec<_> = attempts
|
||||
.values()
|
||||
.map(|attempt| Arc::clone(&attempt.state))
|
||||
.collect();
|
||||
drop(attempts);
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline
|
||||
&& pending
|
||||
.iter()
|
||||
.any(|state| !state.lock().unwrap().terminal())
|
||||
{
|
||||
std::thread::sleep(OUTPUT_POLL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_login(
|
||||
machine: &MachineConfig,
|
||||
provider: &ProviderConfig,
|
||||
commands: mpsc::Receiver<Input>,
|
||||
state: &Arc<Mutex<LoginState>>,
|
||||
) {
|
||||
if let Err(err) = run_login_inner(machine, provider, commands, state) {
|
||||
*state.lock().unwrap() = LoginState::Failed {
|
||||
detail: format!("couldn't sign in to Claude on {}: {err:#}", machine.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn run_login_inner(
|
||||
machine: &MachineConfig,
|
||||
provider: &ProviderConfig,
|
||||
commands: mpsc::Receiver<Input>,
|
||||
state: &Arc<Mutex<LoginState>>,
|
||||
) -> Result<()> {
|
||||
let transport = Transport::for_machine(machine);
|
||||
let args = vec![
|
||||
"BROWSER=/bin/false".to_string(),
|
||||
provider.program().to_string(),
|
||||
"auth".to_string(),
|
||||
"login".to_string(),
|
||||
"--claudeai".to_string(),
|
||||
];
|
||||
let host = match &transport {
|
||||
Transport::Here => None,
|
||||
Transport::Ssh { ssh, .. } => Some(ssh),
|
||||
};
|
||||
let mut command = crate::ssh::command(host, "env", &args, None, None);
|
||||
command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.with_context(|| format!("couldn't run {} auth login", provider.program()))?;
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.context("the login process has no stdin")?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.context("the login process has no stdout")?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.context("the login process has no stderr")?;
|
||||
let (output, lines) = mpsc::channel();
|
||||
read_lines(stdout, output.clone());
|
||||
read_lines(stderr, output);
|
||||
|
||||
let started = Instant::now();
|
||||
let mut authorization_url = None;
|
||||
let mut last_line = None;
|
||||
loop {
|
||||
while let Ok(line) = lines.try_recv() {
|
||||
if let Some(url) = authorization_url_in(&line) {
|
||||
authorization_url = Some(url.to_string());
|
||||
*state.lock().unwrap() = LoginState::WaitingForCode {
|
||||
authorization_url: url.to_string(),
|
||||
detail: None,
|
||||
};
|
||||
} else if line.to_ascii_lowercase().contains("invalid code") {
|
||||
if let Some(url) = &authorization_url {
|
||||
*state.lock().unwrap() = LoginState::WaitingForCode {
|
||||
authorization_url: url.clone(),
|
||||
detail: Some(
|
||||
"That code was not accepted. Copy the complete code and try again."
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
}
|
||||
} else if !line.trim().is_empty() {
|
||||
last_line = Some(line.trim().chars().take(500).collect::<String>());
|
||||
}
|
||||
}
|
||||
|
||||
match commands.recv_timeout(OUTPUT_POLL) {
|
||||
Ok(Input::Code(code)) => {
|
||||
*state.lock().unwrap() = LoginState::Submitting;
|
||||
writeln!(stdin, "{code}").context("couldn't send the login code")?;
|
||||
stdin.flush().context("couldn't send the login code")?;
|
||||
}
|
||||
Ok(Input::Cancel) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
*state.lock().unwrap() = LoginState::Cancelled;
|
||||
return Ok(());
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("the phone disconnected from the sign-in attempt");
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
|
||||
if let Some(status) = child
|
||||
.try_wait()
|
||||
.context("couldn't check the login process")?
|
||||
{
|
||||
*state.lock().unwrap() = if status.success() {
|
||||
LoginState::Succeeded
|
||||
} else {
|
||||
LoginState::Failed {
|
||||
detail: last_line
|
||||
.unwrap_or_else(|| format!("Claude's login process exited with {status}")),
|
||||
}
|
||||
};
|
||||
return Ok(());
|
||||
}
|
||||
if authorization_url.is_none() && started.elapsed() >= AUTHORIZATION_URL_TIMEOUT {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("the Claude CLI did not provide an authorization URL");
|
||||
}
|
||||
if started.elapsed() >= LOGIN_TIMEOUT {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("the sign-in attempt expired; start it again");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_lines(reader: impl Read + Send + 'static, output: mpsc::Sender<String>) {
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(reader).lines().map_while(Result::ok) {
|
||||
let _ = output.send(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn authorization_url_in(line: &str) -> Option<&str> {
|
||||
let start = line.find("https://")?;
|
||||
let tail = &line[start..];
|
||||
let end = tail
|
||||
.find(|character: char| character.is_whitespace() || character == '\u{1b}')
|
||||
.unwrap_or(tail.len());
|
||||
let url = &tail[..end];
|
||||
(url.starts_with("https://claude.com/") || url.starts_with("https://platform.claude.com/"))
|
||||
.then_some(url)
|
||||
}
|
||||
|
||||
fn valid_code(code: &str) -> Result<&str> {
|
||||
let code = code.trim();
|
||||
anyhow::ensure!(!code.is_empty(), "the login code is empty");
|
||||
anyhow::ensure!(code.len() <= 4096, "the login code is too long");
|
||||
anyhow::ensure!(
|
||||
!code.chars().any(char::is_control),
|
||||
"the login code contains a line break or control character"
|
||||
);
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
fn attempt_id() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::DriverKind;
|
||||
|
||||
#[test]
|
||||
fn extracts_only_anthropics_https_login_url() {
|
||||
assert_eq!(
|
||||
authorization_url_in("visit: https://claude.com/cai/oauth/authorize?state=x"),
|
||||
Some("https://claude.com/cai/oauth/authorize?state=x")
|
||||
);
|
||||
assert!(authorization_url_in("visit: http://claude.com/nope").is_none());
|
||||
assert!(authorization_url_in("visit: https://example.com/nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_code_is_one_bounded_line() {
|
||||
assert_eq!(valid_code(" abc#state ").unwrap(), "abc#state");
|
||||
assert!(valid_code("\n").is_err());
|
||||
assert!(valid_code("a\nb").is_err());
|
||||
assert!(valid_code(&"x".repeat(4097)).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn relays_a_headless_cli_login_without_taking_over_its_credentials() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let cli = dir.path().join("fake-claude");
|
||||
std::fs::write(
|
||||
&cli,
|
||||
"#!/bin/sh\necho 'https://claude.com/cai/oauth/authorize?state=test'\nIFS= read -r code\n[ \"$code\" = 'the-code' ]\n",
|
||||
)
|
||||
.expect("write fake CLI");
|
||||
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o700))
|
||||
.expect("make fake CLI executable");
|
||||
|
||||
let monitor = Arc::new(UsageMonitor::new(Default::default()));
|
||||
let logins = LoginManager::new(monitor);
|
||||
let machine = MachineConfig {
|
||||
id: "vm".to_string(),
|
||||
name: "test vm".to_string(),
|
||||
ssh: None,
|
||||
providers: Vec::new(),
|
||||
};
|
||||
let provider = ProviderConfig {
|
||||
name: "claude-cli".to_string(),
|
||||
kind: DriverKind::ClaudeCli,
|
||||
command: Some(cli.display().to_string()),
|
||||
models: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
model_settings: Default::default(),
|
||||
max_loaded: None,
|
||||
};
|
||||
|
||||
let started = logins.start(machine, provider);
|
||||
let ready = logins.wait_until_ready("vm", "claude", &started.attempt);
|
||||
assert!(matches!(ready.state, LoginState::WaitingForCode { .. }));
|
||||
let wire = serde_json::to_value(&ready).expect("serialize login state");
|
||||
assert!(wire.get("authorizationUrl").is_some(), "{wire}");
|
||||
assert!(wire.get("authorization_url").is_none(), "{wire}");
|
||||
let submitted = logins
|
||||
.submit("vm", "claude", &started.attempt, "the-code")
|
||||
.expect("submit code");
|
||||
assert!(matches!(submitted.state, LoginState::Submitting));
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
let finished = logins
|
||||
.read("vm", "claude", &started.attempt)
|
||||
.expect("attempt remains readable");
|
||||
if matches!(finished.state, LoginState::Succeeded) {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"login did not finish: {finished:?}"
|
||||
);
|
||||
std::thread::sleep(OUTPUT_POLL);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Auto-resume: picking a session back up when its account's usage limit
|
||||
//! lifts.
|
||||
//!
|
||||
//! Off unless a session was switched to it, because this spends quota the
|
||||
//! moment quota exists and does it while nobody is watching. What it does is
|
||||
//! narrow on purpose: it sends one message -- "continue" unless something else
|
||||
//! was typed -- to a session that stopped because the account ran out, and
|
||||
//! then it is done. There is no retry loop around the conversation itself.
|
||||
//!
|
||||
//! **The schedule is a plan to ask, never a plan to send.** A reset time is
|
||||
//! the one thing here that cannot be trusted: the dialect's is a hint written
|
||||
//! when the turn failed, the endpoint's moves when the window moves, and both
|
||||
//! are wrong across the case this exists for -- a limit that lifts later than
|
||||
//! it said. So the wait ends in a *question* to [`crate::usage`], and only an
|
||||
//! answer that says the limits no longer apply sends anything. Every other
|
||||
//! answer, including one that cannot be got at all, becomes a new wait.
|
||||
//!
|
||||
//! This is the top layer: it holds the session manager and the usage monitor
|
||||
//! and neither holds it. That is what lets the decision below be a pure
|
||||
//! function of a snapshot and a clock, which is the whole of what is worth
|
||||
//! testing here.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::session::{LimitHit, OwedResume, SessionManager, now};
|
||||
use crate::usage::{UsageMonitor, UsageSnapshot, UsageState};
|
||||
|
||||
/// How often to look at the schedule. Coarse deliberately: a wait measured in
|
||||
/// hours does not deserve a fine-grained clock, and the meter behind it is
|
||||
/// cached for three minutes anyway.
|
||||
const TICK: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How close to a scheduled check is close enough to ask the meter. Anything
|
||||
/// further out is left alone, so a session waiting five hours costs nothing
|
||||
/// until the last few minutes of it.
|
||||
const NEARLY: f64 = 300.0;
|
||||
|
||||
/// How long to wait after an answer that decided nothing -- the machine could
|
||||
/// not be asked, or it says the limit is still on with no reset time.
|
||||
const BACKOFF: f64 = 300.0;
|
||||
|
||||
/// The least time to wait before asking again, whatever a reset time says. A
|
||||
/// window that claims to reset in the past would otherwise be asked about on
|
||||
/// every tick.
|
||||
const AT_LEAST: f64 = 60.0;
|
||||
|
||||
/// How long after the limit was hit to stop waiting.
|
||||
///
|
||||
/// Something has to bound it, or a machine that can never be asked -- an
|
||||
/// unplugged laptop, a machine somebody edited away -- is retried for ever with
|
||||
/// nothing on screen saying so. A day is past the longest window Claude
|
||||
/// reports, so reaching this means the wait was never going to end on its own.
|
||||
const GIVE_UP: f64 = 24.0 * 60.0 * 60.0;
|
||||
|
||||
/// The percentage at which a window is spent. The API counts up to 100, so
|
||||
/// this is an equality in all but name; written as a threshold because a
|
||||
/// figure arriving slightly over is a full window, not a corrupt one.
|
||||
const SPENT: f64 = 100.0;
|
||||
|
||||
/// What to do about one owed resume, having asked the meter.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Step {
|
||||
/// The limits no longer apply: send the message.
|
||||
Send,
|
||||
/// Ask again at this epoch second.
|
||||
WaitUntil(f64),
|
||||
/// This has been waiting longer than anything real would take.
|
||||
GiveUp,
|
||||
}
|
||||
|
||||
/// Runs the schedule until the server stops.
|
||||
///
|
||||
/// Two things wake it: the tick, and a session reporting that it has just run
|
||||
/// out. The second is not an optimisation -- a limit hit is what *creates* a
|
||||
/// schedule, and a tick that happened a moment before it would otherwise leave
|
||||
/// the session unrecorded until the next one.
|
||||
pub async fn run(manager: Arc<SessionManager>, monitor: Arc<UsageMonitor>) {
|
||||
let mut limits = manager.subscribe_limits();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(TICK) => {}
|
||||
hit = limits.recv() => match hit {
|
||||
Ok(LimitHit { session_id, resets_at }) => note(&manager, &session_id, resets_at),
|
||||
// Lagged: some reports were dropped, and a session that hit a
|
||||
// limit while this was busy has no schedule. Nothing is lost
|
||||
// for good -- the sweep below reads the config, and the
|
||||
// session will report again the next time it is poked -- but
|
||||
// it is worth saying, because until then that session waits
|
||||
// for a person.
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(missed)) => {
|
||||
tracing::warn!("auto-resume missed {missed} limit reports");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||
},
|
||||
}
|
||||
sweep(&manager, &monitor).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a limit against the session that hit it, if it is one that resumes.
|
||||
pub(crate) fn note(manager: &SessionManager, session_id: &str, resets_at: Option<f64>) {
|
||||
match manager.note_limit(session_id, resets_at) {
|
||||
Ok(true) => tracing::info!("session {session_id} hit its usage limit; auto-resume is on"),
|
||||
Ok(false) => {}
|
||||
Err(err) => tracing::error!("couldn't schedule a resume for {session_id}: {err:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One pass over everything owed a message.
|
||||
async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
|
||||
let at = now();
|
||||
for owed in manager.owed_resumes() {
|
||||
if owed.scheduled.at - at > NEARLY {
|
||||
continue;
|
||||
}
|
||||
// Asked per session rather than once for the whole sweep: the answer
|
||||
// is cached per machine and per meter, so several sessions on one
|
||||
// account share one fetch, and a machine nobody is waiting on is not
|
||||
// dialled at all.
|
||||
let snapshot = snapshot_for(Arc::clone(monitor), manager, &owed).await;
|
||||
match decide(snapshot.as_ref(), &owed, now()) {
|
||||
Step::Send => match manager.resume_now(&owed.session_id) {
|
||||
Ok(message) => tracing::info!(
|
||||
"the limit on {} has lifted; sent \"{message}\" to {}",
|
||||
owed.machine,
|
||||
owed.session_id
|
||||
),
|
||||
Err(err) => {
|
||||
tracing::error!("couldn't resume {}: {err:#}", owed.session_id)
|
||||
}
|
||||
},
|
||||
Step::WaitUntil(next) => {
|
||||
if let Err(err) = manager.reschedule_resume(&owed.session_id, next) {
|
||||
tracing::error!(
|
||||
"couldn't move {}'s resume to {next}: {err:#}",
|
||||
owed.session_id
|
||||
);
|
||||
}
|
||||
}
|
||||
Step::GiveUp => {
|
||||
// About the machine rather than in the state's own words: the
|
||||
// detail is in the log, and what lands in the transcript has
|
||||
// to read on a phone.
|
||||
let why = match snapshot.as_ref().map(|snapshot| &snapshot.state) {
|
||||
Some(UsageState::Ok) => "the limit has not lifted in a day".to_string(),
|
||||
_ => format!("{} could not be asked for a day", owed.machine),
|
||||
};
|
||||
if let Err(err) = manager.abandon_resume(&owed.session_id, &why) {
|
||||
tracing::error!("couldn't clear {}'s resume: {err:#}", owed.session_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The numbers for the machine and the meter this session is billed against,
|
||||
/// and `None` when nothing reports on it.
|
||||
///
|
||||
/// Blocking work, so it goes to a blocking thread: the fetch behind it reads a
|
||||
/// credential file over ssh and then makes an HTTP call.
|
||||
async fn snapshot_for(
|
||||
monitor: Arc<UsageMonitor>,
|
||||
manager: &SessionManager,
|
||||
owed: &OwedResume,
|
||||
) -> Option<UsageSnapshot> {
|
||||
let machines: Vec<_> = manager
|
||||
.machines()
|
||||
.into_iter()
|
||||
.filter(|machine| machine.id == owed.machine)
|
||||
.collect();
|
||||
if machines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let provider = owed.provider;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
monitor
|
||||
.snapshots(&machines)
|
||||
.into_iter()
|
||||
.find(|snapshot| snapshot.provider == provider)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What one owed resume should do, given what the meter said and the time.
|
||||
///
|
||||
/// A pure function of the two, which is what makes the rule inspectable: every
|
||||
/// answer that is not "the limits no longer apply" is a longer wait, and the
|
||||
/// only thing that ends the waiting other than success is the clock.
|
||||
///
|
||||
/// The reset time comes from the *snapshot* rather than from the schedule, so
|
||||
/// a window that turns out to reset later than the dialect said pushes the
|
||||
/// check back, and one that resets sooner pulls it forward. That is the case
|
||||
/// the whole design is about: the first answer was a guess, this one is a
|
||||
/// measurement.
|
||||
pub fn decide(snapshot: Option<&UsageSnapshot>, owed: &OwedResume, at: f64) -> Step {
|
||||
let step = match snapshot {
|
||||
// The meter answered with numbers, which is the only answer that can
|
||||
// send anything.
|
||||
Some(snapshot) if snapshot.state == UsageState::Ok => {
|
||||
let spent: Vec<&crate::usage::UsageWindow> = snapshot
|
||||
.windows
|
||||
.iter()
|
||||
.filter(|window| window.percent >= SPENT)
|
||||
.collect();
|
||||
if spent.is_empty() {
|
||||
Step::Send
|
||||
} else {
|
||||
// The earliest of the spent windows: it is the first moment
|
||||
// the situation can have changed, and if the others are still
|
||||
// full this comes straight back here.
|
||||
match spent
|
||||
.iter()
|
||||
.filter_map(|window| epoch_of(window.resets_at.as_deref()))
|
||||
.min_by(f64::total_cmp)
|
||||
{
|
||||
Some(resets) => Step::WaitUntil(resets),
|
||||
// Spent with no reset time anybody could read. Not a
|
||||
// reason to send: what is known is that the limit is on.
|
||||
None => Step::WaitUntil(at + BACKOFF),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Logged out, unreachable, or the endpoint refused us -- and nothing
|
||||
// at all, which is a session whose machine or provider has gone. None
|
||||
// of them says the limit has lifted, and sending on any of them is
|
||||
// exactly the "inferred value presented as a measured one" this is
|
||||
// built to avoid.
|
||||
_ => Step::WaitUntil(at + BACKOFF),
|
||||
};
|
||||
match step {
|
||||
// Waiting past the point where a real window would have reset means
|
||||
// whatever is wrong is not going to fix itself.
|
||||
Step::WaitUntil(_) if at - owed.scheduled.since > GIVE_UP => Step::GiveUp,
|
||||
Step::WaitUntil(next) => Step::WaitUntil(next.max(at + AT_LEAST)),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// An RFC-3339 timestamp as epoch seconds, and `None` for one that is absent
|
||||
/// or unreadable -- the same two answers the phone's countdown makes, kept
|
||||
/// apart from each other nowhere here because both mean "this cannot decide
|
||||
/// when to ask".
|
||||
fn epoch_of(resets_at: Option<&str>) -> Option<f64> {
|
||||
let text = resets_at?;
|
||||
time::OffsetDateTime::parse(text, &time::format_description::well_known::Rfc3339)
|
||||
.ok()
|
||||
.map(|at| at.unix_timestamp() as f64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::ScheduledResume;
|
||||
use crate::usage::UsageWindow;
|
||||
|
||||
fn owed(since: f64) -> OwedResume {
|
||||
OwedResume {
|
||||
session_id: "s1".to_string(),
|
||||
machine: "local".to_string(),
|
||||
provider: crate::usage::CLAUDE,
|
||||
scheduled: ScheduledResume { at: since, since },
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
|
||||
UsageSnapshot {
|
||||
provider: crate::usage::CLAUDE.to_string(),
|
||||
machine: "local".to_string(),
|
||||
machine_name: "this machine".to_string(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
state,
|
||||
windows,
|
||||
fetched_at: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn window(percent: f64, resets_at: Option<&str>) -> UsageWindow {
|
||||
UsageWindow {
|
||||
kind: "session".to_string(),
|
||||
label: "5-hour window".to_string(),
|
||||
percent,
|
||||
duration_minutes: Some(300),
|
||||
resets_at: resets_at.map(str::to_string),
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_meter_with_room_in_it_is_the_only_thing_that_sends() {
|
||||
let clear = snapshot(UsageState::Ok, vec![window(41.0, None)]);
|
||||
assert_eq!(decide(Some(&clear), &owed(0.0), 100.0), Step::Send);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_still_spent_moves_the_check_to_its_own_reset_time() {
|
||||
// The case the feature exists for: the wait was scheduled for one
|
||||
// time, the limit is still on, and the endpoint now names another.
|
||||
let at = 1_788_546_972.0;
|
||||
let later = "2026-09-05T12:00:00+00:00";
|
||||
let spent = snapshot(UsageState::Ok, vec![window(100.0, Some(later))]);
|
||||
assert_eq!(
|
||||
decide(Some(&spent), &owed(at - 60.0), at),
|
||||
Step::WaitUntil(epoch_of(Some(later)).expect("parses"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reset_time_already_past_still_waits_a_little() {
|
||||
let at = 1_788_546_972.0;
|
||||
let spent = snapshot(
|
||||
UsageState::Ok,
|
||||
vec![window(100.0, Some("2020-01-01T00:00:00+00:00"))],
|
||||
);
|
||||
assert_eq!(
|
||||
decide(Some(&spent), &owed(at - 60.0), at),
|
||||
Step::WaitUntil(at + AT_LEAST)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_earliest_spent_window_is_the_one_worth_waiting_on() {
|
||||
let at = 1_788_546_972.0;
|
||||
let soon = "2026-09-05T12:00:00+00:00";
|
||||
let far = "2026-09-09T12:00:00+00:00";
|
||||
let mut weekly = window(100.0, Some(far));
|
||||
weekly.kind = "weekly_all".to_string();
|
||||
let spent = snapshot(UsageState::Ok, vec![window(100.0, Some(soon)), weekly]);
|
||||
assert_eq!(
|
||||
decide(Some(&spent), &owed(at - 60.0), at),
|
||||
Step::WaitUntil(epoch_of(Some(soon)).expect("parses"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_meter_that_could_not_be_asked_never_sends() {
|
||||
let at = 1_788_546_972.0;
|
||||
for state in [
|
||||
UsageState::NotLoggedIn,
|
||||
UsageState::Unreachable {
|
||||
detail: "no route".to_string(),
|
||||
},
|
||||
UsageState::Failed {
|
||||
detail: "429".to_string(),
|
||||
},
|
||||
] {
|
||||
let broken = snapshot(state.clone(), Vec::new());
|
||||
assert_eq!(
|
||||
decide(Some(&broken), &owed(at - 60.0), at),
|
||||
Step::WaitUntil(at + BACKOFF),
|
||||
"{state:?}"
|
||||
);
|
||||
}
|
||||
// And no snapshot at all -- a machine or provider edited away under a
|
||||
// session that was waiting on it.
|
||||
assert_eq!(
|
||||
decide(None, &owed(at - 60.0), at),
|
||||
Step::WaitUntil(at + BACKOFF)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_longer_than_any_real_window_gives_up_rather_than_retrying_for_ever() {
|
||||
let at = 1_788_546_972.0;
|
||||
let broken = snapshot(
|
||||
UsageState::Unreachable {
|
||||
detail: "no route".to_string(),
|
||||
},
|
||||
Vec::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
decide(Some(&broken), &owed(at - GIVE_UP - 1.0), at),
|
||||
Step::GiveUp
|
||||
);
|
||||
// A meter that answers is still allowed to send on the same tick: the
|
||||
// ceiling bounds waiting, not resuming.
|
||||
let clear = snapshot(UsageState::Ok, vec![window(3.0, None)]);
|
||||
assert_eq!(
|
||||
decide(Some(&clear), &owed(at - GIVE_UP - 1.0), at),
|
||||
Step::Send
|
||||
);
|
||||
}
|
||||
}
|
||||
+1066
-220
File diff suppressed because it is too large.
Load diff
+281
-78
@@ -47,7 +47,6 @@
|
||||
//! same way as the rest, against 2.1.237 on 2026-08-29.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -57,8 +56,11 @@ use serde_json::{Value, json};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued};
|
||||
use super::driver::{
|
||||
AttachmentRef, BackgroundTask, Driver, Event, EventSink, SessionStatus, Unqueued,
|
||||
};
|
||||
use super::process;
|
||||
use super::subagent::Subagents;
|
||||
use super::transport::{Launch, Streams, Transport};
|
||||
use crate::config::{ProviderConfig, SessionConfig};
|
||||
use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call};
|
||||
@@ -213,8 +215,13 @@ impl ClaudeDriver {
|
||||
transport: &Transport,
|
||||
session_dir: &Path,
|
||||
sink: EventSink,
|
||||
subagents: Arc<Subagents>,
|
||||
initial_status: SessionStatus,
|
||||
) -> Result<Self> {
|
||||
let state = Arc::new(Mutex::new(Translator::new(session_dir.to_path_buf())));
|
||||
let state = Arc::new(Mutex::new(Translator::new(
|
||||
session_dir.to_path_buf(),
|
||||
subagents,
|
||||
)));
|
||||
let queue = Arc::new(Mutex::new(Queue::default()));
|
||||
let reading = Arc::new(AtomicBool::new(true));
|
||||
|
||||
@@ -257,6 +264,9 @@ impl ClaudeDriver {
|
||||
Self::start(meta, provider, transport, session_dir)?
|
||||
}
|
||||
};
|
||||
if !started_here && matches!(initial_status, SessionStatus::Idle | SessionStatus::Waiting) {
|
||||
state.lock().unwrap().mark_settled();
|
||||
}
|
||||
|
||||
// A process this driver has just started has been asked for nothing,
|
||||
// which is what idle means. Said here because nothing else will: the
|
||||
@@ -316,14 +326,22 @@ impl ClaudeDriver {
|
||||
format!("{} {}", provider.name, transport.describe()),
|
||||
));
|
||||
|
||||
Ok(Self {
|
||||
let driver = Self {
|
||||
sink,
|
||||
queue,
|
||||
to_child,
|
||||
state,
|
||||
session_dir: session_dir.to_path_buf(),
|
||||
reading,
|
||||
})
|
||||
};
|
||||
if !started_here {
|
||||
// Claude 2.1.261 sends a full background-task snapshot after a
|
||||
// repeated initialize. That repairs a completion edge missed by a
|
||||
// backend that was down while the CLI kept running; older CLIs
|
||||
// accept the request and simply send no snapshot.
|
||||
driver.send_control(json!({"subtype": "initialize"}), None);
|
||||
}
|
||||
Ok(driver)
|
||||
}
|
||||
|
||||
/// Starts a new CLI for this session, with its streams in the session
|
||||
@@ -351,6 +369,12 @@ impl ClaudeDriver {
|
||||
if let Some(mode) = &meta.permission_mode {
|
||||
push("--permission-mode", mode);
|
||||
}
|
||||
// Launch-only: see `SessionConfig::effort`. Omitted entirely when
|
||||
// unset, so the CLI's own default is what an unchosen session gets
|
||||
// rather than a level this app decided to call the default.
|
||||
if let Some(effort) = &meta.effort {
|
||||
push("--effort", effort);
|
||||
}
|
||||
// Named at birth, so this session is the same session in the CLI's own
|
||||
// picker and in what other agents see.
|
||||
//
|
||||
@@ -382,11 +406,11 @@ impl ClaudeDriver {
|
||||
|
||||
// Fresh logs, because the offsets that index them start at zero and
|
||||
// everything the previous process said is already in the transcript.
|
||||
let stdin = make_fifo(&session_dir.join(STDIN_FIFO))?;
|
||||
let stdout = create_log(&session_dir.join(STDOUT_LOG))?;
|
||||
let stderr = create_log(&session_dir.join(STDERR_LOG))?;
|
||||
let stdin = process::make_fifo(&session_dir.join(STDIN_FIFO))?;
|
||||
let stdout = process::create_log(&session_dir.join(STDOUT_LOG))?;
|
||||
let stderr = process::create_log(&session_dir.join(STDERR_LOG))?;
|
||||
|
||||
let program = provider.command.as_deref().unwrap_or("claude");
|
||||
let program = provider.program();
|
||||
let launch = Launch::new(program, args, meta.cwd.as_deref());
|
||||
let child = transport.spawn(
|
||||
&launch,
|
||||
@@ -488,12 +512,16 @@ impl ClaudeDriver {
|
||||
}
|
||||
|
||||
impl Driver for ClaudeDriver {
|
||||
fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
|
||||
self.state.lock().unwrap().background_tasks()
|
||||
}
|
||||
|
||||
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
|
||||
let mut content = Vec::new();
|
||||
// An image goes into the message itself; the model looks at it. Any
|
||||
// other file stays where the upload put it and the message says where,
|
||||
// because the CLI can read a file by path and a model cannot be handed
|
||||
// a trace any other way. Named after the text, so the words come first.
|
||||
// a trace any other way.
|
||||
let mut files = Vec::new();
|
||||
for id in &attachments {
|
||||
let sent = if crate::media::media_type_for(id).is_some() {
|
||||
@@ -507,18 +535,6 @@ impl Driver for ClaudeDriver {
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut body = text.clone();
|
||||
for path in files {
|
||||
if !body.is_empty() {
|
||||
body.push_str("\n\n");
|
||||
}
|
||||
body.push_str(&format!("Attached file: {}", path.display()));
|
||||
}
|
||||
if !body.is_empty() {
|
||||
content.push(json!({"type": "text", "text": body}));
|
||||
}
|
||||
let line =
|
||||
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string();
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
// Saying so beats writing into a fifo that nothing is reading, which is
|
||||
// what this used to do -- the message went nowhere and looked exactly
|
||||
@@ -531,6 +547,15 @@ impl Driver for ClaudeDriver {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Built under the lock because whether this is a steer decides what the
|
||||
// CLI is told, and the answer must be the same one the branch below acts
|
||||
// on -- see `super::driver::message_body`.
|
||||
let body = super::driver::message_body(&text, &files, queue.running);
|
||||
if !body.is_empty() {
|
||||
content.push(json!({"type": "text", "text": body}));
|
||||
}
|
||||
let line =
|
||||
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string();
|
||||
if queue.running {
|
||||
// Into the running turn, now. Announced when the CLI shows it has
|
||||
// been round the model again -- see `Queue`.
|
||||
@@ -792,11 +817,39 @@ async fn follow(
|
||||
process::Liveness::Dead if complete > 0 => {}
|
||||
process::Liveness::Dead => {
|
||||
queue.lock().unwrap().close(&sink, "the session ended");
|
||||
let detail = stderr_tail(&stderr_path);
|
||||
if !detail.is_empty() {
|
||||
let _ = sink.send(Event::Error {
|
||||
message: format!("{label} exited:\n{detail}"),
|
||||
});
|
||||
if !process::stopping(&session_dir) {
|
||||
let detail = stderr_tail(&stderr_path);
|
||||
// A token the CLI will not accept leaves the chat
|
||||
// *unrecoverable* rather than merely failed: every later
|
||||
// start passes the same `--resume` and dies the same way.
|
||||
// So it is forgotten, and `Cleared` marks where the model's
|
||||
// context stopped -- as for a Codex thread with no rollout.
|
||||
let refused =
|
||||
missing_conversation(&detail) && read_resume_token(&session_dir).is_some();
|
||||
// Both the message and the divider follow the removal
|
||||
// rather than the diagnosis: one that failed leaves the
|
||||
// session as stuck as it was.
|
||||
let recovered = refused && forget_resume_token(&session_dir);
|
||||
if !detail.is_empty() {
|
||||
// The CLI's sentence reads like the chat is lost when
|
||||
// one more message is all it needs, and this is the
|
||||
// only place anyone sees it. Its words stay on top,
|
||||
// being what a person would search for.
|
||||
let message = if recovered {
|
||||
format!(
|
||||
"{label} exited:\n{detail}\n\nThat conversation is gone from the \
|
||||
CLI, so this session has stopped trying to resume it. Send a \
|
||||
message to carry on in a new one -- everything above is kept, \
|
||||
but the model starts without it."
|
||||
)
|
||||
} else {
|
||||
format!("{label} exited:\n{detail}")
|
||||
};
|
||||
let _ = sink.send(Event::Error { message });
|
||||
}
|
||||
if recovered {
|
||||
let _ = sink.send(Event::Cleared);
|
||||
}
|
||||
}
|
||||
let _ = sink.send(Event::Status {
|
||||
state: SessionStatus::Exited,
|
||||
@@ -857,8 +910,12 @@ fn translate_line(
|
||||
return true;
|
||||
};
|
||||
let opens_a_model_call = starts_a_model_call(&message);
|
||||
let parent_running = queue.lock().unwrap().running;
|
||||
let (events, new_session_id, before) = {
|
||||
let mut state = state.lock().unwrap();
|
||||
if parent_running {
|
||||
state.mark_running();
|
||||
}
|
||||
let before = state.session_id.clone();
|
||||
let events = state.translate(&message);
|
||||
let after = state.session_id.clone();
|
||||
@@ -927,10 +984,14 @@ fn translate_line(
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Either status the turn can end in -- see `SessionStatus::Waiting`.
|
||||
// A turn that ended with a subagent still running is over for this
|
||||
// queue's purposes: the CLI will read the next message, and holding
|
||||
// one back until the subagent reported would sit on it indefinitely.
|
||||
if matches!(
|
||||
event,
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle
|
||||
state: SessionStatus::Idle | SessionStatus::Waiting
|
||||
}
|
||||
) {
|
||||
// The case that must not be missed: a message written after the
|
||||
@@ -1034,44 +1095,6 @@ fn stderr_tail(path: &Path) -> String {
|
||||
tail_of(&kept)
|
||||
}
|
||||
|
||||
/// Creates the stdin fifo if it is not already there, and opens it read-write
|
||||
/// for the process to inherit.
|
||||
///
|
||||
/// Read-write is the whole trick: a fifo opened read-only delivers EOF as soon
|
||||
/// as the last writer closes, so the process would exit the moment this server
|
||||
/// did -- exactly what leaving it running has to prevent. Holding it open for
|
||||
/// writing means the process is its own last writer.
|
||||
fn make_fifo(path: &Path) -> Result<std::fs::File> {
|
||||
if !path.exists() {
|
||||
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
|
||||
.with_context(|| format!("{} is not a usable path", path.display()))?;
|
||||
// SAFETY: a nul-terminated path this call only reads, and a mode with
|
||||
// no bits the kernel can object to. Owner-only, like everything else in
|
||||
// a session directory: this carries what the person typed.
|
||||
let made = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
|
||||
if made != 0 {
|
||||
return Err(std::io::Error::last_os_error())
|
||||
.with_context(|| format!("creating the fifo {}", path.display()));
|
||||
}
|
||||
}
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("opening the fifo {}", path.display()))
|
||||
}
|
||||
|
||||
/// A fresh, empty, owner-only log for one of the process's output streams.
|
||||
fn create_log(path: &Path) -> Result<std::fs::File> {
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("creating {}", path.display()))
|
||||
}
|
||||
|
||||
pub(super) fn read_resume_token(session_dir: &Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(session_dir.join(RESUME_FILE)).ok()?;
|
||||
serde_json::from_str::<Value>(&text)
|
||||
@@ -1088,6 +1111,38 @@ pub(super) fn write_resume_token(session_dir: &Path, session_id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a resume token the CLI has refused, so the next start makes a session
|
||||
/// rather than repeating the failure. Answers whether it is really gone: the
|
||||
/// caller promises somebody the session has stopped resuming, and a failed
|
||||
/// removal would make that a promise this server cannot keep.
|
||||
fn forget_resume_token(session_dir: &Path) -> bool {
|
||||
let path = session_dir.join(RESUME_FILE);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => true,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"couldn't forget the refused resume token at {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the CLI refused `--resume` because the token names nothing it has.
|
||||
///
|
||||
/// Two sentences say it and both recover the same way; matched on the phrases
|
||||
/// that carry the fact, since the rest names the id. Measured against 2.1.237:
|
||||
/// a valid UUID with nothing behind it gives the first, a non-UUID matching no
|
||||
/// title the second.
|
||||
fn missing_conversation(detail: &str) -> bool {
|
||||
let detail = detail.to_ascii_lowercase();
|
||||
detail.contains("no conversation found with session id")
|
||||
|| (detail.contains("--resume requires a valid session id")
|
||||
&& detail.contains("does not match any session title"))
|
||||
}
|
||||
|
||||
/// Where an uploaded attachment is, as a path the CLI can be told.
|
||||
///
|
||||
/// Absolute, because the CLI's working directory is the session's and the
|
||||
@@ -1095,14 +1150,7 @@ pub(super) fn write_resume_token(session_dir: &Path, session_id: &str) {
|
||||
/// one this server would have written, so a crafted id cannot name a file
|
||||
/// outside the session.
|
||||
fn attachment_path(session_dir: &Path, id: &str) -> Result<PathBuf> {
|
||||
if !id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|
||||
|| id.contains("..")
|
||||
{
|
||||
anyhow::bail!("invalid attachment id");
|
||||
}
|
||||
let path = session_dir.join("attachments").join(id);
|
||||
let path = super::driver::attachment_path(session_dir, id)?;
|
||||
// A file copied to the session's own machine is named where it landed there
|
||||
// -- `routes::upload_attachment` writes that down beside it -- because the
|
||||
// path has to be one the CLI can open, not one this server can.
|
||||
@@ -1163,7 +1211,10 @@ mod tests {
|
||||
/// this" and "the transcript records that".
|
||||
fn events_from_lines(lines: &[&str]) -> Vec<Event> {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf())));
|
||||
let state = Arc::new(Mutex::new(Translator::new(
|
||||
dir.path().to_path_buf(),
|
||||
Arc::new(Subagents::new(dir.path().to_path_buf())),
|
||||
)));
|
||||
let queue = Arc::new(Mutex::new(Queue::default()));
|
||||
let (sink, mut out) = mpsc::unbounded_channel::<Event>();
|
||||
for line in lines {
|
||||
@@ -1187,7 +1238,10 @@ mod tests {
|
||||
interject: impl FnOnce(&Arc<Mutex<Queue>>),
|
||||
) -> Vec<Event> {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf())));
|
||||
let state = Arc::new(Mutex::new(Translator::new(
|
||||
dir.path().to_path_buf(),
|
||||
Arc::new(Subagents::new(dir.path().to_path_buf())),
|
||||
)));
|
||||
let queue = Arc::new(Mutex::new(Queue::default()));
|
||||
let (sink, mut out) = mpsc::unbounded_channel::<Event>();
|
||||
let mut interject = Some(interject);
|
||||
@@ -1481,7 +1535,10 @@ mod tests {
|
||||
// doing.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (sink, mut received) = mpsc::unbounded_channel();
|
||||
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf())));
|
||||
let state = Arc::new(Mutex::new(Translator::new(
|
||||
dir.path().to_path_buf(),
|
||||
Arc::new(Subagents::new(dir.path().to_path_buf())),
|
||||
)));
|
||||
let queue = Arc::new(Mutex::new(Queue::default()));
|
||||
|
||||
let text = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"working"}},"parent_tool_use_id":null}"#;
|
||||
@@ -1526,7 +1583,10 @@ mod tests {
|
||||
// session back to work.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (sink, mut received) = mpsc::unbounded_channel();
|
||||
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf())));
|
||||
let state = Arc::new(Mutex::new(Translator::new(
|
||||
dir.path().to_path_buf(),
|
||||
Arc::new(Subagents::new(dir.path().to_path_buf())),
|
||||
)));
|
||||
let queue = Arc::new(Mutex::new(Queue::default()));
|
||||
queue.lock().unwrap().close(&sink, "the session ended");
|
||||
|
||||
@@ -1549,4 +1609,147 @@ mod tests {
|
||||
assert!(received.try_recv().is_err());
|
||||
assert!(queue.closed);
|
||||
}
|
||||
|
||||
/// Both refusals verbatim from 2.1.237, and the exits that must *not* be
|
||||
/// read as one: a session that merely failed still has a conversation, and
|
||||
/// forgetting its token would discard the model's context for nothing.
|
||||
#[test]
|
||||
fn both_refused_resume_tokens_are_recognised() {
|
||||
assert!(missing_conversation(
|
||||
"No conversation found with session ID: a1c6c855-86cd-4f47-8b50-eaea85be3579"
|
||||
));
|
||||
assert!(missing_conversation(
|
||||
"Error: --resume requires a valid session ID or session title when used with \
|
||||
--print. Usage: claude -p --resume <session-id|title>. Provided value \
|
||||
\"not-a-real-session\" is not a UUID and does not match any session title."
|
||||
));
|
||||
assert!(!missing_conversation("Error: connection closed"));
|
||||
assert!(!missing_conversation(
|
||||
"Credit balance is too low to run this request"
|
||||
));
|
||||
// The usage line alone is a different complaint: `--resume` was passed
|
||||
// wrongly, not given a token that named nothing.
|
||||
assert!(!missing_conversation(
|
||||
"Error: --resume requires a valid session ID or session title when used with --print."
|
||||
));
|
||||
}
|
||||
|
||||
/// The composition the reader performs, not just the matcher: `stderr_tail`
|
||||
/// both truncates and trims, so the phrase can survive the CLI and still
|
||||
/// not reach `missing_conversation`.
|
||||
#[test]
|
||||
fn the_refusal_survives_the_stderr_tail_that_carries_it() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("stderr.log");
|
||||
// Verbatim from 2.1.237, trailing newline included -- a shell's error
|
||||
// ends with one, which is exactly what `tail_of` exists to trim.
|
||||
std::fs::write(
|
||||
&path,
|
||||
"No conversation found with session ID: a1c6c855-86cd-4f47-8b50-eaea85be3579\n",
|
||||
)
|
||||
.expect("write stderr");
|
||||
assert!(missing_conversation(&stderr_tail(&path)));
|
||||
|
||||
// And still found when noise precedes it, since the refusal is the last
|
||||
// thing the CLI writes and the tail is taken from the end.
|
||||
let mut noisy = "some earlier warning\n".repeat(STDERR_LINES_KEPT * 2);
|
||||
noisy.push_str("No conversation found with session ID: a1c6c855\n");
|
||||
std::fs::write(&path, noisy).expect("write noisy stderr");
|
||||
assert!(missing_conversation(&stderr_tail(&path)));
|
||||
}
|
||||
|
||||
/// A dead process, a refusal in its stderr log, and a token on disk --
|
||||
/// driven through the reader that performs the recovery rather than through
|
||||
/// its parts, because the wiring is what the parts cannot check.
|
||||
async fn exit_with_stderr(stderr: &str) -> (tempfile::TempDir, Vec<Event>) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
// Empty but present: an unreadable stdout is a different path that
|
||||
// returns before any of this.
|
||||
std::fs::write(dir.path().join(STDOUT_LOG), "").expect("stdout log");
|
||||
std::fs::write(dir.path().join(STDERR_LOG), stderr).expect("stderr log");
|
||||
write_resume_token(dir.path(), "a1c6c855-86cd-4f47-8b50-eaea85be3579");
|
||||
|
||||
let (sink, mut events) = mpsc::unbounded_channel();
|
||||
follow(
|
||||
dir.path().to_path_buf(),
|
||||
// No `/proc` entry, so `liveness()` reads `Dead` -- the state this
|
||||
// arm exists for, without having to kill anything.
|
||||
process::Record {
|
||||
pid: u32::MAX,
|
||||
started: 0,
|
||||
detail: process::Detail::Stdio { stdout_read: 0 },
|
||||
},
|
||||
0,
|
||||
Arc::new(Mutex::new(Translator::new(
|
||||
dir.path().to_path_buf(),
|
||||
Arc::new(Subagents::new(dir.path().to_path_buf())),
|
||||
))),
|
||||
sink,
|
||||
Arc::new(Mutex::new(Queue::default())),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
"claude-cli on vm".to_string(),
|
||||
)
|
||||
.await;
|
||||
let seen = std::iter::from_fn(|| events.try_recv().ok()).collect();
|
||||
(dir, seen)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_session_whose_conversation_vanished_is_left_able_to_start_again() {
|
||||
let (dir, seen) = exit_with_stderr(
|
||||
"No conversation found with session ID: a1c6c855-86cd-4f47-8b50-eaea85be3579\n",
|
||||
)
|
||||
.await;
|
||||
|
||||
let reported = seen
|
||||
.iter()
|
||||
.find_map(|event| match event {
|
||||
Event::Error { message } => Some(message.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the exit is reported");
|
||||
// The CLI's own words, and then what to do about them.
|
||||
assert!(
|
||||
reported.contains("No conversation found with session ID"),
|
||||
"{reported}"
|
||||
);
|
||||
assert!(
|
||||
reported.contains("Send a message to carry on"),
|
||||
"{reported}"
|
||||
);
|
||||
// The divider, so the transcript says where the model's context ended.
|
||||
assert!(seen.contains(&Event::Cleared), "{seen:?}");
|
||||
// And the point of all of it: the next start has no token to repeat.
|
||||
assert_eq!(read_resume_token(dir.path()), None);
|
||||
}
|
||||
|
||||
/// The same reader on an exit that is *not* a refusal. The token is what
|
||||
/// the session is still worth resuming from, so it has to survive.
|
||||
#[tokio::test]
|
||||
async fn an_ordinary_failure_keeps_the_token_it_can_still_resume_from() {
|
||||
let (dir, seen) = exit_with_stderr("Error: connection closed\n").await;
|
||||
|
||||
assert!(!seen.contains(&Event::Cleared), "{seen:?}");
|
||||
assert_eq!(
|
||||
read_resume_token(dir.path()).as_deref(),
|
||||
Some("a1c6c855-86cd-4f47-8b50-eaea85be3579")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_token_is_forgotten_so_the_next_start_makes_a_session() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
write_resume_token(dir.path(), "a1c6c855-86cd-4f47-8b50-eaea85be3579");
|
||||
assert!(read_resume_token(dir.path()).is_some());
|
||||
|
||||
assert!(forget_resume_token(dir.path()));
|
||||
|
||||
// Gone, so `launch` pushes `--name` instead of `--resume` and the CLI
|
||||
// is asked for a session it can actually make.
|
||||
assert_eq!(read_resume_token(dir.path()), None);
|
||||
// Removing an absent one succeeds too -- the check and the remove are
|
||||
// separate, so the file can go between them.
|
||||
assert!(forget_resume_token(dir.path()));
|
||||
assert_eq!(read_resume_token(dir.path()), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user