Compare commits
114
Commits
4ab26f068e
..
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.
|
||||
@@ -21,6 +21,3 @@ certs/
|
||||
config.ron
|
||||
config.json
|
||||
sessions/
|
||||
|
||||
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
||||
iris/target/
|
||||
@@ -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,17 +225,23 @@ 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
|
||||
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening
|
||||
and stream effects in `SessionScreen.kt`.
|
||||
- `TODO.md` — the working list.
|
||||
- `RUST.md` — the plan for moving the app to Rust (on the `rustify`
|
||||
branch of the `ai-app-2` clone): what has to be reproduced, the
|
||||
framework decision, and the ordered experiments with their pass
|
||||
conditions. Read it before touching anything under that branch.
|
||||
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
||||
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
||||
rather than a script kept here) and the APK, in parallel. It points at
|
||||
@@ -142,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
|
||||
@@ -297,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.
|
||||
@@ -330,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
|
||||
|
||||
@@ -383,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
|
||||
@@ -480,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,
|
||||
@@ -487,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`
|
||||
|
||||
@@ -1,611 +0,0 @@
|
||||
# Moving the app to Rust
|
||||
|
||||
Working document for the question Iris asked on 2026-09-04: what are the
|
||||
options for switching the phone app to Rust, ideally pure Rust with one UI
|
||||
framework shared with a future winit-based desktop application, at full
|
||||
feature parity and without giving up anything native, performance
|
||||
especially. Constraints she set: no Dioxus and nothing that draws through a
|
||||
WebView; **no UI DSL** (which rules out Makepad and Slint); the result
|
||||
should stay lightweight; platform-specific pieces are fine to maintain;
|
||||
reimplementing a framework piece from scratch where it does not fit is
|
||||
fine; effort and elapsed time do not matter, long-term robustness does;
|
||||
this clone is where things get tried before anything is committed to
|
||||
`ai-app`. Her own library, [iris](https://github.com/cat16/iris), is the
|
||||
**in-house framework to be built up** for this, with Masonry as the
|
||||
yardstick it is measured against.
|
||||
|
||||
Decisions get a date and a reason here, the way `PLAN.md` does. Nothing in
|
||||
this file has been tried yet unless a section says it has.
|
||||
|
||||
## What has to be reproduced
|
||||
|
||||
The app is ~19,000 lines of Kotlin. It splits three ways, and the split is
|
||||
what decides how much of a port is mechanical.
|
||||
|
||||
**Pure logic with no Compose or Android in it, ~4,500 lines.** `Api.kt`
|
||||
(1,142), `Events.kt`, `EventStream.kt`, `Sse.kt`, `TranscriptCache.kt`
|
||||
(589, touches `java.io.File` only), `TranscriptSource.kt`,
|
||||
`MarkdownSyntax.kt`, `Languages.kt`, `Highlighter.kt`, `Ansi.kt`,
|
||||
`ResetCountdown.kt`, `Durations.kt`, `Sizes.kt`, `ModelName.kt`,
|
||||
`LoadState.kt`, `ImportableStream.kt`. `TranscriptUnits.kt` and
|
||||
`TranscriptItems.kt` (the event fold into rows, ~940 lines) are logic with
|
||||
a handful of Compose annotations. This is also exactly the code that has
|
||||
JVM unit tests today. All of it ports directly, and most of it already has a
|
||||
Rust twin in `server/`: `Events.kt` is a hand-kept mirror of
|
||||
`session/driver.rs`'s enum, the highlighter and the syntax scanner exist on
|
||||
the server for the explorer, and the cache compares the server's own JSON
|
||||
lines. **Sharing these types between server and app is the single largest
|
||||
"keep things in sync" win available, and it does not depend on which UI
|
||||
framework wins.**
|
||||
|
||||
**Compose UI, ~13,000 lines.** Screens, dialogs, the transcript list, the
|
||||
markdown renderer's customisations, tool cards, the file explorer viewer and
|
||||
editor. This is the part a UI framework choice is about.
|
||||
|
||||
**Android platform code, ~1,500 lines**, spread over 20 files. Every one of
|
||||
these is a Java-side object that no Rust framework can replace, because
|
||||
Android only offers them as Java classes:
|
||||
|
||||
- `NotificationService` — a **foreground service** holding the
|
||||
`/notifications` SSE stream while the app is closed, with its ongoing
|
||||
notification, `specialUse` type and the `POST_NOTIFICATIONS` request.
|
||||
- `MainActivity` — edge-to-edge, the `ACCESS_LOCAL_NETWORK` runtime
|
||||
permission (Android 17), `singleTop` intent routing for `aiapp://enroll`,
|
||||
notification taps, and the **share sheet** (`ACTION_SEND`, any MIME type).
|
||||
- `ServerConfig` — the bearer token sealed under an **Android Keystore**
|
||||
AES-GCM key, shared with Dev Updater through `wg-app-link`'s `:link`.
|
||||
- `EnrollmentScanActivity` — the in-app **QR scanner** (zxing, camera).
|
||||
- `Attachments` — `ContentResolver` reads of shared URIs, `BitmapFactory`
|
||||
decode and downscale, **EXIF** orientation.
|
||||
- `SessionImage` — bitmap decode for produced images.
|
||||
- `ScrollAnchor`, `Drafts` — `SharedPreferences`; `CrashLog` — `filesDir`.
|
||||
- `TranscriptCache` — `cacheDir`.
|
||||
- `DebugStats`/`FrameStats` — `Choreographer` frame timing and the render
|
||||
report; `runtime-tracing` names composables in a system trace.
|
||||
|
||||
So **"pure Rust" on Android means Rust owns every line of logic and
|
||||
drawing, behind a thin shell of Java stubs**, and a packaging step that
|
||||
produces a signed APK. How thin, and whether Gradle is inevitable, are
|
||||
answered below.
|
||||
|
||||
### How much Java is unavoidable, and why
|
||||
|
||||
Rust can *call* any Android API through JNI (`jni` crate, with
|
||||
`ndk-context` handing over the `JavaVM` and the Activity): posting a
|
||||
notification, `startForegroundService`, the Keystore, `ContentResolver`
|
||||
reads, permission requests, `WindowInsets`, the clipboard. None of that
|
||||
needs a line of Kotlin. What JNI cannot do is *define* a class that the
|
||||
system instantiates **by name from the manifest** — an `Activity`, a
|
||||
`Service`, an `Application`, a `BroadcastReceiver`. Those must exist as dex
|
||||
bytecode inside the APK before any Rust runs, because the framework
|
||||
constructs them and only then calls into native code. `NativeActivity` is
|
||||
the platform's own stub for the Activity case; there is no
|
||||
`NativeService`, and android-view ships its own `View` subclass for the
|
||||
same reason.
|
||||
|
||||
So the floor is roughly **two Java classes of ten lines each**: an
|
||||
`Activity` and a `Service` whose lifecycle methods are declared `native`
|
||||
and registered from `JNI_OnLoad`, plus whatever android-view already
|
||||
provides. Everything they would have done in Kotlin — insets, intent
|
||||
routing, the SSE follow loop, the notification builder — is Rust reached
|
||||
through those stubs. Writing the stubs in Java rather than Kotlin drops
|
||||
`kotlinc` from the toolchain; `javac` comes with the JDK Gradle already
|
||||
needs. Generating the dex from Rust is not worth it: there is no mature
|
||||
Rust dex writer, and the stubs never change.
|
||||
|
||||
### Can the APK be built without Gradle?
|
||||
|
||||
Yes. An APK is a zip containing a binary-XML `AndroidManifest.xml`,
|
||||
`resources.arsc`, `classes.dex`, `lib/<abi>/*.so` and assets, aligned and
|
||||
signed with the v2 scheme. The tools are `aapt2` (manifest and resources),
|
||||
`d8` (Java bytecode to dex), `zipalign` and `apksigner`, all in the SDK's
|
||||
`build-tools`, none of them Gradle. Three ways to drive them:
|
||||
|
||||
- **A `cargo xtask`** (or `build.rs`-adjacent script) that runs `cargo ndk`
|
||||
for each ABI, `javac` + `d8` for the stubs, `aapt2 link`, `zipalign`,
|
||||
`apksigner`. About 150 lines, every step visible, no AGP, no Gradle
|
||||
daemon holding 2.8 GB between builds. The pinned-CA constant becomes a
|
||||
`build.rs` reading the same `certs/ca.pem` path.
|
||||
- **[cargo-apk2](https://github.com/mzdk100/cargo-apk2)**: the maintained
|
||||
successor to cargo-apk, and unlike it compiles `java_sources` /
|
||||
`kotlin_sources` into the dex and declares multiple activities **and
|
||||
services** with intent filters from `[package.metadata.android]`, with
|
||||
per-profile keystores and optional `aapt2`. Exactly the shape needed;
|
||||
the question is whether a third-party tool with one maintainer beats
|
||||
150 lines we own.
|
||||
- **cargo-apk / xbuild**: unmaintained and `NativeActivity`-only. No.
|
||||
|
||||
What Gradle would take with it: Android Lint (which found two real bugs
|
||||
here, but in Kotlin that would no longer exist — with forty lines of Java
|
||||
stubs there is little left for it to find), manifest merging, R8, and the
|
||||
generated-source plumbing. What it gives back: one toolchain, `cargo`
|
||||
end to end, and Dev Updater keeps calling `build-apk.sh` exactly as now.
|
||||
**Recommendation: the xtask**, with cargo-apk2 read for the details it
|
||||
already got right (v2 signing, `uses-feature`, ABI splits).
|
||||
|
||||
### The behaviours that are hard to get back
|
||||
|
||||
Reading the Compose code for what a replacement must be able to express,
|
||||
rather than what it happens to look like:
|
||||
|
||||
1. **The transcript is one selectable body of text.** One
|
||||
`SelectionContainer` around the whole lazy list, so a selection runs from
|
||||
a reply into the tool output beneath it. The framework needs selectable
|
||||
read-only rich text across many rows, with the platform's selection
|
||||
handles and clipboard on the phone.
|
||||
2. **Rich inline text**: markdown with links (one tap detector per text,
|
||||
not a node per link), inline code chips drawn behind the text, tables
|
||||
with wrapping cells and a sideways scroll, syntax-highlighted fences,
|
||||
ANSI colour in tool output, Nerd Font icon glyphs. Needs a text layout
|
||||
engine with spans, not just styled labels.
|
||||
3. **A bottom-anchored virtualised list of variable-height rows**, paged in
|
||||
both directions (800-event pages, `HISTORY_SCREENS` measured in
|
||||
viewports), with a saved scroll anchor per session, "hold the edge
|
||||
nearest the tap" when a row expands (`holdTopEdge`, done in the layout
|
||||
pass so the wrong frame is never drawn), and rows keyed so that a run of
|
||||
tool calls stays one row while it grows.
|
||||
4. **The soft keyboard**: the composer resizes with the IME, the guard
|
||||
against a stuck inset animation, drafts per session, autocorrect and
|
||||
suggestions from the phone's own keyboard. This is where most Rust
|
||||
frameworks fail on Android today; see below.
|
||||
5. **Platform integration through the app model**: foreground service,
|
||||
notifications, share sheet, deep link, Keystore, camera, back gesture,
|
||||
edge-to-edge insets, local-network permission.
|
||||
6. **Accessibility names on icon buttons**, which the bench scripts depend
|
||||
on (`ui-trace` taps by label). A framework with no accessibility tree
|
||||
also breaks the measuring rig.
|
||||
7. **Measurable frames**: the debug render report, and a way to attribute
|
||||
a frame's cost to a widget on the real phone.
|
||||
|
||||
## The two constraints that decide it
|
||||
|
||||
**1. Android text input.** Every framework built on `winit` inherits
|
||||
winit's Android backend, and that backend cannot drive the soft keyboard
|
||||
properly: the IME tracking issues
|
||||
([#1823](https://github.com/rust-windowing/winit/issues/1823),
|
||||
[#2766](https://github.com/rust-windowing/winit/issues/2766)) are open,
|
||||
`ReceivedCharacter` is unimplemented on Android
|
||||
([#2305](https://github.com/rust-windowing/winit/issues/2305)), and the
|
||||
`android-activity` groundwork for editor actions only merged in February
|
||||
2026 ([PR #214](https://github.com/rust-mobile/android-activity/pull/214))
|
||||
with the winit half still to come. Composition, autocorrect and suggestions
|
||||
need an `InputConnection` implemented on the Java side, which winit's
|
||||
`NativeActivity`/`GameActivity` model does not offer. The frameworks that
|
||||
type on Android today each wrote their own Java glue (Slint, Makepad), and
|
||||
the one designed to do it the way Android intends is
|
||||
[`android-view`](https://github.com/rust-mobile/android-view): a Rust
|
||||
implementation of an Android `View`, with text input through
|
||||
`InputConnection`, accessibility, touch, callbacks on the UI thread, usable
|
||||
either as a whole app or embedded beside ordinary Android components. It is
|
||||
marked WIP. Both Linebender (its Masonry demo lives in that repo) and
|
||||
Robius/Makepad ([Robrix's release notes](https://github.com/project-robius/robrix/releases)
|
||||
say Android lacks a "full" keyboard and they are integrating android-view
|
||||
for it) are converging on it. **That makes android-view the phone-side
|
||||
foundation whichever widget set sits on top**, and the first thing to
|
||||
build and measure here.
|
||||
|
||||
**2. Rich, selectable text and a virtualised list.** Frameworks group by
|
||||
their text stack:
|
||||
|
||||
- **Parley + Fontique + Vello** (Linebender): rich spans, selection and
|
||||
editing utilities, IME support driven through `ui-events`, AccessKit text
|
||||
properties ([Linebender 2026 Q1](https://linebender.org/blog/tmil-25/),
|
||||
[parley](https://github.com/linebender/parley)). Used by Masonry/Xilem,
|
||||
and by Blitz. Vello proper needs compute shaders; `vello_hybrid` (CPU
|
||||
path processing, GPU compositing) is "roughly beta" and runs on GLES too,
|
||||
and Vello CPU exists as a no-GPU fallback.
|
||||
- **cosmic-text** (iced, egui optionally): good layout, but the widgets on
|
||||
top decide selection. iced's `markdown` widget is not selectable
|
||||
([discourse](https://discourse.iced.rs/t/markdown-widgets-text-should-be-selectable/1107)).
|
||||
- **Slint's own**: `TextInput` with `read-only` is the selectable-text
|
||||
trick; there is **no inline rich text at all** (issue
|
||||
[#1325](https://github.com/slint-ui/slint/issues/1325), markdown request
|
||||
[#6684](https://github.com/slint-ui/slint/issues/6684) both open). A
|
||||
markdown transcript with links and code chips cannot be drawn.
|
||||
- **Makepad's own**: GPU/SDF text, a `Markdown` widget and a virtualised
|
||||
`PortalList` in `makepad-widgets`.
|
||||
|
||||
## Options
|
||||
|
||||
### A. Keep Compose, move the logic into a Rust core (uniffi)
|
||||
|
||||
A `client-core` crate (events shared with the server, API client, SSE,
|
||||
transcript fold, cache, markdown model, highlighter, ANSI) exposed to
|
||||
Kotlin through [uniffi](https://github.com/mozilla/uniffi-rs). Compose keeps
|
||||
drawing. Desktop would be a second UI (iced or Compose Desktop) over the
|
||||
same core.
|
||||
|
||||
- **For**: the logic and the wire types stop drifting from the server
|
||||
today, with tests in one language. Incremental and always shippable.
|
||||
- **Against**: it is not what was asked for. The 13,000 lines of UI stay
|
||||
Kotlin, the desktop app shares no UI code, and the `:link` Kotlin module
|
||||
stays. uniffi's Kotlin Multiplatform bindings are a
|
||||
[community fork](https://github.com/UbiqueInnovation/uniffi-kotlin-multiplatform-bindings);
|
||||
the Android-only bindings are Mozilla's and solid.
|
||||
- **Verdict**: not the destination, but **step one of every other option**
|
||||
is building this crate, so it costs nothing to keep it as the fallback.
|
||||
|
||||
### B. Slint
|
||||
|
||||
Rust on Android is officially supported (minSdk 26, `android-activity`
|
||||
backend, own Java IME glue, safe areas and keyboard insets since 1.15,
|
||||
Skia renderer needs `clang`). Royalty-free licence requires disclosing
|
||||
Slint use; GPLv3 otherwise. UI is a separate `.slint` DSL, not Rust.
|
||||
|
||||
- **Against**: no rich inline text (see above), so the transcript cannot be
|
||||
drawn as it is today; the UI language is not Rust, which forfeits the
|
||||
"compiler catches it" motivation for the half of the code that is UI.
|
||||
- **Verdict**: rejected on rich text alone.
|
||||
|
||||
### C. iced
|
||||
|
||||
Elm-style, Rust-only widgets, desktop-first, `winit` + `wgpu`. Has a
|
||||
`markdown` widget and `rich_text` with links. The maintainer states mobile
|
||||
is a non-goal ([iced](https://github.com/iced-rs/iced)); a community
|
||||
Android example exists and its author could not get the soft keyboard
|
||||
working, patched widgets for touch, and notes no accessibility
|
||||
([HN thread](https://news.ycombinator.com/item?id=46350641)). Markdown is
|
||||
not selectable; `scrollable` is not virtualised.
|
||||
|
||||
- **Verdict**: a fine desktop toolkit and the one Iris named, but every
|
||||
phone-side gap (IME, touch, accessibility, selection, virtualisation)
|
||||
would be ours to build and maintain against a project that does not want
|
||||
them. Not the shared framework.
|
||||
|
||||
### D. egui
|
||||
|
||||
Immediate mode, `winit`-based on Android, AccessKit integration,
|
||||
selectable labels across a `Ui`. Repaints only on input by default, so
|
||||
battery is not the immediate-mode worry. Android IME is blocked on winit
|
||||
([discussion](https://github.com/emilk/egui/discussions/2053)); the
|
||||
workaround is an in-app virtual keyboard, which is exactly the
|
||||
non-native keyboard to avoid. Variable-height virtualised lists are manual
|
||||
(`show_rows` assumes uniform heights). Looks like egui, not Material.
|
||||
|
||||
- **Verdict**: workable on desktop, wrong on the phone for the same reason
|
||||
as iced, plus a look that would need a full custom style.
|
||||
|
||||
### E. Makepad
|
||||
|
||||
GPU-rendered, hybrid retained/immediate, `live_design!` DSL with hot
|
||||
reload, MIT, 1.0 in 2025 ([makepad](https://github.com/makepad/makepad)).
|
||||
Ships Android apps today with its own Java glue; Robrix (a Matrix chat
|
||||
client, the closest analogue to this app) is its reference application on
|
||||
Android, iOS and desktop. Has `Markdown`, `PortalList` (virtualised),
|
||||
`TextInput`. Robrix reports the Android keyboard is not "full" and is moving
|
||||
to android-view for it; the README says non-standard targets "may require
|
||||
minor fixes".
|
||||
|
||||
- **For**: the only option that already ships a chat-shaped app on Android
|
||||
and desktop from one codebase, with the widgets this app needs.
|
||||
- **Against**: the DSL is its own language with its own shader-based
|
||||
styling, so a large part of the UI would not be checked by rustc; the
|
||||
rendering model (SDF everything) is a different world from Compose's,
|
||||
and selection across a `Markdown` widget is unverified.
|
||||
- **Verdict**: **rejected 2026-09-04** — Iris does not want a DSL. Kept
|
||||
here so its Android keyboard status stays a data point about
|
||||
android-view, not as an option.
|
||||
|
||||
### F. Masonry / Xilem on android-view (Linebender)
|
||||
|
||||
Retained widget tree (Masonry) with a reactive view layer (Xilem) that
|
||||
reads like Compose; Rust all the way down; Vello, Parley, Fontique,
|
||||
AccessKit, `ui-events`. Widgets include `Prose` (selectable read-only rich
|
||||
text), `TextArea`, `VirtualScroll`, and this year `Svg`, `Split`,
|
||||
`CollapsePanel`, a new layout system, and IME through `ui-events`
|
||||
independent of winit. `masonry_android_view` exists in the android-view
|
||||
repo and is "not yet generally usable"; Xilem calls itself experimental.
|
||||
Desktop runs on winit. Vello needs a compute-capable GPU or falls back to
|
||||
`vello_hybrid`/CPU.
|
||||
|
||||
- **For**: the only stack where every hard behaviour above maps onto a
|
||||
component designed for it: selection and rich text (Parley/Prose),
|
||||
virtualised variable heights (`VirtualScroll`), native IME
|
||||
(android-view's `InputConnection`), accessibility (AccessKit, now with an
|
||||
Android crate), one Rust widget language on both platforms. The team is
|
||||
the one writing the Android integration everyone else is adopting.
|
||||
- **Against**: pre-1.0 with API churn each release; a small team; no
|
||||
Material widget set, so every control's look is ours; some of the pieces
|
||||
(`masonry_android_view`, `vello_hybrid`) are explicitly unfinished. Being
|
||||
early means fixing things upstream ourselves, which Iris said is
|
||||
acceptable.
|
||||
- **Verdict**: **the option to try first**, because it is the only one
|
||||
whose gaps are "not finished yet" rather than "not designed for this".
|
||||
|
||||
### G. iris — the in-house library, and what "from scratch" means here
|
||||
|
||||
[cat16/iris](https://github.com/cat16/iris), read 2026-09-04 from the one
|
||||
public commit (2026-01-31, "portfolio copy"; ~8,700 lines in `core`,
|
||||
`macro` and the crate itself). Retained-mode widgets stored outside the
|
||||
render tree, `wgpu` 28 directly, `winit` 0.30, `cosmic-text` 0.16, a
|
||||
relative-anchor-plus-offset layout with `rest()` and `rel()` lengths, a
|
||||
postfix builder API (`rect(..).radius(30).on(CursorSense::click(), ..)
|
||||
.sized(..).align(..)`), events handled where the widget is declared, and
|
||||
a single-threaded context passed explicitly — all of which reads like this
|
||||
codebase's own rules. There is text editing (`widget/text/edit.rs`),
|
||||
images, masks, spans and stacks; the TODO names text resizing as
|
||||
per-frame slow and scaling as unsolved. It requires **nightly** (fourteen
|
||||
`#![feature]` gates, among them `const_trait_impl`, `unboxed_closures`,
|
||||
`portable_simd`, `associated_type_defaults`). Desktop only; no Android
|
||||
surface, no IME, no accessibility tree, no virtualised list, no rich-text
|
||||
selection.
|
||||
|
||||
**iris is not a candidate to be tested as it stands. It is the in-house
|
||||
library** (Iris, 2026-09-04): "essentially a good start to a rewrite from
|
||||
scratch", to be maintained and extended by the sessions working here.
|
||||
So the list above of what it lacks is a **work list, not a score**. When
|
||||
the app needs something iris does not have, the answer is to build it
|
||||
into iris. The layer iris has is the widget and layout layer; the layers
|
||||
it needs are the same ones Masonry gets from android-view, Parley and
|
||||
AccessKit, and there is no reason iris cannot sit on those same
|
||||
foundations rather than reinvent them — the surface, the keyboard bridge
|
||||
and the accessibility tree are platform plumbing, not a framework's
|
||||
identity. Whether the text stack stays cosmic-text or moves to Parley is
|
||||
the first real design decision in that work (see I1 below).
|
||||
|
||||
Two things to carry into that work honestly. Nightly is the opposite of
|
||||
"holds up long term": a build that breaks on a toolchain update, on the
|
||||
machine Dev Updater builds on, unattended. Pin a dated nightly in
|
||||
`rust-toolchain.toml` immediately, and keep a list of which `#![feature]`
|
||||
gates are load-bearing so they can be retired as they stabilise or are
|
||||
designed around. And a one-person framework carries every gap itself,
|
||||
which is what Iris said she is willing to do.
|
||||
|
||||
"From scratch" therefore means iris, not a fourth thing. Masonry stays in
|
||||
the plan as the **yardstick and the fallback**: building its demo and its
|
||||
version of the transcript screen first says what a finished stack costs
|
||||
on this hardware, proves android-view before iris depends on it, and
|
||||
gives a comparison that is measured rather than remembered.
|
||||
|
||||
Not considered further: **GPUI** (Zed) mobile is a community fork that
|
||||
depends on unpublished crates; **Dioxus/Blitz** is excluded by Iris (its
|
||||
native renderer is Parley/Vello under HTML semantics, and the earlier
|
||||
`tdep-survey/app-dioxus` spike parked it on a `vello_hybrid` stroke bug and
|
||||
shipped the WebView); **Compose Multiplatform Desktop** would give a desktop
|
||||
app for nothing but in Kotlin, which is the opposite direction.
|
||||
|
||||
### Weight and debug builds
|
||||
|
||||
Iris remembers the Linebender stack being slow in debug. What is behind
|
||||
that is the dependency graph — Vello, wgpu, Parley, Fontique, Skrifa —
|
||||
running unoptimised on the CPU side (path encoding, shaping), not the
|
||||
widget layer. Xilem's own advice is only `split-debuginfo = "unpacked"` to
|
||||
keep `target/` small; the fix everyone with this shape of dependency tree
|
||||
uses is to optimise dependencies while leaving the app crate at `opt-level
|
||||
= 0`:
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
E1 measures this rather than remembering it: cold and incremental build
|
||||
time, APK size, resident memory at rest and while streaming, and the
|
||||
frame cost of one 800-event page — for the Masonry demo as shipped, then
|
||||
with the profile above. Vello proper needs compute shaders and carries a
|
||||
large shader set; `vello_hybrid` is lighter and Masonry can now render
|
||||
through either (or Vello CPU) via its `imaging` abstraction, so "keep it
|
||||
light" has a knob inside the same stack.
|
||||
|
||||
## Recommendation
|
||||
|
||||
1. **Build `client-core` now, whatever the framework.** A Rust crate holding
|
||||
the event model (shared with `server/` as one crate, ending the
|
||||
`Events.kt` mirror), the API and SSE clients, the transcript fold, the
|
||||
cache, the markdown block model, the highlighter and the ANSI parser,
|
||||
with the existing JVM tests ported. It is the part of the app that is
|
||||
already tested, already logic, and already duplicated on the server.
|
||||
2. **One foundation, two widget layers.** The platform plumbing is shared
|
||||
whichever way the decision goes: android-view for the Android surface,
|
||||
keyboard and accessibility bridge; `wgpu` for the GPU; AccessKit for
|
||||
names; winit on the desktop. On top of it, **Masonry as the yardstick**
|
||||
(E1, E2) and **iris as the thing being built** (I0–I5), both aimed at
|
||||
the same transcript screen with the same pass conditions.
|
||||
3. **Decide when the transcript screen exists in both**, from the
|
||||
measurements, and record the decision here with the numbers. If iris
|
||||
carries the screen within the Compose baseline, it is the app's
|
||||
framework and Masonry was the calibration. If it does not, the
|
||||
measurement says which parts of Masonry to adopt underneath it.
|
||||
4. Then the shell (E3), the desktop window (E4) and the packaging (E5),
|
||||
which do not depend on the choice.
|
||||
|
||||
## Experiments, in order
|
||||
|
||||
Each has a pass condition that is a measurement in this clone. The rig
|
||||
matters: this emulator runs `-gpu host` with **host Vulkan switched off**
|
||||
(`GPU_HOST_FEATURES` in `emulator-tools`, a gfxstream/Venus gap), so inside
|
||||
the guest a `wgpu` app gets GLES, not Vulkan; the earlier Dioxus spike also
|
||||
needed `WGPU_GLES_MINOR_VERSION=1` for compute shaders and found `wgpu`'s
|
||||
Android backend wants API 26 (a libc symbol). The real phone has Vulkan.
|
||||
Per the standing rule, a rig limit is something to fix before it is
|
||||
accepted.
|
||||
|
||||
- [ ] **E0 — toolchain.** No NDK is installed (`~/Android/Sdk/ndk` is
|
||||
empty; the Android Rust targets are). Install the NDK under the
|
||||
user-owned SDK, `cargo-ndk`, and record the versions here.
|
||||
- [ ] **E1 — android-view's Masonry demo on this emulator.** Pass: it
|
||||
builds with `cargo-ndk` + Gradle, renders on the GPU, and the phone's
|
||||
own keyboard types into its editor with autocorrect and suggestions.
|
||||
Note which `wgpu` backend it took and any environment flags needed.
|
||||
- [ ] **E2 — a transcript in Masonry.** One screen: open a sandbox session,
|
||||
page 800 events into `VirtualScroll` bottom-anchored, draw markdown
|
||||
from `pulldown-cmark` into Parley rich text with links and code
|
||||
chips, select across two rows with the platform handles, expand a
|
||||
tool row holding its top edge. Pass: the render numbers land within
|
||||
the Compose baseline in `transcript-bench.sh` on the GPU emulator
|
||||
(same gestures, same session), and every one of the seven behaviours
|
||||
above is either shown or has a written reason it cannot be.
|
||||
- [ ] **E3 — the shell.** Kotlin `MainActivity` + `NotificationService`
|
||||
+ Keystore + share intent calling into Rust over JNI, with the SSE
|
||||
follow loop in Rust. Pass: a notification arrives with the app
|
||||
closed, and a share lands in a session.
|
||||
- [ ] **E4 — the same screen on the desktop** in a winit window, from the
|
||||
same crate, with only the layout differing.
|
||||
- [ ] **E5 — the packaging xtask**: `cargo ndk` → `javac`/`d8` → `aapt2`
|
||||
→ `zipalign` → `apksigner`, signed with the existing release key,
|
||||
installed through Dev Updater. Pass: the APK installs over the
|
||||
Gradle-built one and the notification service starts.
|
||||
|
||||
### The iris track
|
||||
|
||||
These build iris up to carry the app. Each is a feature added to iris
|
||||
with a pass condition, in dependency order. Work in `iris/` in this
|
||||
repository on the `rustify` branch, and record in this file what each
|
||||
step measured.
|
||||
|
||||
- [x] **I0a — where iris lives (decided 2026-09-04).** For now it is
|
||||
**vendored at `iris/` in this repository**, history not carried,
|
||||
and consumed by path. Iris's decision: keep it close while it is
|
||||
being reshaped for this app, and give it back its own repository —
|
||||
`iris/iris` on the gitea remote, which already holds the full
|
||||
244-commit history, on a branch of its own — once it has proved
|
||||
itself. The vendored tree is that repository's `main` at
|
||||
`7b54aaf` ("readme", 2026-01-29), byte-identical to the public
|
||||
GitHub copy, so a later reconciliation has a known base. A crate
|
||||
that uses it says `iris = { path = "../iris" }`.
|
||||
- [ ] **I0b — make it build here.** Pin a dated nightly in
|
||||
`iris/rust-toolchain.toml` (the machine has
|
||||
`nightly-x86_64-unknown-linux-gnu`, rustc 1.100.0-nightly
|
||||
2026-08-25); list every `#![feature]` gate with what it is for, so
|
||||
each can be retired when it stabilises. Get `cargo build`, `clippy`
|
||||
and `fmt` clean at the defaults and the `tabs` example running in a
|
||||
window (this VM has no display: a headless compositor is what
|
||||
`emu` uses for the emulator, and the same trick serves here).
|
||||
Pass: a fresh clone builds unattended from the pinned toolchain.
|
||||
**Measured 2026-09-04**: `cargo +nightly check` on that nightly
|
||||
fails in `iris-core` with 36 errors, all one cause — the
|
||||
`const_trait_impl` feature changed shape between January and
|
||||
August: `impl const Trait for T` is now rejected with "expected a
|
||||
trait, found type" unless the trait itself is declared
|
||||
`const trait`, at seven sites (`num.rs`, `align.rs`, `axis.rs`,
|
||||
`pos.rs`, `color.rs`, `math.rs`, `vec2.rs`), and the unresolved
|
||||
`UiVec2`/`Vec2`/`impl_op` imports cascade from those. That is the
|
||||
nightly-drift risk in one build; the fix is mechanical (declare
|
||||
the traits `const trait`), and it is the first item of I0b rather
|
||||
than pinning back to a January nightly, which would only defer it.
|
||||
- [ ] **I1 — the text stack decision.** iris uses cosmic-text; the
|
||||
transcript needs rich inline spans (links, code chips, colour),
|
||||
selection across many widgets with the platform's handles on the
|
||||
phone, and an editor the IME can drive (composition regions, not
|
||||
just committed characters). Compare cosmic-text and Parley against
|
||||
exactly those three, in iris, on a real transcript's text. Parley
|
||||
is the expectation because android-view's IME bridge and AccessKit's
|
||||
text properties are written against it; measure rather than assume.
|
||||
Pass: a written decision here with what each was tried on, and the
|
||||
TODO's "text resizing per frame is really slow" measured and either
|
||||
fixed or explained.
|
||||
- [ ] **I2 — iris on android-view.** An `android-view` surface as a second
|
||||
backend beside winit: `wgpu` on the view's surface (GLES here, see
|
||||
the Vulkan section; Vulkan on the phone), touch as pointer events,
|
||||
window insets and the keyboard inset as layout inputs, the back
|
||||
gesture as an event, the IME bridge feeding the editor from I1.
|
||||
Pass: the `tabs` example and a text field run on the emulator, and
|
||||
the phone's own keyboard types into the field with autocorrect and
|
||||
suggestions — the same bar as E1.
|
||||
- [ ] **I3 — a virtualised, bottom-anchored list.** Variable-height rows,
|
||||
keyed, composed only while visible, paged in both directions with a
|
||||
"more" sentinel at each end, a scroll anchor that survives rows
|
||||
being inserted above, and "hold the edge nearest the tap" done in
|
||||
the layout pass. Pass: 800 rows of real transcript text from the
|
||||
sandbox scroll without a frame over the Compose baseline in
|
||||
`transcript-bench.sh`, measured on the GPU emulator.
|
||||
- [ ] **I4 — accessibility names via AccessKit.** Every control carries a
|
||||
name; `ui-trace` can find and tap it by label. Pass: `bench-lib.sh`'s
|
||||
tap-by-name works against the iris screen unchanged.
|
||||
- [ ] **I5 — the transcript screen in iris.** E2's pass conditions, all
|
||||
seven behaviours, against the sandbox with `--delay`. This is the
|
||||
point the decision in the recommendation is made at.
|
||||
|
||||
## For the next agent
|
||||
|
||||
What to do when you pick this up, in order, so nothing here has to be
|
||||
re-derived:
|
||||
|
||||
1. Read this file, then `AGENTS.md` and `PLAN.md`. The rules there
|
||||
(measure, do not read; fix the rig before accepting its limits; the
|
||||
emulator is this checkout's own) all apply.
|
||||
2. Work on the **`rustify`** branch of this clone (`ai-app-2`), not on
|
||||
`main` and not in `ai-app`. Nothing on this branch is production until
|
||||
Iris says so. Commit and push as you go.
|
||||
3. Take the next unchecked box above, in order: E0 first, then E1, then
|
||||
the iris track from I0. E-steps and I-steps can proceed in parallel in
|
||||
separate sessions once E1 has proved android-view on this emulator.
|
||||
4. Every step ends with its measurement written into this file beside the
|
||||
box, and the box ticked or the reason it could not be written in its
|
||||
place. A step that is blocked says by what, not "later".
|
||||
5. Run the existing rigs rather than inventing new ones: `ui-sandbox.sh`
|
||||
for a server with fixtures, `transcript-bench.sh` for the scroll
|
||||
baseline, `ui-trace` for anything positional, `emu up` for the
|
||||
emulator. The Vulkan section below says how to get a Vulkan path in the
|
||||
emulator when a `wgpu` backend needs one.
|
||||
6. Decisions belong here with a date and what was rejected, the way
|
||||
`PLAN.md` does it. Do not put design into commit messages alone.
|
||||
|
||||
### Vulkan in the emulator (measured 2026-09-04)
|
||||
|
||||
A `wgpu` app in this emulator was going to get GLES only, because host
|
||||
Vulkan is switched off in `emulator-tools`. Retried today on Mesa
|
||||
26.1.7: **Venus still fails the same way** — gfxstream picks
|
||||
`externalMemoryMode: OpaqueFd`, probes `VK_FORMAT_R8G8B8A8_UNORM` for an
|
||||
exportable colour buffer, and Venus says the format is unsupported
|
||||
(`Failed to find memory type for ColorBuffers`, fatal before adb sees the
|
||||
device). Venus does advertise `VK_KHR_external_memory_fd` and
|
||||
`VK_EXT_external_memory_dma_buf`, so the gap is specifically opaque-fd
|
||||
image export. gfxstream has a string-valued `VulkanExternalMemoryMode`
|
||||
setting ("overrides what would otherwise be determined automatically"),
|
||||
but `-feature Name=Value` is rejected as a bad feature name, and the only
|
||||
mode words compiled into this emulator's `libgfxstream_backend.so`
|
||||
(37.1.11) are `OpaqueFd`, `Metal` and `none` — there is no dma-buf mode
|
||||
in this build to switch to. So Venus is blocked by the emulator, not by
|
||||
Mesa; retry when the emulator package updates, since upstream gfxstream
|
||||
does have dma-buf external memory.
|
||||
|
||||
What **does** work: pointing the emulator's Vulkan loader at the software
|
||||
ICDs the emulator ships itself, with the feature enabled:
|
||||
|
||||
VK_DRIVER_FILES=$HOME/Android/Sdk/emulator/lib64/vulkan/vk_swiftshader_icd.json \
|
||||
GPU_HOST_FEATURES="-feature Vulkan" emu up
|
||||
|
||||
The guest then reports Vulkan 1.3 (`cmd gpu vkjson`, SwiftShader
|
||||
Subzero) while GLES still runs on the real GPU through virgl — so a
|
||||
Vello/wgpu app can take its real Vulkan path here, with compute shaders,
|
||||
CPU-rasterised. That is enough to test *correctness* of the Vulkan path
|
||||
in the emulator; GPU *performance* of it is a phone measurement either
|
||||
way, exactly as `MACHINE.md` already says about frame times. **lavapipe**
|
||||
(`lvp_icd.json`, the other ICD the emulator ships) selected llvmpipe and
|
||||
booted, then the emulator died right after loading the `default_boot`
|
||||
snapshot with nothing in the log; a snapshot saved under a different
|
||||
Vulkan device is the suspect, and `-no-snapshot-load` is the untested
|
||||
next step. SwiftShader is the one that works today.
|
||||
Making this an `emu` option belongs in `emulator-tools` and is a shared
|
||||
tooling change, so it goes through the other sessions first.
|
||||
|
||||
## Things a Rust app changes elsewhere
|
||||
|
||||
- **`wg-app-link`'s `:link`** (pinned TLS, enrollment store, QR activity)
|
||||
is Kotlin shared with Dev Updater. The certificate code already exists on
|
||||
the Rust side of the submodule; the pinned-CA build step
|
||||
(`generatePinnedCert`) becomes a `build.rs` reading the same path. The QR
|
||||
scanner stays a Kotlin activity, since the camera is a platform feature.
|
||||
- **Tooling** becomes `cargo` for everything but packaging: `cargo test`,
|
||||
`clippy`, `fmt` cover the whole client, which is the motivation. Gradle
|
||||
remains for the APK, signing (`~/.config/ai-app/release.jks`) and Dev
|
||||
Updater's build modes; `build-apk.sh` would call `cargo ndk` first.
|
||||
- **The bench scripts** (`ui-trace` by accessibility label) keep working
|
||||
only if the framework exposes names through AccessKit on Android; that is
|
||||
part of E2's pass condition, not a nicety.
|
||||
- **Icons** stay Nerd Font glyphs from the committed subset; Parley/Fontique
|
||||
loads a font file directly, so `build-icon-font.sh` is unchanged.
|
||||
|
||||
## Sources
|
||||
|
||||
- iced: [repo](https://github.com/iced-rs/iced), [0.14 release](https://github.com/iced-rs/iced/releases/tag/0.14.0), [Android thread](https://news.ycombinator.com/item?id=46350641), [markdown selection request](https://discourse.iced.rs/t/markdown-widgets-text-should-be-selectable/1107)
|
||||
- Linebender: [2026 Q1 report](https://linebender.org/blog/tmil-25/), [xilem](https://github.com/linebender/xilem), [parley](https://github.com/linebender/parley), [vello](https://github.com/linebender/vello), [vello_hybrid](https://docs.rs/vello_hybrid/latest/vello_hybrid/)
|
||||
- android-view: [repo](https://github.com/rust-mobile/android-view); android-activity [PR #214](https://github.com/rust-mobile/android-activity/pull/214)
|
||||
- winit Android IME: [#1823](https://github.com/rust-windowing/winit/issues/1823), [#2766](https://github.com/rust-windowing/winit/issues/2766), [#2305](https://github.com/rust-windowing/winit/issues/2305)
|
||||
- egui on Android: [discussion #2053](https://github.com/emilk/egui/discussions/2053)
|
||||
- Slint: [Android guide](https://docs.slint.dev/latest/docs/slint/guide/platforms/mobile/android/), [1.15 release](https://slint.dev/blog/slint-1.15-released), [licensing](https://slint.dev/faqs), rich text [#1325](https://github.com/slint-ui/slint/issues/1325), markdown [#6684](https://github.com/slint-ui/slint/issues/6684)
|
||||
- Makepad: [repo](https://github.com/makepad/makepad), [makepad-widgets](https://docs.rs/makepad-widgets), [Robrix](https://github.com/project-robius/robrix), [Robrix releases](https://github.com/project-robius/robrix/releases)
|
||||
- AccessKit: [releases](https://github.com/AccessKit/accesskit/releases)
|
||||
- Build tools: [cargo-ndk](https://github.com/bbqsrc/cargo-ndk), [cargo-apk](https://github.com/rust-mobile/cargo-apk), [rust-mobile](https://github.com/rust-mobile)
|
||||
- uniffi: [repo](https://github.com/mozilla/uniffi-rs), [KMP bindings fork](https://github.com/UbiqueInnovation/uniffi-kotlin-multiplatform-bindings)
|
||||
- GPUI mobile: [gpui-mobile](https://github.com/itsbalamurali/gpui-mobile)
|
||||
- The earlier Dioxus spike's findings on `wgpu`/Vulkan in this emulator: `~/repos/tdep-survey/app-dioxus/README.md`
|
||||
+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",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/target
|
||||
perf.data*
|
||||
Generated
-3635
File diff suppressed because it is too large.
Load diff
@@ -1,42 +0,0 @@
|
||||
[package]
|
||||
name = "iris"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
iris-core = { workspace = true }
|
||||
iris-macro = { workspace = true }
|
||||
cosmic-text = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
winit = { workspace = true }
|
||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||
pollster = { workspace = true }
|
||||
wgpu = { workspace = true }
|
||||
image = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
|
||||
|
||||
[workspace]
|
||||
members = ["core", "macro"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace.dependencies]
|
||||
pollster = "0.4.0"
|
||||
winit = "0.30.12"
|
||||
wgpu = "28.0.0"
|
||||
bytemuck = "1.23.1"
|
||||
image = "0.25.6"
|
||||
cosmic-text = "0.16.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
fxhash = "0.2.1"
|
||||
arboard = "3.6.1"
|
||||
iris-core = { path = "core" }
|
||||
iris-macro = { path = "macro" }
|
||||
tokio = "1.49.0"
|
||||
@@ -1,40 +0,0 @@
|
||||
images
|
||||
settings (sampler)
|
||||
|
||||
text
|
||||
figure out ways to speed up / what costs the most
|
||||
resizing (per frame) is really slow (assuming painter isn't griefing)
|
||||
j is weird / fix x offset
|
||||
|
||||
masks r just made to bare minimum work
|
||||
|
||||
scaling
|
||||
could be just a simple scaling factor that multiplies abs
|
||||
and need to ensure text uses raw abs and not scaled abs
|
||||
naming? (pt, px)
|
||||
want to keep (drawn) regions using px? or should I add another field to UiScalar/Vec
|
||||
field could be best solution so redrawing stuff isn't needed & you can specify both as user
|
||||
|
||||
WidgetRef<W> or smth instead of Id
|
||||
enum that's either an Id or an actual concrete instance of W
|
||||
painter takes them in instead of (or in addition to) id
|
||||
then type wrapper widgets to contain them
|
||||
allows for compile time optimization if a widget wrapper's inner is known at compile time
|
||||
and the id of inner is not needed anywhere
|
||||
maybe introduce InnerWidget trait to allow for editors to expose & modify inner type
|
||||
maybe could also store a parent widget and keep using InnerWidget trait? unsure if possible
|
||||
|
||||
really weird limitation:
|
||||
I don't think you can currently remove an element from a parent and put it in a child of the same parent
|
||||
because it removes the unused children after the entire parent redraw
|
||||
but the child gets drawn during that, so it will think the child is still active !!!
|
||||
or something like that idk, maybe I need a special enum for parent that includes a undecided state where it may or may not get redrawn by the parent
|
||||
or just do ref counting and ensure all drawn things == 1 afterwards (seems like best way)
|
||||
ok so I'm removing the limit for now
|
||||
|
||||
don't forget I'm streaming
|
||||
|
||||
tags
|
||||
vecs for each widget type?
|
||||
|
||||
POTENTIAL BUG: closures that store IDs will not decrement the id!!! need to not increment id if moved into closure somehow??? wait no, need to decrement ID every time an event fn is added...... only if the id is used in it..??
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "iris-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
winit = { workspace = true }
|
||||
wgpu = { workspace = true }
|
||||
bytemuck ={ workspace = true }
|
||||
image = { workspace = true }
|
||||
cosmic-text = { workspace = true }
|
||||
fxhash = { workspace = true }
|
||||
@@ -1,23 +0,0 @@
|
||||
use crate::{UiRsc, WidgetIdFn, WidgetLike, WeakWidget};
|
||||
|
||||
pub trait WidgetAttr<Rsc, W: ?Sized> {
|
||||
type Input;
|
||||
fn run(rsc: &mut Rsc, id: WeakWidget<W>, input: Self::Input);
|
||||
}
|
||||
|
||||
pub trait Attrable<Rsc, W: ?Sized, Tag> {
|
||||
fn attr<A: WidgetAttr<Rsc, W>>(self, input: A::Input) -> impl WidgetIdFn<Rsc, W>;
|
||||
}
|
||||
|
||||
impl<Rsc: UiRsc, WL: WidgetLike<Rsc, Tag>, Tag> Attrable<Rsc, WL::Widget, Tag> for WL {
|
||||
fn attr<A: WidgetAttr<Rsc, WL::Widget>>(
|
||||
self,
|
||||
input: A::Input,
|
||||
) -> impl WidgetIdFn<Rsc, WL::Widget> {
|
||||
|rsc| {
|
||||
let id = self.add(rsc);
|
||||
A::run(rsc, id, input);
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
use crate::{HasEvents, WeakWidget, Widget};
|
||||
|
||||
pub struct EventCtx<'a, Rsc: HasEvents, Data> {
|
||||
pub state: &'a mut Rsc::State,
|
||||
pub data: Data,
|
||||
}
|
||||
|
||||
pub struct EventIdCtx<'a, Rsc: HasEvents, Data, W: ?Sized> {
|
||||
pub widget: WeakWidget<W>,
|
||||
pub state: &'a mut Rsc::State,
|
||||
pub data: Data,
|
||||
}
|
||||
|
||||
impl<Rsc: HasEvents, Data, W: Widget> EventIdCtx<'_, Rsc, Data, W> {
|
||||
pub fn widget<'a>(&self, rsc: &'a mut Rsc) -> &'a mut W {
|
||||
&mut rsc.ui_mut().widgets[self.widget]
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
use crate::{
|
||||
ActiveData, Event, EventCtx, EventFn, EventIdCtx, EventLike, HasEvents, IdLike, LayerId,
|
||||
WeakWidget, WidgetEventFn, WidgetId,
|
||||
util::{HashMap, HashSet, TypeMap},
|
||||
};
|
||||
use std::{any::TypeId, rc::Rc};
|
||||
|
||||
pub struct EventManager<Rsc> {
|
||||
widget_to_types: HashMap<WidgetId, HashSet<TypeId>>,
|
||||
types: TypeMap<dyn EventManagerLike<Rsc>>,
|
||||
}
|
||||
|
||||
impl<Rsc> Default for EventManager<Rsc> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
widget_to_types: Default::default(),
|
||||
types: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Rsc: HasEvents + 'static> EventManager<Rsc> {
|
||||
pub fn get_type<E: EventLike>(&mut self) -> &mut TypeEventManager<Rsc, E::Event> {
|
||||
self.types.type_or_default()
|
||||
}
|
||||
|
||||
pub fn register<I: IdLike + 'static, E: EventLike>(
|
||||
&mut self,
|
||||
id: I,
|
||||
event: E,
|
||||
f: impl for<'a> WidgetEventFn<Rsc, <E::Event as Event>::Data<'a>, I::Widget>,
|
||||
) {
|
||||
let i = id.id();
|
||||
self.get_type::<E>().register(id, event, f);
|
||||
self.widget_to_types
|
||||
.entry(i)
|
||||
.or_default()
|
||||
.insert(Self::type_key::<E>());
|
||||
}
|
||||
|
||||
pub fn type_key<E: EventLike>() -> TypeId {
|
||||
TypeId::of::<TypeEventManager<Rsc, E::Event>>()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait EventsLike {
|
||||
fn remove(&mut self, id: WidgetId);
|
||||
fn draw(&mut self, active: &ActiveData);
|
||||
fn undraw(&mut self, active: &ActiveData);
|
||||
}
|
||||
|
||||
impl<Rsc: HasEvents + 'static> EventsLike for EventManager<Rsc> {
|
||||
fn remove(&mut self, id: WidgetId) {
|
||||
for t in self.widget_to_types.get(&id).into_flat_iter() {
|
||||
self.types.get_mut(t).unwrap().remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(&mut self, active: &ActiveData) {
|
||||
for t in self.widget_to_types.get(&active.id).into_flat_iter() {
|
||||
self.types.get_mut(t).unwrap().draw(active);
|
||||
}
|
||||
}
|
||||
|
||||
fn undraw(&mut self, active: &ActiveData) {
|
||||
for t in self.widget_to_types.get(&active.id).into_flat_iter() {
|
||||
self.types.get_mut(t).unwrap().undraw(active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait EventManagerLike<State> {
|
||||
fn remove(&mut self, id: WidgetId);
|
||||
fn draw(&mut self, data: &ActiveData);
|
||||
fn undraw(&mut self, data: &ActiveData);
|
||||
}
|
||||
|
||||
type EventData<Rsc, E> = (E, Rc<dyn for<'a> EventFn<Rsc, <E as Event>::Data<'a>>>);
|
||||
pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
|
||||
// TODO: reduce visiblity!!
|
||||
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
|
||||
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
|
||||
}
|
||||
|
||||
impl<Rsc: HasEvents, E: Event> EventManagerLike<Rsc> for TypeEventManager<Rsc, E> {
|
||||
fn remove(&mut self, id: WidgetId) {
|
||||
self.map.remove(&id);
|
||||
for layer in self.active.values_mut() {
|
||||
layer.remove(&id);
|
||||
}
|
||||
}
|
||||
fn draw(&mut self, data: &ActiveData) {
|
||||
self.active
|
||||
.entry(data.layer)
|
||||
.or_default()
|
||||
.entry(data.id)
|
||||
.or_default();
|
||||
}
|
||||
fn undraw(&mut self, data: &ActiveData) {
|
||||
if let Some(layer) = self.active.get_mut(&data.layer) {
|
||||
layer.remove(&data.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Rsc: HasEvents, E: Event> Default for TypeEventManager<Rsc, E> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: Default::default(),
|
||||
map: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Rsc: HasEvents + 'static, E: Event> TypeEventManager<Rsc, E> {
|
||||
fn register<I: IdLike + 'static>(
|
||||
&mut self,
|
||||
widget: I,
|
||||
event: impl EventLike<Event = E>,
|
||||
f: impl for<'a> WidgetEventFn<Rsc, E::Data<'a>, I::Widget>,
|
||||
) {
|
||||
let event = event.into_event();
|
||||
self.map.entry(widget.id()).or_default().push((
|
||||
event,
|
||||
Rc::new(move |ctx, rsc| {
|
||||
f(
|
||||
EventIdCtx {
|
||||
widget: WeakWidget::new(widget.id()),
|
||||
state: ctx.state,
|
||||
data: ctx.data,
|
||||
},
|
||||
rsc,
|
||||
);
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn run_fn<'a>(
|
||||
&mut self,
|
||||
id: impl IdLike,
|
||||
) -> impl for<'b> FnOnce(EventCtx<'_, Rsc, E::Data<'b>>, &mut Rsc) + 'a {
|
||||
let fs = self.map.get(&id.id()).cloned().unwrap_or_default();
|
||||
move |ctx, rsc| {
|
||||
for (e, f) in fs {
|
||||
if let Some(data) = e.should_run(&ctx.data) {
|
||||
f(
|
||||
EventCtx {
|
||||
state: ctx.state,
|
||||
data,
|
||||
},
|
||||
rsc,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
mod ctx;
|
||||
mod manager;
|
||||
mod rsc;
|
||||
|
||||
pub use ctx::*;
|
||||
pub use manager::*;
|
||||
pub use rsc::*;
|
||||
|
||||
pub trait Event: Sized + 'static + Clone {
|
||||
type Data<'a>: Clone = ();
|
||||
type State: Default = ();
|
||||
#[allow(unused_variables)]
|
||||
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
||||
Some(data.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait EventLike {
|
||||
type Event: Event;
|
||||
fn into_event(self) -> Self::Event;
|
||||
}
|
||||
|
||||
impl<E: Event> EventLike for E {
|
||||
type Event = Self;
|
||||
|
||||
fn into_event(self) -> Self::Event {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub trait EventFn<Rsc: HasEvents, Data>: Fn(EventCtx<Rsc, Data>, &mut Rsc) + 'static {}
|
||||
impl<Rsc: HasEvents, F: Fn(EventCtx<Rsc, Data>, &mut Rsc) + 'static, Data> EventFn<Rsc, Data>
|
||||
for F
|
||||
{
|
||||
}
|
||||
|
||||
pub trait WidgetEventFn<Rsc: HasEvents, Data, W: ?Sized>:
|
||||
Fn(EventIdCtx<Rsc, Data, W>, &mut Rsc) + 'static
|
||||
{
|
||||
}
|
||||
impl<Rsc: HasEvents, F: Fn(EventIdCtx<Rsc, Data, W>, &mut Rsc) + 'static, Data, W: ?Sized>
|
||||
WidgetEventFn<Rsc, Data, W> for F
|
||||
{
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
use crate::{
|
||||
Event, EventCtx, EventLike, EventManager, IdLike, UiRsc, WeakWidget, Widget, WidgetEventFn,
|
||||
};
|
||||
|
||||
pub trait HasState: 'static {
|
||||
type State;
|
||||
}
|
||||
|
||||
pub trait HasEvents: Sized + UiRsc + HasState {
|
||||
fn events(&self) -> &EventManager<Self>;
|
||||
fn events_mut(&mut self) -> &mut EventManager<Self>;
|
||||
|
||||
fn register_event<W: Widget + ?Sized, E: EventLike>(
|
||||
&mut self,
|
||||
id: WeakWidget<W>,
|
||||
event: E,
|
||||
f: impl for<'a> WidgetEventFn<Self, <E::Event as Event>::Data<'a>, W>,
|
||||
) {
|
||||
self.events_mut().register(id, event, f);
|
||||
}
|
||||
}
|
||||
|
||||
pub trait RunEvents: HasEvents {
|
||||
fn run_event<E: EventLike>(
|
||||
&mut self,
|
||||
id: impl IdLike,
|
||||
data: <E::Event as Event>::Data<'_>,
|
||||
state: &mut Self::State,
|
||||
) {
|
||||
let f = self.events_mut().get_type::<E>().run_fn(id);
|
||||
f(EventCtx { state, data }, self)
|
||||
}
|
||||
}
|
||||
impl<T: HasEvents> RunEvents for T {}
|
||||
@@ -1,36 +0,0 @@
|
||||
#![feature(macro_metavar_expr_concat)]
|
||||
#![feature(const_ops)]
|
||||
#![feature(const_trait_impl)]
|
||||
#![feature(const_convert)]
|
||||
#![feature(map_try_insert)]
|
||||
#![feature(unboxed_closures)]
|
||||
#![feature(fn_traits)]
|
||||
#![feature(const_cmp)]
|
||||
#![feature(const_destruct)]
|
||||
#![feature(portable_simd)]
|
||||
#![feature(associated_type_defaults)]
|
||||
#![feature(unsize)]
|
||||
#![feature(coerce_unsized)]
|
||||
#![feature(option_into_flat_iter)]
|
||||
|
||||
mod attr;
|
||||
mod event;
|
||||
mod num;
|
||||
mod orientation;
|
||||
mod primitive;
|
||||
mod render;
|
||||
mod ui;
|
||||
mod widget;
|
||||
|
||||
pub mod util;
|
||||
|
||||
pub use attr::*;
|
||||
pub use event::*;
|
||||
pub use num::*;
|
||||
pub use orientation::*;
|
||||
pub use primitive::*;
|
||||
pub use render::*;
|
||||
pub use ui::*;
|
||||
pub use widget::*;
|
||||
|
||||
pub type UiColor = primitive::Color<u8>;
|
||||
@@ -1,49 +0,0 @@
|
||||
use crate::util::Vec2;
|
||||
use std::marker::Destruct;
|
||||
|
||||
pub const trait UiNum {
|
||||
fn to_f32(self) -> f32;
|
||||
}
|
||||
|
||||
impl const UiNum for f32 {
|
||||
fn to_f32(self) -> f32 {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl const UiNum for u32 {
|
||||
fn to_f32(self) -> f32 {
|
||||
self as f32
|
||||
}
|
||||
}
|
||||
|
||||
impl const UiNum for i32 {
|
||||
fn to_f32(self) -> f32 {
|
||||
self as f32
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn vec2(x: impl const UiNum, y: impl const UiNum) -> Vec2 {
|
||||
Vec2::new(x.to_f32(), y.to_f32())
|
||||
}
|
||||
|
||||
impl<T: const UiNum + Copy> const From<T> for Vec2 {
|
||||
fn from(v: T) -> Self {
|
||||
Self {
|
||||
x: v.to_f32(),
|
||||
y: v.to_f32(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: const UiNum, U: const UiNum> const From<(T, U)> for Vec2
|
||||
where
|
||||
(T, U): const Destruct,
|
||||
{
|
||||
fn from((x, y): (T, U)) -> Self {
|
||||
Self {
|
||||
x: x.to_f32(),
|
||||
y: y.to_f32(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
use crate::vec2;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Align {
|
||||
pub x: Option<AxisAlign>,
|
||||
pub y: Option<AxisAlign>,
|
||||
}
|
||||
|
||||
impl Align {
|
||||
pub const TOP_LEFT: RegionAlign = RegionAlign::TOP_LEFT;
|
||||
pub const TOP_CENTER: RegionAlign = RegionAlign::TOP_CENTER;
|
||||
pub const TOP_RIGHT: RegionAlign = RegionAlign::TOP_RIGHT;
|
||||
pub const CENTER_LEFT: RegionAlign = RegionAlign::CENTER_LEFT;
|
||||
pub const CENTER: RegionAlign = RegionAlign::CENTER;
|
||||
pub const CENTER_RIGHT: RegionAlign = RegionAlign::CENTER_RIGHT;
|
||||
pub const BOT_LEFT: RegionAlign = RegionAlign::BOT_LEFT;
|
||||
pub const BOT_CENTER: RegionAlign = RegionAlign::BOT_CENTER;
|
||||
pub const BOT_RIGHT: RegionAlign = RegionAlign::BOT_RIGHT;
|
||||
pub const LEFT: CardinalAlign = CardinalAlign::LEFT;
|
||||
pub const H_CENTER: CardinalAlign = CardinalAlign::H_CENTER;
|
||||
pub const RIGHT: CardinalAlign = CardinalAlign::RIGHT;
|
||||
pub const TOP: CardinalAlign = CardinalAlign::TOP;
|
||||
pub const V_CENTER: CardinalAlign = CardinalAlign::V_CENTER;
|
||||
pub const BOT: CardinalAlign = CardinalAlign::BOT;
|
||||
|
||||
pub fn tuple(&self) -> (Option<AxisAlign>, Option<AxisAlign>) {
|
||||
(self.x, self.y)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AxisAlign {
|
||||
Neg,
|
||||
Center,
|
||||
Pos,
|
||||
}
|
||||
|
||||
impl AxisAlign {
|
||||
pub const fn rel(&self) -> f32 {
|
||||
match self {
|
||||
Self::Neg => 0.0,
|
||||
Self::Center => 0.5,
|
||||
Self::Pos => 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CardinalAlign {
|
||||
axis: Axis,
|
||||
align: AxisAlign,
|
||||
}
|
||||
|
||||
impl CardinalAlign {
|
||||
pub const LEFT: Self = Self::new(Axis::X, AxisAlign::Neg);
|
||||
pub const H_CENTER: Self = Self::new(Axis::X, AxisAlign::Center);
|
||||
pub const RIGHT: Self = Self::new(Axis::X, AxisAlign::Pos);
|
||||
pub const TOP: Self = Self::new(Axis::Y, AxisAlign::Neg);
|
||||
pub const V_CENTER: Self = Self::new(Axis::Y, AxisAlign::Center);
|
||||
pub const BOT: Self = Self::new(Axis::Y, AxisAlign::Pos);
|
||||
|
||||
pub const fn new(axis: Axis, align: AxisAlign) -> Self {
|
||||
Self { axis, align }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RegionAlign {
|
||||
pub x: AxisAlign,
|
||||
pub y: AxisAlign,
|
||||
}
|
||||
|
||||
impl RegionAlign {
|
||||
pub const TOP_LEFT: Self = Self::new(AxisAlign::Neg, AxisAlign::Neg);
|
||||
pub const TOP_CENTER: Self = Self::new(AxisAlign::Center, AxisAlign::Neg);
|
||||
pub const TOP_RIGHT: Self = Self::new(AxisAlign::Pos, AxisAlign::Neg);
|
||||
pub const CENTER_LEFT: Self = Self::new(AxisAlign::Neg, AxisAlign::Center);
|
||||
pub const CENTER: Self = Self::new(AxisAlign::Center, AxisAlign::Center);
|
||||
pub const CENTER_RIGHT: Self = Self::new(AxisAlign::Pos, AxisAlign::Center);
|
||||
pub const BOT_LEFT: Self = Self::new(AxisAlign::Neg, AxisAlign::Pos);
|
||||
pub const BOT_CENTER: Self = Self::new(AxisAlign::Center, AxisAlign::Pos);
|
||||
pub const BOT_RIGHT: Self = Self::new(AxisAlign::Pos, AxisAlign::Pos);
|
||||
|
||||
pub const fn new(x: AxisAlign, y: AxisAlign) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
pub const fn rel(&self) -> Vec2 {
|
||||
vec2(self.x.rel(), self.y.rel())
|
||||
}
|
||||
}
|
||||
|
||||
impl UiVec2 {
|
||||
pub fn partial_align(&self, align: Align) -> UiRegion {
|
||||
UiRegion {
|
||||
x: if let Some(align) = align.x {
|
||||
self.x.align(align)
|
||||
} else {
|
||||
UiSpan::FULL
|
||||
},
|
||||
y: if let Some(align) = align.y {
|
||||
self.y.align(align)
|
||||
} else {
|
||||
UiSpan::FULL
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn align(&self, align: RegionAlign) -> UiRegion {
|
||||
UiRegion {
|
||||
x: self.x.align(align.x),
|
||||
y: self.y.align(align.y),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Vec2 {
|
||||
pub fn partial_align(&self, align: Align) -> UiRegion {
|
||||
let s = UiVec2::from(*self);
|
||||
UiRegion {
|
||||
x: if let Some(align) = align.x {
|
||||
s.x.align(align)
|
||||
} else {
|
||||
UiSpan::FULL
|
||||
},
|
||||
y: if let Some(align) = align.y {
|
||||
s.y.align(align)
|
||||
} else {
|
||||
UiSpan::FULL
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn align(&self, align: RegionAlign) -> UiRegion {
|
||||
let s = UiVec2::from(*self);
|
||||
UiRegion {
|
||||
x: s.x.align(align.x),
|
||||
y: s.y.align(align.y),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiScalar {
|
||||
pub const fn align(&self, align: AxisAlign) -> UiSpan {
|
||||
let rel = align.rel();
|
||||
let mut start = UiScalar::rel(rel);
|
||||
start.abs -= self.abs * rel;
|
||||
start.rel -= self.rel * rel;
|
||||
let mut end = UiScalar::rel(rel);
|
||||
end.abs += self.abs * (1.0 - rel);
|
||||
end.rel += self.rel * (1.0 - rel);
|
||||
UiSpan { start, end }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RegionAlign> for Align {
|
||||
fn from(region: RegionAlign) -> Self {
|
||||
Self {
|
||||
x: Some(region.x),
|
||||
y: Some(region.y),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Align> for RegionAlign {
|
||||
fn from(align: Align) -> Self {
|
||||
Self {
|
||||
x: align.x.unwrap_or(AxisAlign::Center),
|
||||
y: align.y.unwrap_or(AxisAlign::Center),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CardinalAlign> for RegionAlign {
|
||||
fn from(align: CardinalAlign) -> Self {
|
||||
Align::from(align).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CardinalAlign> for Align {
|
||||
fn from(cardinal: CardinalAlign) -> Self {
|
||||
let align = Some(cardinal.align);
|
||||
match cardinal.axis {
|
||||
Axis::X => Self { x: align, y: None },
|
||||
Axis::Y => Self { x: None, y: align },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl const From<RegionAlign> for UiVec2 {
|
||||
fn from(align: RegionAlign) -> Self {
|
||||
Self::rel(align.rel())
|
||||
}
|
||||
}
|
||||
|
||||
impl RegionAlign {
|
||||
pub const fn pos(self) -> UiVec2 {
|
||||
UiVec2::from(self)
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||
pub enum Axis {
|
||||
X,
|
||||
Y,
|
||||
}
|
||||
|
||||
impl std::ops::Not for Axis {
|
||||
type Output = Self;
|
||||
|
||||
fn not(self) -> Self::Output {
|
||||
match self {
|
||||
Self::X => Self::Y,
|
||||
Self::Y => Self::X,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
pub struct Dir {
|
||||
pub axis: Axis,
|
||||
pub sign: Sign,
|
||||
}
|
||||
|
||||
impl Dir {
|
||||
pub const fn new(axis: Axis, dir: Sign) -> Self {
|
||||
Self { axis, sign: dir }
|
||||
}
|
||||
|
||||
pub const LEFT: Self = Self::new(Axis::X, Sign::Neg);
|
||||
pub const RIGHT: Self = Self::new(Axis::X, Sign::Pos);
|
||||
pub const UP: Self = Self::new(Axis::Y, Sign::Neg);
|
||||
pub const DOWN: Self = Self::new(Axis::Y, Sign::Pos);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
pub enum Sign {
|
||||
Neg,
|
||||
Pos,
|
||||
}
|
||||
|
||||
impl Vec2 {
|
||||
pub fn axis(&self, axis: Axis) -> f32 {
|
||||
match axis {
|
||||
Axis::X => self.x,
|
||||
Axis::Y => self.y,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn axis_mut(&mut self, axis: Axis) -> &mut f32 {
|
||||
match axis {
|
||||
Axis::X => &mut self.x,
|
||||
Axis::Y => &mut self.y,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn from_axis(axis: Axis, aligned: f32, ortho: f32) -> Self {
|
||||
Self {
|
||||
x: match axis {
|
||||
Axis::X => aligned,
|
||||
Axis::Y => ortho,
|
||||
},
|
||||
y: match axis {
|
||||
Axis::Y => aligned,
|
||||
Axis::X => ortho,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const trait AxisT {
|
||||
fn get() -> Axis;
|
||||
}
|
||||
|
||||
pub struct XAxis;
|
||||
impl const AxisT for XAxis {
|
||||
fn get() -> Axis {
|
||||
Axis::X
|
||||
}
|
||||
}
|
||||
|
||||
pub struct YAxis;
|
||||
impl const AxisT for YAxis {
|
||||
fn get() -> Axis {
|
||||
Axis::Y
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct BothAxis<T> {
|
||||
pub x: T,
|
||||
pub y: T,
|
||||
}
|
||||
|
||||
impl<T> BothAxis<T> {
|
||||
pub const fn axis<A: const AxisT>(&mut self) -> &mut T {
|
||||
match A::get() {
|
||||
Axis::X => &mut self.x,
|
||||
Axis::Y => &mut self.y,
|
||||
}
|
||||
}
|
||||
pub fn take_axis<A: const AxisT>(self) -> T {
|
||||
match A::get() {
|
||||
Axis::X => self.x,
|
||||
Axis::Y => self.y,
|
||||
}
|
||||
}
|
||||
pub fn axis_dyn(&mut self, axis: Axis) -> &mut T {
|
||||
match axis {
|
||||
Axis::X => &mut self.x,
|
||||
Axis::Y => &mut self.y,
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 210 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user