Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.claude/skills/ai-app-rigs
|
||||||
@@ -72,13 +72,19 @@ Each exists because something was invisible without it.
|
|||||||
`usage::Fixture`'s, since those are its states. With none set an echo
|
`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.
|
session meters nothing, which is the ordinary case and draws no bar.
|
||||||
- **A fake CLI exercises the process lifecycle without a token.** Point a
|
- **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
|
`claude_cli` provider's `command` at a script that ordinarily runs
|
||||||
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
|
`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
|
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
|
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
|
`--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
|
this when what is under test is *whether a process is running*, and for
|
||||||
`debug-transcript.sh` when it is *what the transcript draws*.
|
`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
|
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
|
||||||
the first session (or `-k` keeps the current screen), scrolls a fixed
|
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
|
gesture loop, and prints the app's render report — the same one the in-app
|
||||||
@@ -141,28 +147,55 @@ moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
|
|||||||
|
|
||||||
### Testing llama.cpp and ssh here
|
### Testing llama.cpp and ssh here
|
||||||
|
|
||||||
**Both are set up here as of 2026-09-04** and need nothing typed. The
|
**Both are set up here** and need nothing typed. The prebuilt llama.cpp lives
|
||||||
prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp`
|
outside the repo at `~/.local/opt/llama.cpp-vk` — a **Vulkan** build as of
|
||||||
(the 15 MB `ubuntu-x64` release asset) and is symlinked as
|
2026-09-19, replacing the CPU one that was there before — and is symlinked as
|
||||||
`/usr/local/bin/llama-server`, which is what makes **discovery find it over
|
both `~/.local/bin/llama-server` and `/usr/local/bin/llama-server`. The second
|
||||||
ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets.
|
is what makes **discovery find it over ssh**: `~/.local/bin` is not on the
|
||||||
It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is
|
PATH a non-interactive ssh session gets. It resolves its own libraries through
|
||||||
needed. One model is downloaded — `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`,
|
`$ORIGIN`, so no `LD_LIBRARY_PATH` is needed.
|
||||||
639 MB under `~/.local/share/ai-app/models` — and answers at usable speed on
|
|
||||||
this VM's 8 cores. **Do not test with a 2-bit quant**: the
|
Two models are downloaded under `~/.local/share/ai-app/models`:
|
||||||
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
|
- `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`, 639 MB, loads in ~4s. It
|
||||||
is how to tell the two apart in a hurry.
|
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.
|
||||||
|
|
||||||
|
**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
|
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
|
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
|
`~/.ssh/authorized_keys`, labelled removable), and the real config carries a
|
||||||
setup called **"this vm over ssh"** — `bob@127.0.0.1` with that
|
machine called **"this vm over ssh"** — `bob@127.0.0.1` with that
|
||||||
`identityFile` plus
|
`identityFile` plus
|
||||||
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
|
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
|
||||||
so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the
|
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
|
whole rig for "does a remote llama session work", since the far machine is
|
||||||
this one and the model file is the same file. For a throwaway setup of your
|
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`
|
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
|
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
|
exiting immediately is the signal, and it costs no tokens. The remote login
|
||||||
@@ -200,7 +233,7 @@ never be able to close the app, whatever produced it.
|
|||||||
**Deleting a session offers to take the machine's own transcript with it** —
|
**Deleting a session offers to take the machine's own transcript with it** —
|
||||||
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
|
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
|
||||||
confirmation, and only where the driver keeps a record of its own
|
confirmation, and only where the driver keeps a record of its own
|
||||||
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
|
(`keepsOwnTranscript`, currently Claude Code or Codex). Off by default,
|
||||||
because leaving that copy is what makes an ordinary delete recoverable — and
|
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,
|
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
|
since the sentence promising the conversation "should still be there to
|
||||||
@@ -210,6 +243,57 @@ where it was instead of half-deleted.
|
|||||||
|
|
||||||
## Measurements worth not re-taking
|
## 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
|
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
|
||||||
emulator against a real imported transcript with the server at
|
emulator against a real imported transcript with the server at
|
||||||
`--delay 120`. Settled and flinging fast, both into fresh history and back
|
`--delay 120`. Settled and flinging fast, both into fresh history and back
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ai-app
|
# 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,
|
replacing the Claude app for daily use. Rust/Axum backend on the desktop,
|
||||||
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
||||||
between them.
|
between them.
|
||||||
@@ -34,7 +34,25 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
|||||||
|
|
||||||
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
||||||
comment is the HTTP table and the surface's source of truth.
|
comment is the HTTP table and the surface's source of truth.
|
||||||
**A llama.cpp session runs on whatever machine its setup names** (built
|
**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 llama.cpp session runs on its configured machine** (built
|
||||||
2026-09-04, the last of phase 5): `Transport::reserve_port` returns the
|
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
|
port the server binds *there* and the port that reaches it *here*, and
|
||||||
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
|
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
|
||||||
@@ -43,14 +61,93 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
|||||||
because `llama-server` never reads the stdin whose closing ends a CLI and
|
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 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
|
the machine that will serve it, so the spawn screen offers
|
||||||
`GET /setups/{id}/models` rather than the backend's own downloads; and the
|
`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
|
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
|
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".
|
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'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 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".
|
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
||||||
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
`AppRoot.kt` is the navigation `when`; `SidePanels.kt` the one drag that
|
||||||
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
slides the whole main screen over a session from the left (`MainPanel.kt`)
|
||||||
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
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.
|
the Keystore-sealed token.
|
||||||
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
||||||
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
||||||
@@ -60,7 +157,7 @@ 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
|
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
|
projects version-locked to the commit this repo pins. What deliberately did
|
||||||
**not** move is the API surface and the config *schema*: routes, drivers,
|
**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
|
- `SUBAGENTS.md` — a session's subagents as transcripts of their own
|
||||||
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
|
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
|
||||||
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
|
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
|
||||||
@@ -171,7 +268,7 @@ means here:
|
|||||||
|
|
||||||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
- **`ai-server` belongs on the host in production.** That is where the LAN
|
||||||
address the phone can reach is, and where WireGuard terminates.
|
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>`.
|
there with `sudo WG_ENDPOINT=<ddns name>`.
|
||||||
- **The tunnel and the real phone can never terminate in the VM**, because
|
- **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.
|
nothing outside can open a connection into it. Phone bring-up is host work.
|
||||||
@@ -204,6 +301,11 @@ day to day:
|
|||||||
keep what a development server spawns. The flag decides only what **new**
|
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
|
sessions are marked as; what happens on the way out is decided by the
|
||||||
**mark**.
|
**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
|
- 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
|
`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
|
in `process.json`; removing either by hand while the session is live loses
|
||||||
@@ -228,6 +330,65 @@ PLAN.md's "Auto-resume" is the design; day to day:
|
|||||||
backend restart. A day after the limit was hit it gives up and says so in
|
backend restart. A day after the limit was hit it gives up and says so in
|
||||||
the transcript.
|
the transcript.
|
||||||
|
|
||||||
|
## A session waiting on its own work
|
||||||
|
|
||||||
|
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. 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
|
## Shared appearance
|
||||||
|
|
||||||
- **A row something is happening to is dimmed, drained of colour, and says
|
- **A row something is happening to is dimmed, drained of colour, and says
|
||||||
@@ -252,8 +413,64 @@ PLAN.md's "Auto-resume" is the design; day to day:
|
|||||||
|
|
||||||
## Things that have bitten
|
## Things that have bitten
|
||||||
|
|
||||||
Project-specific only — a lesson that would bite any project on this machine
|
- **A server started with no `--tools` answers 403 at `GET /tools`, not an
|
||||||
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
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.
|
||||||
|
|
||||||
|
- **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 caches callsite interest process-wide.** A test that hits a
|
||||||
`tracing::warn!` with no subscriber installed can poison the interest cache
|
`tracing::warn!` with no subscriber installed can poison the interest cache
|
||||||
@@ -347,6 +564,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
|
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
|
for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what
|
||||||
is actually on screen.
|
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.**
|
- **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
|
`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,
|
a growing list — fine at 80 events and about 300,000 element copies at 800,
|
||||||
@@ -354,4 +584,12 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
|||||||
shape: the `markdownIn` scan that decides *what* to parse ran before the
|
shape: the `markdownIn` scan that decides *what* to parse ran before the
|
||||||
hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
|
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.
|
that wraps the *fetch* and leaves the work done with the result outside it.
|
||||||
|
- **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.
|
||||||
+36
-19
@@ -14,7 +14,7 @@ AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` /
|
|||||||
## What it is, in one paragraph
|
## What it is, in one paragraph
|
||||||
|
|
||||||
A machine's filesystem, seen from the phone through the backend. The explorer
|
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
|
where to start. Every operation — list, read, write, create — is one shell
|
||||||
script run through `Transport`, exactly the way the import listing and the
|
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
|
usage fetch already work, so the local and the ssh case are one
|
||||||
@@ -25,16 +25,16 @@ message. The phone draws what came back.
|
|||||||
|
|
||||||
### 1. Keyed on the machine, opened from the session
|
### 1. Keyed on the machine, opened from the session
|
||||||
|
|
||||||
Routes live under `/setups/{id}/…`, beside `importable`, because a 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
|
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
|
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
|
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.
|
is one more caller and no new code.
|
||||||
|
|
||||||
Rejected: routes under `/sessions/{id}/`. The session would be a detour to
|
Rejected: routes under `/sessions/{id}/`. The session would be a detour to
|
||||||
find the setup, and "browse this machine" from anywhere else would need a
|
find the machine, and "browse this machine" from anywhere else would need a
|
||||||
session to exist first.
|
session to exist first.
|
||||||
|
|
||||||
### 2. One shell script per operation, over `Transport`, on both transports
|
### 2. One shell script per operation, over `Transport`, on both transports
|
||||||
@@ -68,7 +68,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
|
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
|
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
|
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
|
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.
|
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
|
The import rule stands where it is, because there a path was unnecessary and
|
||||||
@@ -82,13 +82,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
|
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
|
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
|
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
|
navigates on a resolved absolute path. The phone also resolves `~` through the
|
||||||
that, and a `~` the session was spawned with is shown as what it turned out
|
same route, then shortens that directory and every path beneath it back to
|
||||||
to be. The phone never resolves `..` itself.
|
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
|
### 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
|
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
|
`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
|
||||||
at), or the machine's own error.
|
at), or the machine's own error.
|
||||||
@@ -101,7 +102,7 @@ what it is.
|
|||||||
|
|
||||||
### 6. A write is conditional on what the reader saw
|
### 6. A write is conditional on what the reader saw
|
||||||
|
|
||||||
`PUT /setups/{id}/file` carries the sha256 the read reported. The script
|
`PUT /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;
|
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 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
|
the common case, not the exotic one, and silently overwriting an agent's edit
|
||||||
@@ -122,9 +123,9 @@ precondition is fresh without a second read.
|
|||||||
|
|
||||||
### 7. Create refuses to overwrite
|
### 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
|
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
|
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
|
for "directory"; a created file opens straight into edit mode, because an
|
||||||
empty file is not something to look at.
|
empty file is not something to look at.
|
||||||
@@ -208,17 +209,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
|
be lost. The explorer draws over the session, which deliberately has no
|
||||||
`imePadding`, so the explorer's own box adds it.
|
`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
|
`Screen.Session` in `AppRoot` gains a `files: FilesTarget?`. When set, the
|
||||||
`FilesScreen` is composed **on top of** the session in the same `Box`, and
|
`FilesScreen` is composed **on top of** the session in the same `Box`, and
|
||||||
the session stays composed under it: its event stream keeps flowing, its
|
the session stays composed under it: its event stream keeps flowing, its
|
||||||
scroll position and draft stay where they were, and returning from a file
|
scroll position and draft stay where they were, and returning from a file
|
||||||
costs nothing. Back — the button and the platform gesture — clears `files`
|
costs nothing. From an open file, both the header's back button and Android back
|
||||||
when set and goes to the list otherwise. Inside the explorer the same back
|
return to its containing directory. From a directory, the header's back button
|
||||||
steps one level: editor → viewer (with the unsaved question) → listing →
|
clears `files` and returns to the session. Android back instead walks toward the
|
||||||
parent directory, and only from the starting directory does it close. "Back
|
session's project directory: upward to the common ancestor, then down one path
|
||||||
returns; it does not exit."
|
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
|
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
|
leaf screen goes to Main today, and a session disposed and re-created on each
|
||||||
@@ -267,6 +272,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**
|
already are. **Moving it is where the no-coordinate-taps rule got enforced**
|
||||||
(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
|
(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
|
## HTTP surface
|
||||||
|
|
||||||
In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields`
|
In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields`
|
||||||
|
|||||||
+195
-47
@@ -1,68 +1,159 @@
|
|||||||
# Subagents
|
# Subagents
|
||||||
|
|
||||||
A session's subagents -- the helpers a Claude Code session starts through its
|
A session's subagents -- helpers started by Claude Code's Task tool or Codex's
|
||||||
Task tool -- each get a transcript of their own, listed under the session's
|
collaboration tools -- each get a transcript of their own, listed in a panel
|
||||||
card and readable in the same transcript view the session has. Designed
|
over the open session and readable in the same transcript view the session has.
|
||||||
2026-09-05; the decisions Bryan has not yet reviewed are in `DECISIONS.md`.
|
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
|
## What a subagent is here
|
||||||
|
|
||||||
**A subagent is a second transcript owned by a session, in the same event
|
**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
|
model, with no process and no controls.** It is not a session: it cannot be
|
||||||
messaged, stopped or started, and it has no setup, model or usage of its
|
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
|
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
|
paging routes, the SSE stream, the phone's cache and rendering -- is reused
|
||||||
by addressing, not by copying.
|
by addressing, not by copying.
|
||||||
|
|
||||||
The CLI reports a subagent's messages on the parent's own stream-json
|
Claude reports a subagent's messages on the parent's own stream-json output,
|
||||||
output, each carrying `parent_tool_use_id` = the id of the Task `tool_use`
|
each carrying `parent_tool_use_id` = the id of the Task `tool_use` that started
|
||||||
that started it. Before this the translator dropped those lines
|
it. Before this the translator dropped those lines
|
||||||
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
|
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
|
||||||
them to that subagent's own translator and transcript. The parent's
|
them to that subagent's own translator and transcript. The parent's
|
||||||
transcript still shows only the Task call itself.
|
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
|
## Storage
|
||||||
|
|
||||||
Under the session directory:
|
Under the session directory:
|
||||||
|
|
||||||
```
|
```
|
||||||
<session>/subagents/<tool_use_id>/meta.json {title, created}
|
<session>/subagents/<subagent_id>/meta.json {title, created}
|
||||||
<session>/subagents/<tool_use_id>/transcript.jsonl same SeqEvent lines as the session's
|
<session>/subagents/<subagent_id>/transcript.jsonl same SeqEvent lines as the session's
|
||||||
```
|
```
|
||||||
|
|
||||||
The id is the Task tool_use id (`toolu_…`), which is unique, stable across a
|
The id is Claude's Task tool_use id (`toolu_…`) or Codex's child thread id.
|
||||||
backend restart, and already the key everything on the parent side uses.
|
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
|
Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
|
||||||
id becomes a path.
|
id becomes a path.
|
||||||
|
|
||||||
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
|
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
|
||||||
`read_window`, `catch_up` and `read_after` work on it unchanged.
|
`read_window`, `catch_up` and `read_after` work on it unchanged.
|
||||||
|
|
||||||
Its path out: deleting the session deletes its directory, subagents included.
|
Its path out: deleting the session deletes its directory, subagents included,
|
||||||
There is no separate delete.
|
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
|
## Lifecycle, as events in the subagent's transcript
|
||||||
|
|
||||||
1. Created on the first child line for an unseen parent id (or, when the
|
1. Created when the parent Task/Agent call is seen. A current Claude CLI's
|
||||||
parent Task call was seen, at that call). First lines written:
|
`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
|
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when
|
||||||
the prompt is known -- it genuinely is the subagent's first user turn.
|
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`
|
2. Every child line is translated by that subagent's own `Translator`
|
||||||
(one per subagent: tool ids are unique but streaming deltas are by
|
(one per subagent: tool ids are unique but streaming deltas are by
|
||||||
content-block index, and parallel subagents interleave).
|
content-block index, and parallel subagents interleave).
|
||||||
3. **The parent's `tool_result` never finishes a subagent.** The Task tool
|
3. **What ends a subagent is the CLI's own task lifecycle**, on top-level
|
||||||
runs in the background by default: the `tool_result` -- "Async agent
|
`system` lines that carry no `parent_tool_use_id`: `task_started`
|
||||||
launched..." -- arrives the moment it *starts*, while the subagent goes
|
(`task_id`, `tool_use_id`, `task_type`, `is_backgrounded`, the prompt),
|
||||||
on working for however long its own turn takes, sometimes minutes. What
|
`task_progress` repeatedly, then `task_updated` (`patch.status`, naming the
|
||||||
ends it is its own turn ending: the raw API's `message_delta` on its
|
*task* only) and `task_notification` (`tool_use_id`, `status`, and `summary`
|
||||||
stream carrying `stop_reason: "end_turn"` (a `stop_reason` of `tool_use`
|
-- the agent's own report). `translate_task` keeps the
|
||||||
is the model about to call one, not an end), or a `result` line for its
|
`task_id -> tool_use_id` mapping from the first so the update can be
|
||||||
own turn if a future CLI version ever sends one. Either maps to
|
attributed, records the summary as the subagent's closing text, and writes
|
||||||
`Status Exited`; the subagent's vocabulary has no `Idle`, so the
|
`Status Exited`. A
|
||||||
equivalent event `dispatch` produces for an ordinary session is dropped
|
`completed` update is deliberately not the end: its notification carries
|
||||||
rather than written. A shipped version of this finished on the
|
the summary and would otherwise land after the ending. Any other terminal
|
||||||
`tool_result` instead, which read a running background agent as
|
status ends it from the update, since the failure to avoid is a subagent
|
||||||
"finished" with its transcript truncated at the moment it launched.
|
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**
|
4. **A child line for a subagent that already finished reopens it**
|
||||||
(`Status Running`) rather than being dropped: a background Task can be
|
(`Status Running`) rather than being dropped: a background Task can be
|
||||||
sent another message long after its first turn ended, and that is
|
sent another message long after its first turn ended, and that is
|
||||||
@@ -70,7 +161,27 @@ There is no separate delete.
|
|||||||
`Translator`, just picking back up.
|
`Translator`, just picking back up.
|
||||||
5. When the parent session's process exits (`Status Exited` on the
|
5. When the parent session's process exits (`Status Exited` on the
|
||||||
session), every subagent still `Running` gets `Status Exited` too: its
|
session), every subagent still `Running` gets `Status Exited` too: its
|
||||||
process was the parent's.
|
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:
|
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 registry reopens the existing transcript on the next child line, and
|
||||||
@@ -80,27 +191,34 @@ the backend was down nothing recorded that until the next line arrives, so
|
|||||||
its last status stays `Running`, which the list reports as **unknown**
|
its last status stays `Running`, which the list reports as **unknown**
|
||||||
rather than as running (see the wire shape) until then.
|
rather than as running (see the wire shape) until then.
|
||||||
|
|
||||||
Title: the Task call's `description` input, then ` (<subagent_type>)` when
|
Title: for Claude, the Task call's `description` input, then
|
||||||
one is given; falling back to the tool's name when the child arrives before
|
` (<subagent_type>)` when one is given; falling back to `Task` when the
|
||||||
(or without) the parent call being seen.
|
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
|
## Server layout
|
||||||
|
|
||||||
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
|
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
|
||||||
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
|
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
|
||||||
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
|
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
|
||||||
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk. Drivers get an
|
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.
|
`Arc<Subagents>` beside their `EventSink`; llama ignores it.
|
||||||
- `session/claude/translate.rs` -- routes child lines by parent id, holds
|
- `session/claude/translate.rs` -- routes child lines by parent id, holds
|
||||||
one child `Translator` per subagent, remembers pending Task calls'
|
one child `Translator` per subagent, remembers pending Task calls'
|
||||||
description/prompt/subagent_type.
|
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)
|
- `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
|
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
|
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
|
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
|
end when their subagent does. Three seconds so the running state can be
|
||||||
seen on the phone.
|
seen on the phone.
|
||||||
- `routes.rs` -- three routes, in the doc table.
|
- `routes.rs` -- four routes, in the doc table.
|
||||||
|
|
||||||
## Wire shape
|
## Wire shape
|
||||||
|
|
||||||
@@ -110,11 +228,23 @@ GET /sessions same field on each row
|
|||||||
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
|
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}/transcript exactly the session transcript's query and answer
|
||||||
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
|
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
|
`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`, `exited`), except that a subagent whose session is not itself
|
||||||
running cannot be running: the list answers `unknown` for that one. The
|
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.
|
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 count on `SessionInfo` is a directory listing, so the list stays cheap.
|
||||||
@@ -122,19 +252,37 @@ The per-subagent status is only read when the list route is asked for.
|
|||||||
|
|
||||||
## Phone
|
## Phone
|
||||||
|
|
||||||
- `SessionSummary.subagents: Int`. A card with a non-zero count ends in an
|
- The subcards are ordered **still running first, then most recently
|
||||||
expander row -- a full-width `Chevron(Pointing.Down)` row that flips to
|
active** -- a display decision made on the phone (`subagentOrder`), over the
|
||||||
`Pointing.Up` -- collapsed by default. Expanding fetches
|
server's stable oldest-first answer. Two keys rather than activity alone
|
||||||
`/sessions/{id}/subagents` and draws one `OutlinedCard` per subagent,
|
because a subagent that is thinking reports nothing meanwhile and would sink
|
||||||
indented inside the session card, the way dev-updater draws a project's
|
below one that just finished.
|
||||||
components: title, then the status word and a relative time. The
|
- **Holding a subcard selects it, and several at a time**, exactly as the
|
||||||
expansion state is per session id and survives a refresh of the list.
|
import list works, with the selection bar drawn inside the panel rather than
|
||||||
- Tapping a subcard opens `Screen.Subagent`, which is `SessionScreen` in
|
at the bottom of the session: this selection belongs to the subagent list,
|
||||||
**read-only** form: the same transcript, paging, cache, selection,
|
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
|
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.
|
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
|
The header shows the subagent's title with the session's title beneath it.
|
||||||
it. Back returns to the list.
|
It is another layer over the still-composed session and its panel; Back
|
||||||
|
returns to the panel.
|
||||||
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
|
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
|
||||||
cache take a transcript address rather than a session id --
|
cache take a transcript address rather than a session id --
|
||||||
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
|
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ one in place when it turns out to need a decision.
|
|||||||
|
|
||||||
## App — transcript
|
## 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
|
- [ ] Messages received from other agents are inconsistent — sometimes they
|
||||||
appear, sometimes they don't. **Needs a rig.** Read the code rather than
|
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
|
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
|
that would work today, for Claude sessions, and it is the option that was
|
||||||
not chosen.
|
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
@@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
@@ -21,6 +22,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.wgapplink.localNetworkAllowed
|
import com.example.wgapplink.localNetworkAllowed
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -31,41 +33,39 @@ import kotlinx.coroutines.withContext
|
|||||||
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
|
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
|
||||||
* and the back button the only other way between them.
|
* and the back button the only other way between them.
|
||||||
*
|
*
|
||||||
* Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
|
* Import, models and machines are tabs inside [MainScreen] -- four views of the same backend, none
|
||||||
* them a step down from another -- and what is left here is only what genuinely is a step down: one
|
* of them a step down from another -- and what is left here is only what genuinely is a step down:
|
||||||
* session, spawning one, and settings.
|
* one session, spawning one, and settings.
|
||||||
*/
|
*/
|
||||||
private sealed class Screen {
|
private sealed class Screen {
|
||||||
/**
|
data object Main : Screen()
|
||||||
* The session list, with a subagent's own transcript over it when [subagent] is set.
|
|
||||||
*
|
|
||||||
* A layer on this screen rather than a screen of its own, for the same reason [Session.files]
|
|
||||||
* is: [SessionListScreen] owns which cards are expanded and what each expansion fetched, kept
|
|
||||||
* in `remember`, and a subagent is opened from a card's expander. As a sibling `Screen` it was
|
|
||||||
* disposed and recreated on every return, which lost that state -- an expanded card collapsed
|
|
||||||
* itself the moment its own subagent's view was closed.
|
|
||||||
*/
|
|
||||||
data class Main(val subagent: SubagentTarget? = null) : Screen()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
|
* One session, with the file explorer or a subagent transcript over it when set.
|
||||||
* SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
|
|
||||||
* is opened from the session list's card rather than from inside the session it belongs to.
|
|
||||||
*/
|
|
||||||
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One session, with the file explorer over it when [files] is set.
|
|
||||||
*
|
*
|
||||||
* The explorer is a layer on this screen rather than a screen of its own, so the session under
|
* Both are layers on this screen rather than screens of their own, so the session under them
|
||||||
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
|
* stays composed: its event stream keeps flowing, its scroll position and draft stay put, and
|
||||||
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re-
|
* coming back costs nothing. As sibling `Screen`s they would dispose and recreate it on every
|
||||||
* created on every return, refetching the transcript over the tunnel.
|
* 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()
|
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()
|
data object Settings : Screen()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ fun AppRoot(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
||||||
var screen by remember { mutableStateOf<Screen>(Screen.Main()) }
|
var screen by remember { mutableStateOf<Screen>(Screen.Main) }
|
||||||
// A notification tap this could not follow, and why. Null both before one is asked for and
|
// A notification tap this could not follow, and why. Null both before one is asked for and
|
||||||
// after one succeeds, since success is a screen rather than a message.
|
// after one succeeds, since success is a screen rather than a message.
|
||||||
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
||||||
@@ -114,7 +114,7 @@ fun AppRoot(
|
|||||||
share = shareRequest
|
share = shareRequest
|
||||||
// A session already open takes it. Otherwise the list is where the choice is made,
|
// A session already open takes it. Otherwise the list is where the choice is made,
|
||||||
// whatever screen was showing: Spawn and Settings have nowhere to put a file.
|
// whatever screen was showing: Spawn and Settings have nowhere to put a file.
|
||||||
if (screen !is Screen.Session) screen = Screen.Main()
|
if (screen !is Screen.Session) screen = Screen.Main
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ fun AppRoot(
|
|||||||
existing = null,
|
existing = null,
|
||||||
onSaved = { saved ->
|
onSaved = { saved ->
|
||||||
settings = saved
|
settings = saved
|
||||||
screen = Screen.Main()
|
screen = Screen.Main
|
||||||
},
|
},
|
||||||
onBack = null,
|
onBack = null,
|
||||||
)
|
)
|
||||||
@@ -154,7 +154,7 @@ fun AppRoot(
|
|||||||
// shows, so it always refetches.
|
// shows, so it always refetches.
|
||||||
val goToMain = {
|
val goToMain = {
|
||||||
reloadToken++
|
reloadToken++
|
||||||
screen = Screen.Main()
|
screen = Screen.Main
|
||||||
}
|
}
|
||||||
if (screen !is Screen.Main) {
|
if (screen !is Screen.Main) {
|
||||||
BackHandler(onBack = goToMain)
|
BackHandler(onBack = goToMain)
|
||||||
@@ -196,44 +196,23 @@ fun AppRoot(
|
|||||||
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is
|
// 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.
|
// the cost that made it lag, so it moves only its composer and transcript.
|
||||||
when (val here = screen) {
|
when (val here = screen) {
|
||||||
is Screen.Main ->
|
Screen.Main ->
|
||||||
Box(Modifier.imePadding()) {
|
Box(Modifier.imePadding()) {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
settings = current,
|
settings = current,
|
||||||
reloadToken = reloadToken,
|
reloadToken = reloadToken,
|
||||||
share = share,
|
share = share,
|
||||||
onOpen = { screen = Screen.Session(it) },
|
onOpen = { screen = Screen.Session(it) },
|
||||||
onOpenSubagent = { summary, subagent ->
|
|
||||||
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
|
|
||||||
},
|
|
||||||
onSpawn = { screen = Screen.Spawn },
|
onSpawn = { screen = Screen.Spawn },
|
||||||
onImported = { imported ->
|
onImported = { imported ->
|
||||||
reloadToken++
|
reloadToken++
|
||||||
screen = Screen.Session(imported)
|
screen = Screen.Session(imported)
|
||||||
},
|
},
|
||||||
onSettings = { screen = Screen.Settings },
|
onSettings = { screen = Screen.Settings },
|
||||||
|
onProvider = { machineId, provider ->
|
||||||
|
screen = Screen.ProviderSettings(machineId, provider)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
// Its own back handler is registered after MainScreen's, so it is the one the
|
|
||||||
// platform asks first while a subagent is open -- the same rule the files
|
|
||||||
// explorer's handler follows over its session, below.
|
|
||||||
here.subagent?.let { target ->
|
|
||||||
BackHandler { screen = here.copy(subagent = null) }
|
|
||||||
// Its own opaque background: this screen was always the sole content under
|
|
||||||
// the theme's own Surface before, so it never had to paint one -- stacked over
|
|
||||||
// the list here, the space between its own cards let the list underneath show
|
|
||||||
// through without this. The same fix FilesScreen needed over its session.
|
|
||||||
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
|
||||||
key(target.summary.id, target.subagent.id) {
|
|
||||||
SessionScreen(
|
|
||||||
settings = current,
|
|
||||||
summary = target.summary,
|
|
||||||
onBack = { screen = here.copy(subagent = null) },
|
|
||||||
onFiles = {},
|
|
||||||
subagent = target.subagent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
is Screen.Session ->
|
is Screen.Session ->
|
||||||
// Keyed on the id, because a different session is a different screen rather than this
|
// Keyed on the id, because a different session is a different screen rather than this
|
||||||
@@ -246,6 +225,66 @@ fun AppRoot(
|
|||||||
// A Box so the explorer can be drawn *over* the session rather than instead of it.
|
// 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.
|
// No imePadding here, for the reason above -- the explorer adds its own.
|
||||||
Box {
|
Box {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// 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,
|
||||||
|
onClose = close,
|
||||||
|
onOpenSubagent = { screen = here.copy(subagent = it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalFileLinkHandler provides fileLinkHandler
|
||||||
|
) {
|
||||||
SessionScreen(
|
SessionScreen(
|
||||||
settings = current,
|
settings = current,
|
||||||
summary = here.summary,
|
summary = here.summary,
|
||||||
@@ -253,7 +292,27 @@ fun AppRoot(
|
|||||||
onFiles = { screen = here.copy(files = it) },
|
onFiles = { screen = here.copy(files = it) },
|
||||||
share = share,
|
share = share,
|
||||||
onShareTaken = { share = null },
|
onShareTaken = { share = null },
|
||||||
|
onBackgroundTasks = { backgroundTasks = it },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
// 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.
|
// platform asks first, and it steps back inside itself before closing.
|
||||||
here.files?.let { target ->
|
here.files?.let { target ->
|
||||||
@@ -265,6 +324,15 @@ fun AppRoot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is Screen.ProviderSettings ->
|
||||||
|
Box(Modifier.imePadding()) {
|
||||||
|
ProviderScreen(
|
||||||
|
settings = current,
|
||||||
|
machineId = here.machineId,
|
||||||
|
provider = here.provider,
|
||||||
|
onBack = goToMain,
|
||||||
|
)
|
||||||
|
}
|
||||||
is Screen.Spawn ->
|
is Screen.Spawn ->
|
||||||
Box(Modifier.imePadding()) {
|
Box(Modifier.imePadding()) {
|
||||||
SpawnScreen(
|
SpawnScreen(
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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.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.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* Nothing is drawn at all when the count is zero -- including when the provider never said, which
|
||||||
|
* is the same absence the status row draws. A permanently visible "0 bg tasks" would be a line
|
||||||
|
* about nothing on every session that has never backgrounded anything, which is most of them.
|
||||||
|
*/
|
||||||
|
fun LazyListScope.backgroundTaskSection(
|
||||||
|
count: Int,
|
||||||
|
tasks: LoadState<List<BackgroundTaskSummary>?>,
|
||||||
|
expanded: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (count == 0) return
|
||||||
|
item(key = "background-heading") {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable(onClick = onToggle),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"${backgroundTaskLabel(count)} running",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Chevron(if (expanded) Pointing.Up else Pointing.Down)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!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}" }) { BackgroundTaskCard(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One background task: what it is doing, and what kind of thing is doing it.
|
||||||
|
*
|
||||||
|
* Not something to open, unlike the subagent cards below it -- a task is a provider's runtime state
|
||||||
|
* and has no transcript of its own. A backgrounded agent that does is also in the subagent list,
|
||||||
|
* under its own name.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BackgroundTaskCard(task: BackgroundTaskSummary) {
|
||||||
|
val kind = backgroundTaskKindLabel(task.kind)
|
||||||
|
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
|
// The kind stands in as the title 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 ?: kind,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color =
|
||||||
|
if (task.description == null) MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
else LocalContentColor.current,
|
||||||
|
)
|
||||||
|
if (task.description != null) {
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
kind,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a [BackgroundTaskSummary.kind] is called on screen.
|
||||||
|
*
|
||||||
|
* A kind this build has not heard of is named by what every one of them has in common rather than
|
||||||
|
* by the nearest word we do know, which would be this screen asserting something the server never
|
||||||
|
* said.
|
||||||
|
*/
|
||||||
|
private fun backgroundTaskKindLabel(kind: String) =
|
||||||
|
when (kind) {
|
||||||
|
"agent" -> "subagent"
|
||||||
|
"command" -> "background command"
|
||||||
|
"workflow" -> "workflow"
|
||||||
|
else -> "background task"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
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.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@@ -49,3 +54,49 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50)
|
|||||||
* ends that tall would bow its sides.
|
* ends that tall would bow its sides.
|
||||||
*/
|
*/
|
||||||
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
|
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,
|
"shell" to Language.SHELL,
|
||||||
"zsh" to Language.SHELL,
|
"zsh" to Language.SHELL,
|
||||||
"console" to Language.SHELL,
|
"console" to Language.SHELL,
|
||||||
|
"diff" to Language.DIFF,
|
||||||
"python" to Language.PYTHON,
|
"python" to Language.PYTHON,
|
||||||
"py" to Language.PYTHON,
|
"py" to Language.PYTHON,
|
||||||
"javascript" to Language.JAVASCRIPT,
|
"javascript" to Language.JAVASCRIPT,
|
||||||
|
|||||||
@@ -60,3 +60,23 @@ fun compactingLabel(seconds: Long?): String =
|
|||||||
seconds < 60 -> "compacting ${seconds}s"
|
seconds < 60 -> "compacting ${seconds}s"
|
||||||
else -> "compacting ${seconds / 60}m ${seconds % 60}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)}"
|
||||||
|
}
|
||||||
@@ -40,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.
|
* The mark a clear leaves.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -61,6 +61,24 @@ sealed class SessionEvent {
|
|||||||
|
|
||||||
data class AssistantText(val delta: String) : 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 ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
|
||||||
|
|
||||||
data class ToolUpdate(val id: String, val output: String) : SessionEvent()
|
data class ToolUpdate(val id: String, val output: String) : SessionEvent()
|
||||||
@@ -108,6 +126,26 @@ sealed class SessionEvent {
|
|||||||
val turnStart: Long? = null,
|
val turnStart: Long? = null,
|
||||||
) : SessionEvent()
|
) : 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
|
* 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.
|
* [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. */
|
/** The same command, handed to the session. */
|
||||||
data class CommandSent(val id: String, val text: String) : SessionEvent()
|
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()
|
data class Status(val state: String) : SessionEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,7 +176,25 @@ sealed class SessionEvent {
|
|||||||
* so adding turns up would report a figure the session stopped being true of. Null where the
|
* 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.
|
* 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.
|
* A compaction that finished, and how much context it recovered.
|
||||||
@@ -169,6 +228,8 @@ sealed class SessionEvent {
|
|||||||
*/
|
*/
|
||||||
data class LimitReached(val resetsAt: Double?) : SessionEvent()
|
data class LimitReached(val resetsAt: Double?) : SessionEvent()
|
||||||
|
|
||||||
|
data class AuthenticationRequired(val message: String) : SessionEvent()
|
||||||
|
|
||||||
data class Error(val message: String) : SessionEvent()
|
data class Error(val message: String) : SessionEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -205,6 +266,9 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
)
|
)
|
||||||
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
|
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
|
||||||
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
|
"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" ->
|
"toolStart" ->
|
||||||
SessionEvent.ToolStart(
|
SessionEvent.ToolStart(
|
||||||
id = body.getString("id"),
|
id = body.getString("id"),
|
||||||
@@ -252,19 +316,25 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
body.getString("text"),
|
body.getString("text"),
|
||||||
if (body.has("turnStart")) body.getLong("turnStart") else null,
|
if (body.has("turnStart")) body.getLong("turnStart") else null,
|
||||||
)
|
)
|
||||||
|
"unreadable" -> SessionEvent.Unreadable(body.getString("kind"))
|
||||||
|
"taskNote" -> SessionEvent.RetiredTaskNote
|
||||||
"commandQueued" ->
|
"commandQueued" ->
|
||||||
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
|
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
|
||||||
"commandSent" -> SessionEvent.CommandSent(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"))
|
"status" -> SessionEvent.Status(body.getString("state"))
|
||||||
"settings" ->
|
"settings" ->
|
||||||
SessionEvent.Settings(
|
SessionEvent.Settings(
|
||||||
model = body.optString("model").ifEmpty { null },
|
model = body.optString("model").ifEmpty { null },
|
||||||
permissionMode = body.optString("permissionMode").ifEmpty { null },
|
permissionMode = body.optString("permissionMode").ifEmpty { null },
|
||||||
)
|
)
|
||||||
|
"contextWindow" -> SessionEvent.ContextWindow(body.getLong("tokens"))
|
||||||
"usageDelta" ->
|
"usageDelta" ->
|
||||||
SessionEvent.UsageDelta(
|
SessionEvent.UsageDelta(
|
||||||
body.getLong("tokens"),
|
body.getLong("tokens"),
|
||||||
if (body.has("context")) body.getLong("context") else null,
|
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" ->
|
"compacted" ->
|
||||||
SessionEvent.Compacted(
|
SessionEvent.Compacted(
|
||||||
@@ -277,6 +347,8 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
SessionEvent.LimitReached(
|
SessionEvent.LimitReached(
|
||||||
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
|
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
|
||||||
)
|
)
|
||||||
|
"authenticationRequired" ->
|
||||||
|
SessionEvent.AuthenticationRequired(body.getString("message"))
|
||||||
"error" -> SessionEvent.Error(body.getString("message"))
|
"error" -> SessionEvent.Error(body.getString("message"))
|
||||||
else -> SessionEvent.Unknown(type)
|
else -> SessionEvent.Unknown(type)
|
||||||
}
|
}
|
||||||
@@ -291,7 +363,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
|
* first time the server grows a state, and the drift would be a reply that never splits or one
|
||||||
* split mid-stream.
|
* 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.
|
* The context after [event], given what it was before.
|
||||||
@@ -315,3 +401,18 @@ fun contextAfter(current: Long?, event: SessionEvent): Long? =
|
|||||||
is SessionEvent.Cleared -> null
|
is SessionEvent.Cleared -> null
|
||||||
else -> current
|
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
|
||||||
|
}
|
||||||
@@ -44,26 +44,46 @@ import kotlinx.coroutines.withContext
|
|||||||
/**
|
/**
|
||||||
* Which machine's files to show, and where to start.
|
* Which machine's files to show, and where to start.
|
||||||
*
|
*
|
||||||
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says
|
* A **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 setups tab -- one more
|
* 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.
|
* 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 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. */
|
/** Where the explorer is: in a directory, or in one file. */
|
||||||
private sealed class Spot(val path: String) {
|
private sealed class Spot(val path: String) {
|
||||||
class Dir(path: String) : Spot(path)
|
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.
|
* The files on the machine a session runs on: browse them, read one, change one.
|
||||||
*
|
*
|
||||||
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
|
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
|
||||||
* flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
|
* flowing and coming back from a file costs nothing. Both back controls return from a file to its
|
||||||
* viewer, viewer to the directory it came from, directory to the one above -- and only closes from
|
* directory. In a directory, Android back walks toward the session's project directory and closes
|
||||||
* where it opened.
|
* 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
|
* 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
|
* how one gets asked again on purpose, and creating something refetches the directory it was
|
||||||
@@ -72,51 +92,82 @@ private sealed class Spot(val path: String) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
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>>() }
|
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
||||||
var creating by remember { mutableStateOf(false) }
|
var creating by remember { mutableStateOf(false) }
|
||||||
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
// 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
|
// because both ways out have to ask before discarding it.
|
||||||
// and the platform's own gesture, which must mean the same thing.
|
|
||||||
var editing by remember { mutableStateOf(false) }
|
var editing by remember { mutableStateOf(false) }
|
||||||
var dirty by remember { mutableStateOf(false) }
|
var dirty by remember { mutableStateOf(false) }
|
||||||
var askUnsaved by remember { mutableStateOf(false) }
|
var unsavedDestination by remember { mutableStateOf<UnsavedDestination?>(null) }
|
||||||
|
|
||||||
val here = stack.last()
|
|
||||||
|
|
||||||
fun go(spot: Spot) {
|
fun go(spot: Spot) {
|
||||||
editing = false
|
editing = false
|
||||||
dirty = false
|
dirty = false
|
||||||
stack = stack + spot
|
here = spot
|
||||||
}
|
}
|
||||||
|
|
||||||
fun back() {
|
fun leave(destination: UnsavedDestination) {
|
||||||
when {
|
if (editing && dirty) {
|
||||||
editing && dirty -> askUnsaved = true
|
unsavedDestination = destination
|
||||||
editing -> editing = false
|
} else if (destination == UnsavedDestination.Directory) {
|
||||||
stack.size > 1 -> {
|
go((here as Spot.Doc).directory)
|
||||||
stack = stack.dropLast(1)
|
} else {
|
||||||
editing = false
|
onClose()
|
||||||
dirty = false
|
|
||||||
}
|
|
||||||
else -> onClose()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun load(path: String, again: Boolean) {
|
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] = LoadState.Loading
|
||||||
listings[path] =
|
listings[path] =
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
LoadState.Loaded(fetchDir(settings, target.setup, path))
|
LoadState.Loaded(fetchDir(settings, target.machine, path))
|
||||||
}
|
}
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
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(
|
Box(
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
@@ -129,14 +180,15 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
when (val spot = here) {
|
when (val spot = here) {
|
||||||
is Spot.Dir -> {
|
is Spot.Dir -> {
|
||||||
val state = listings[spot.path] ?: LoadState.Loading
|
val state = listings[spot.path] ?: LoadState.Loading
|
||||||
// The resolved path once there is one: a directory opened as `~` is called what
|
// Navigate with the resolved path, but name anything under the machine's home
|
||||||
// it turned out to be, not what it was asked for.
|
// the way somebody working there would write it.
|
||||||
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
|
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
|
||||||
|
val shownAt = tildePath(at, homeDirectory)
|
||||||
FilesHeader(
|
FilesHeader(
|
||||||
title = baseName(at),
|
title = baseName(shownAt),
|
||||||
path = at,
|
path = shownAt,
|
||||||
machine = target.setupName,
|
machine = target.machineName,
|
||||||
onBack = ::back,
|
onBack = { leave(UnsavedDestination.Session) },
|
||||||
) {
|
) {
|
||||||
GlyphButton(
|
GlyphButton(
|
||||||
REFRESH_GLYPH,
|
REFRESH_GLYPH,
|
||||||
@@ -152,7 +204,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
LaunchedEffect(spot.path) { load(spot.path, again = false) }
|
LaunchedEffect(spot.path) { load(spot.path, again = false) }
|
||||||
DirectoryBody(state, onOpen = ::go)
|
DirectoryBody(state, directory = spot, onOpen = ::go)
|
||||||
}
|
}
|
||||||
is Spot.Doc ->
|
is Spot.Doc ->
|
||||||
DocPane(
|
DocPane(
|
||||||
@@ -161,22 +213,26 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
path = spot.path,
|
path = spot.path,
|
||||||
name = baseName(spot.path),
|
name = baseName(spot.path),
|
||||||
editing = editing,
|
editing = editing,
|
||||||
|
homeDirectory = homeDirectory,
|
||||||
onEditing = { editing = it },
|
onEditing = { editing = it },
|
||||||
onDirty = { dirty = it },
|
onDirty = { dirty = it },
|
||||||
onBack = ::back,
|
onBack = { leave(UnsavedDestination.Directory) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (askUnsaved) {
|
unsavedDestination?.let { destination ->
|
||||||
UnsavedDialog(
|
UnsavedDialog(
|
||||||
onDiscard = {
|
onDiscard = {
|
||||||
askUnsaved = false
|
unsavedDestination = null
|
||||||
editing = false
|
if (destination == UnsavedDestination.Directory) {
|
||||||
dirty = false
|
go((here as Spot.Doc).directory)
|
||||||
|
} else {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onCancel = { askUnsaved = false },
|
onCancel = { unsavedDestination = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +241,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
if (creating && dir != null && listing != null) {
|
if (creating && dir != null && listing != null) {
|
||||||
CreateDialog(
|
CreateDialog(
|
||||||
settings = settings,
|
settings = settings,
|
||||||
setup = target.setup,
|
machine = target.machine,
|
||||||
directory = listing.path,
|
directory = listing.path,
|
||||||
onDismiss = { creating = false },
|
onDismiss = { creating = false },
|
||||||
onCreated = { path, isDirectory ->
|
onCreated = { path, isDirectory ->
|
||||||
@@ -196,7 +252,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
load(dir.path, again = true)
|
load(dir.path, again = true)
|
||||||
// A new file has nothing to look at, so it opens where it can be filled in.
|
// A new file has nothing to look at, so it opens where it can be filled in.
|
||||||
if (!isDirectory) {
|
if (!isDirectory) {
|
||||||
go(Spot.Doc(path))
|
go(Spot.Doc(path, dir))
|
||||||
editing = true
|
editing = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,7 +304,11 @@ private fun FilesHeader(
|
|||||||
* looks like a right one.
|
* looks like a right one.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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) {
|
when (state) {
|
||||||
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
|
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
|
||||||
is LoadState.Error ->
|
is LoadState.Error ->
|
||||||
@@ -289,7 +349,9 @@ private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot)
|
|||||||
name = entry.name,
|
name = entry.name,
|
||||||
trailing = trailingOf(entry),
|
trailing = trailingOf(entry),
|
||||||
onClick = {
|
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 +419,7 @@ private fun ColumnScope.DocPane(
|
|||||||
path: String,
|
path: String,
|
||||||
name: String,
|
name: String,
|
||||||
editing: Boolean,
|
editing: Boolean,
|
||||||
|
homeDirectory: String?,
|
||||||
onEditing: (Boolean) -> Unit,
|
onEditing: (Boolean) -> Unit,
|
||||||
onDirty: (Boolean) -> Unit,
|
onDirty: (Boolean) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -381,7 +444,7 @@ private fun ColumnScope.DocPane(
|
|||||||
state = LoadState.Loading
|
state = LoadState.Loading
|
||||||
state =
|
state =
|
||||||
try {
|
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)
|
if (got is FileContent.Text) draft = TextFieldValue(got.content)
|
||||||
LoadState.Loaded(got)
|
LoadState.Loaded(got)
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
@@ -404,7 +467,7 @@ private fun ColumnScope.DocPane(
|
|||||||
try {
|
try {
|
||||||
val written =
|
val written =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
writeFile(settings, target.setup, path, draft.text, against)
|
writeFile(settings, target.machine, path, draft.text, against)
|
||||||
}
|
}
|
||||||
state =
|
state =
|
||||||
LoadState.Loaded(
|
LoadState.Loaded(
|
||||||
@@ -430,7 +493,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 (editing) {
|
||||||
if (saving) {
|
if (saving) {
|
||||||
GlyphSpinner("Saving")
|
GlyphSpinner("Saving")
|
||||||
@@ -527,7 +595,9 @@ private fun ColumnScope.DocPane(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
val fresh =
|
val fresh =
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
|
withContext(Dispatchers.IO) {
|
||||||
|
fetchFile(settings, target.machine, path)
|
||||||
|
}
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
saveError = e.message
|
saveError = e.message
|
||||||
conflict = null
|
conflict = null
|
||||||
@@ -571,7 +641,7 @@ private fun Note(text: String) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun CreateDialog(
|
private fun CreateDialog(
|
||||||
settings: ServerSettings,
|
settings: ServerSettings,
|
||||||
setup: String,
|
machine: String,
|
||||||
directory: String,
|
directory: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onCreated: (String, Boolean) -> Unit,
|
onCreated: (String, Boolean) -> Unit,
|
||||||
@@ -591,8 +661,8 @@ private fun CreateDialog(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (isDirectory) createDir(settings, setup, path)
|
if (isDirectory) createDir(settings, machine, path)
|
||||||
else createFile(settings, setup, path)
|
else createFile(settings, machine, path)
|
||||||
}
|
}
|
||||||
onCreated(path, isDirectory)
|
onCreated(path, isDirectory)
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
@@ -678,6 +748,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. */
|
/** What a path names: its last segment, with `/` naming itself. */
|
||||||
internal fun baseName(path: String): String {
|
internal fun baseName(path: String): String {
|
||||||
val trimmed = path.trimEnd('/')
|
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. */
|
/** What a span of code is, in the terms the palette has a colour for. */
|
||||||
enum class Kind {
|
enum class Kind {
|
||||||
|
ADDITION,
|
||||||
|
DELETION,
|
||||||
KEYWORD,
|
KEYWORD,
|
||||||
STRING,
|
STRING,
|
||||||
LITERAL,
|
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.
|
* one instance and lives with the rest of the palette.
|
||||||
*/
|
*/
|
||||||
data class SyntaxPalette(
|
data class SyntaxPalette(
|
||||||
|
val addition: Color,
|
||||||
|
val deletion: Color,
|
||||||
val keyword: Color,
|
val keyword: Color,
|
||||||
val string: Color,
|
val string: Color,
|
||||||
val literal: Color,
|
val literal: Color,
|
||||||
@@ -34,6 +38,8 @@ data class SyntaxPalette(
|
|||||||
) {
|
) {
|
||||||
fun of(kind: Kind): Color =
|
fun of(kind: Kind): Color =
|
||||||
when (kind) {
|
when (kind) {
|
||||||
|
Kind.ADDITION -> addition
|
||||||
|
Kind.DELETION -> deletion
|
||||||
Kind.KEYWORD -> keyword
|
Kind.KEYWORD -> keyword
|
||||||
Kind.STRING -> string
|
Kind.STRING -> string
|
||||||
Kind.LITERAL -> literal
|
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.
|
* [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
|
@Composable
|
||||||
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
|
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
var machines by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
|
||||||
var chosen by remember { mutableStateOf<Setup?>(null) }
|
var chosen by remember { mutableStateOf<Machine?>(null) }
|
||||||
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
|
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
|
||||||
|
|
||||||
// What is happening to each row right now, as the word the row shows. A map keyed by id rather
|
// 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
|
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
|
||||||
// themselves, not a flag, so the dialog can say what it is about.
|
// themselves, not a flag, so the dialog can say what it is about.
|
||||||
var confirming by remember { mutableStateOf<List<Importable>?>(null) }
|
var confirming by remember { mutableStateOf<List<Importable>?>(null) }
|
||||||
// Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty
|
// Set from the selected Claude provider rather than repeated in the app.
|
||||||
// times.
|
var permissionMode by remember { mutableStateOf("") }
|
||||||
var permissionMode by remember { mutableStateOf("auto") }
|
|
||||||
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
|
// 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.
|
// so a tap reading it needs no recomposition.
|
||||||
val movedAt = remember { mutableMapOf<String, Long>() }
|
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
|
* 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.
|
* 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 {
|
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()
|
running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
|
||||||
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
|
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
|
||||||
LoadState.Loaded(rows)
|
LoadState.Loaded(rows)
|
||||||
@@ -124,10 +123,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
LoadState.Error(err.message ?: "Couldn't list sessions")
|
LoadState.Error(err.message ?: "Couldn't list sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadSessions(setup: Setup) {
|
fun loadSessions(machine: Machine) {
|
||||||
sessions = LoadState.Loading
|
sessions = LoadState.Loading
|
||||||
selected = emptySet()
|
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. */
|
/** 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) {
|
LaunchedEffect(reloadToken) {
|
||||||
setups =
|
machines =
|
||||||
try {
|
try {
|
||||||
val found = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
val found = withContext(Dispatchers.IO) { fetchMachines(settings) }
|
||||||
found.firstOrNull()?.let {
|
found.firstOrNull()?.let {
|
||||||
chosen = it
|
chosen = it
|
||||||
loadSessions(it)
|
loadSessions(it)
|
||||||
@@ -171,7 +170,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
selected = emptySet()
|
selected = emptySet()
|
||||||
running = running + targets.associate { it.id to WAITING }
|
running = running + targets.associate { it.id to WAITING }
|
||||||
rowErrors = rowErrors - targets.map { it.id }.toSet()
|
rowErrors = rowErrors - targets.map { it.id }.toSet()
|
||||||
val setup = chosen
|
val machine = chosen
|
||||||
val ids = targets.map { it.id }
|
val ids = targets.map { it.id }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// One request for the whole batch, not one per row. Sent row by row, a handover was
|
// One request for the whole batch, not one per row. Sent row by row, a handover was
|
||||||
@@ -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
|
// 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
|
// something still looks outstanding, so the ordinary case does not pay for a second
|
||||||
// listing, which is the most expensive call this screen makes.
|
// 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
|
// 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
|
// saying what is happening to them is the flicker this screen avoids everywhere
|
||||||
// else.
|
// else.
|
||||||
sessions = fetchInto(setup)
|
sessions = fetchInto(machine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
|
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. */
|
/** Continues [targets] in the background, leaving the screen where it is. */
|
||||||
fun importAll(targets: List<Importable>) {
|
fun importAll(targets: List<Importable>) {
|
||||||
val setup = chosen ?: return
|
val machine = chosen ?: return
|
||||||
val useProvider = provider ?: return
|
val useProvider = provider ?: return
|
||||||
handOver(targets) { ids ->
|
handOver(targets) { ids ->
|
||||||
startImport(
|
startImport(
|
||||||
settings,
|
settings,
|
||||||
setup = setup.id,
|
machine = machine.id,
|
||||||
sessionIds = ids,
|
sessionIds = ids,
|
||||||
provider = useProvider.name,
|
provider = useProvider.name,
|
||||||
permissionMode = permissionMode,
|
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.
|
* it, which is the case where waiting is the right thing anyway.
|
||||||
*/
|
*/
|
||||||
fun importAndOpen(target: Importable) {
|
fun importAndOpen(target: Importable) {
|
||||||
val setup = chosen ?: return
|
val machine = chosen ?: return
|
||||||
val useProvider = provider ?: return
|
val useProvider = provider ?: return
|
||||||
running = running + (target.id to IMPORTING)
|
running = running + (target.id to IMPORTING)
|
||||||
rowErrors = rowErrors - target.id
|
rowErrors = rowErrors - target.id
|
||||||
@@ -242,7 +244,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
spawnSession(
|
spawnSession(
|
||||||
settings,
|
settings,
|
||||||
setup = setup.id,
|
machine = machine.id,
|
||||||
provider = useProvider.name,
|
provider = useProvider.name,
|
||||||
// Nothing to say: the server titles it from the session it continues.
|
// Nothing to say: the server titles it from the session it continues.
|
||||||
title = "",
|
title = "",
|
||||||
@@ -270,10 +272,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
|
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
|
||||||
}
|
}
|
||||||
LaunchedEffect(chosen?.id) {
|
LaunchedEffect(chosen?.id) {
|
||||||
val setup = chosen?.id ?: return@LaunchedEffect
|
val machine = chosen?.id ?: return@LaunchedEffect
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
val stream = ImportableStream(settings, setup)
|
val stream = ImportableStream(settings, machine)
|
||||||
liveChanges.set(stream)
|
liveChanges.set(stream)
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -341,24 +343,24 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
when (val loaded = setups) {
|
when (val loaded = machines) {
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
is LoadState.Loading -> CircularProgressIndicator()
|
||||||
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
|
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
|
||||||
is LoadState.Loaded -> {
|
is LoadState.Loaded -> {
|
||||||
// Only worth choosing when there is a choice.
|
// Only worth choosing when there is a choice.
|
||||||
if (loaded.value.size > 1) {
|
if (loaded.value.size > 1) {
|
||||||
Row(Modifier.fillMaxWidth()) {
|
Row(Modifier.fillMaxWidth()) {
|
||||||
loaded.value.forEach { setup ->
|
loaded.value.forEach { machine ->
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
chosen = setup
|
chosen = machine
|
||||||
loadSessions(setup)
|
loadSessions(machine)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
setup.name,
|
machine.name,
|
||||||
color =
|
color =
|
||||||
if (setup.id == chosen?.id)
|
if (machine.id == chosen?.id)
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -375,7 +377,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
} else {
|
} else {
|
||||||
ChipGroup(
|
ChipGroup(
|
||||||
label = "Permissions",
|
label = "Permissions",
|
||||||
options = PERMISSION_MODES,
|
options = provider?.permissionModes.orEmpty(),
|
||||||
selected = permissionMode,
|
selected = permissionMode,
|
||||||
onSelect = { permissionMode = it },
|
onSelect = { permissionMode = it },
|
||||||
)
|
)
|
||||||
@@ -439,9 +441,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val setup = chosen ?: return@TextButton
|
val machine = chosen ?: return@TextButton
|
||||||
confirming = null
|
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.
|
// 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 caller owns reconnecting -- there is no cursor to resume from, because anything missed is in
|
||||||
* the next listing.
|
* the next listing.
|
||||||
*/
|
*/
|
||||||
class ImportableStream(settings: ServerSettings, private val setup: String) {
|
class ImportableStream(settings: ServerSettings, private val machine: String) {
|
||||||
private val stream = Sse(settings)
|
private val stream = Sse(settings)
|
||||||
|
|
||||||
fun close() = stream.close()
|
fun close() = stream.close()
|
||||||
|
|
||||||
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
|
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)
|
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ enum class Language {
|
|||||||
CPP,
|
CPP,
|
||||||
CSHARP,
|
CSHARP,
|
||||||
DART,
|
DART,
|
||||||
|
DIFF,
|
||||||
FISH,
|
FISH,
|
||||||
GO,
|
GO,
|
||||||
JAVA,
|
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.
|
// Lazy for the same reason [RULES] is, since it reads it.
|
||||||
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
|
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
|
||||||
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
|
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)
|
private val C_STYLE = BlockComment("/*", "*/", nests = false)
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
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.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.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
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.query,
|
||||||
|
onValueChange = { state.query = it },
|
||||||
|
label = { Text("Search HuggingFace") },
|
||||||
|
singleLine = true,
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
+118
-35
@@ -1,5 +1,7 @@
|
|||||||
package com.example.aiapp
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -10,6 +12,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@@ -24,6 +27,11 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.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 androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -37,19 +45,25 @@ import kotlinx.coroutines.withContext
|
|||||||
* which is what keeps the enrolled token from being able to introduce commands.
|
* which is what keeps the enrolled token from being able to introduce commands.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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()
|
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 adding by remember { mutableStateOf(false) }
|
||||||
var renaming by remember { mutableStateOf<Setup?>(null) }
|
var renaming by remember { mutableStateOf<Machine?>(null) }
|
||||||
var confirmingDelete by remember { mutableStateOf<Setup?>(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 busy by remember { mutableStateOf<String?>(null) }
|
||||||
var actionError by remember { mutableStateOf<String?>(null) }
|
var actionError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
suspend fun reload() {
|
suspend fun reload() {
|
||||||
state =
|
state =
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) }
|
withContext(Dispatchers.IO) { LoadState.Loaded(fetchMachines(settings)) }
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
LoadState.failed(e)
|
||||||
}
|
}
|
||||||
@@ -82,19 +96,19 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
||||||
is LoadState.Loaded ->
|
is LoadState.Loaded ->
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
uniqueItems(current.value, key = { it.id }) { setup ->
|
uniqueItems(current.value, key = { it.id }) { machine ->
|
||||||
SetupCard(
|
MachineCard(
|
||||||
setup = setup,
|
machine = machine,
|
||||||
onRename = { renaming = setup },
|
onRename = { renaming = machine },
|
||||||
onRediscover = {
|
onRediscover = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
busy = "Asking ${setup.name} what it has…"
|
busy = "Asking ${machine.name} what it has…"
|
||||||
actionError =
|
actionError =
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
updateSetup(
|
updateMachine(
|
||||||
settings,
|
settings,
|
||||||
setup.id,
|
machine.id,
|
||||||
rediscover = true,
|
rediscover = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -105,7 +119,9 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDelete = { confirmingDelete = setup },
|
onDelete = { confirmingDelete = machine },
|
||||||
|
onSignIn = { provider -> signingIn = machine to provider },
|
||||||
|
onProvider = { provider -> onProvider(machine.id, provider.name) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +129,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (adding) {
|
if (adding) {
|
||||||
AddSetupDialog(
|
AddMachineDialog(
|
||||||
onDismiss = { adding = false },
|
onDismiss = { adding = false },
|
||||||
onAdd = { name, ssh ->
|
onAdd = { name, ssh ->
|
||||||
adding = false
|
adding = false
|
||||||
@@ -121,7 +137,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
busy = "Asking $name what it has…"
|
busy = "Asking $name what it has…"
|
||||||
actionError =
|
actionError =
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) { addSetup(settings, name, ssh) }
|
withContext(Dispatchers.IO) { addMachine(settings, name, ssh) }
|
||||||
}
|
}
|
||||||
.exceptionOrNull()
|
.exceptionOrNull()
|
||||||
?.message
|
?.message
|
||||||
@@ -129,13 +145,13 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } },
|
onTest = { ssh -> withContext(Dispatchers.IO) { probeMachine(settings, ssh) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
renaming?.let { setup ->
|
renaming?.let { machine ->
|
||||||
RenameDialog(
|
RenameDialog(
|
||||||
setup = setup,
|
machine = machine,
|
||||||
onDismiss = { renaming = null },
|
onDismiss = { renaming = null },
|
||||||
onRename = { name ->
|
onRename = { name ->
|
||||||
renaming = null
|
renaming = null
|
||||||
@@ -143,7 +159,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
actionError =
|
actionError =
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
updateSetup(settings, setup.id, name = name)
|
updateMachine(settings, machine.id, name = name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.exceptionOrNull()
|
.exceptionOrNull()
|
||||||
@@ -154,10 +170,10 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmingDelete?.let { setup ->
|
confirmingDelete?.let { machine ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmingDelete = null },
|
onDismissRequest = { confirmingDelete = null },
|
||||||
title = { Text("Remove \"${setup.name}\"?") },
|
title = { Text("Remove \"${machine.name}\"?") },
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
"The machine is left alone -- this only stops this app offering it. " +
|
"The machine is left alone -- this only stops this app offering it. " +
|
||||||
@@ -171,7 +187,9 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
actionError =
|
actionError =
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) }
|
withContext(Dispatchers.IO) {
|
||||||
|
deleteMachine(settings, machine.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.exceptionOrNull()
|
.exceptionOrNull()
|
||||||
?.message
|
?.message
|
||||||
@@ -187,34 +205,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
|
@Composable
|
||||||
private fun SetupCard(
|
private fun MachineCard(
|
||||||
setup: Setup,
|
machine: Machine,
|
||||||
onRename: () -> Unit,
|
onRename: () -> Unit,
|
||||||
onRediscover: () -> Unit,
|
onRediscover: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
|
onSignIn: (Provider) -> Unit,
|
||||||
|
onProvider: (Provider) -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
Text(setup.name, style = MaterialTheme.typography.titleSmall)
|
Text(machine.name, style = MaterialTheme.typography.titleSmall)
|
||||||
Text(
|
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".
|
// machine / this machine".
|
||||||
setup.address ?: "runs where the backend does",
|
machine.address ?: "runs where the backend does",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
if (machine.providers.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
if (setup.providers.isEmpty()) {
|
"Nothing found on it. Install something and rediscover.",
|
||||||
"Nothing found on it. Install something and rediscover."
|
|
||||||
} else {
|
|
||||||
setup.providers.joinToString(" · ") { it.name }
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
TextButton(onClick = onRename) { Text("Rename") }
|
TextButton(onClick = onRename) { Text("Rename") }
|
||||||
TextButton(onClick = onRediscover) { Text("Rediscover") }
|
TextButton(onClick = onRediscover) { Text("Rediscover") }
|
||||||
@@ -226,7 +309,7 @@ private fun SetupCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddSetupDialog(
|
private fun AddMachineDialog(
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAdd: (String, SshDetails?) -> Unit,
|
onAdd: (String, SshDetails?) -> Unit,
|
||||||
onTest: suspend (SshDetails?) -> List<Provider>,
|
onTest: suspend (SshDetails?) -> List<Provider>,
|
||||||
@@ -349,8 +432,8 @@ private fun AddSetupDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
private fun RenameDialog(machine: Machine, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
||||||
var name by remember { mutableStateOf(setup.name) }
|
var name by remember { mutableStateOf(machine.name) }
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Rename") },
|
title = { Text("Rename") },
|
||||||
@@ -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
|
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
|
* These were screens reached by words in a row under the title, and the row was already full. Tabs
|
||||||
* full. Tabs say the same thing in less space and say one more thing besides: that these are places
|
* say the same thing in less space and say one more thing besides: that these are places to be
|
||||||
* to be rather than errands to run. Sessions, the machine's importable history, the models on it
|
* rather than errands to run. Sessions, the machine's importable history and the machines
|
||||||
* and the machines themselves are all *the same backend*, looked at four ways, and none is a step
|
* themselves are all *the same backend*, looked at three ways, and none is a step down from
|
||||||
* down from another. Settings still is, which is why it stays a pushed screen with its own Back.
|
* 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) {
|
private enum class MainTab(val label: String) {
|
||||||
Sessions("Sessions"),
|
Sessions("Sessions"),
|
||||||
Import("Import"),
|
Import("Import"),
|
||||||
Models("Models"),
|
Machines("Machines"),
|
||||||
Setups("Setups"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -48,11 +52,13 @@ fun MainScreen(
|
|||||||
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
|
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
|
||||||
share: ShareRequest? = null,
|
share: ShareRequest? = null,
|
||||||
onOpen: (SessionSummary) -> Unit,
|
onOpen: (SessionSummary) -> Unit,
|
||||||
/** Opens one session's subagent, from the expander under its card. */
|
|
||||||
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
|
|
||||||
onSpawn: () -> Unit,
|
onSpawn: () -> Unit,
|
||||||
onImported: (SessionSummary) -> Unit,
|
onImported: (SessionSummary) -> Unit,
|
||||||
onSettings: () -> 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 tab by remember { mutableStateOf(MainTab.Sessions) }
|
||||||
var refreshToken by remember { mutableIntStateOf(0) }
|
var refreshToken by remember { mutableIntStateOf(0) }
|
||||||
@@ -141,13 +147,13 @@ fun MainScreen(
|
|||||||
settings = settings,
|
settings = settings,
|
||||||
reloadToken = token,
|
reloadToken = token,
|
||||||
onOpen = onOpen,
|
onOpen = onOpen,
|
||||||
onOpenSubagent = onOpenSubagent,
|
|
||||||
onSpawn = onSpawn,
|
onSpawn = onSpawn,
|
||||||
|
onDeleted = onDeleted,
|
||||||
)
|
)
|
||||||
MainTab.Import ->
|
MainTab.Import ->
|
||||||
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
|
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
|
||||||
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
|
MainTab.Machines ->
|
||||||
MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token)
|
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.getUnescapedTextInNode
|
||||||
import com.mikepenz.markdown.utils.resolveImageAlt
|
import com.mikepenz.markdown.utils.resolveImageAlt
|
||||||
import com.mikepenz.markdown.utils.resolveImageLink
|
import com.mikepenz.markdown.utils.resolveImageLink
|
||||||
|
import java.net.URI
|
||||||
import org.intellij.markdown.MarkdownElementTypes
|
import org.intellij.markdown.MarkdownElementTypes
|
||||||
import org.intellij.markdown.MarkdownTokenTypes
|
import org.intellij.markdown.MarkdownTokenTypes
|
||||||
import org.intellij.markdown.ast.ASTNode
|
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)
|
content.buildMarkdownAnnotatedString(node, style, settings)
|
||||||
}
|
}
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val fileLinkHandler = LocalFileLinkHandler.current
|
||||||
val onPlainTap = LocalMarkdownTap.current
|
val onPlainTap = LocalMarkdownTap.current
|
||||||
val layout = remember { Ref<TextLayoutResult>() }
|
val layout = remember { Ref<TextLayoutResult>() }
|
||||||
// The renderer's own rule for a style that names no colour: the theme's text colour.
|
// 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 {
|
when {
|
||||||
url != null -> {
|
url != null -> {
|
||||||
up.consume()
|
up.consume()
|
||||||
uriHandler.openUri(url)
|
if (fileLinkHandler?.invoke(url) != true) uriHandler.openUri(url)
|
||||||
}
|
}
|
||||||
onPlainTap != null -> {
|
onPlainTap != null -> {
|
||||||
up.consume()
|
up.consume()
|
||||||
@@ -164,6 +166,55 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
|||||||
*/
|
*/
|
||||||
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
|
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
|
* [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
|
* 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
|
* one model rather than one model from another. Anything that does not look like that is returned
|
||||||
* untouched.
|
* 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.
|
* A display decision, not a correction: the full name is what the session reports.
|
||||||
*/
|
*/
|
||||||
fun modelLabel(model: String?): String {
|
fun modelLabel(model: String?): String {
|
||||||
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
|
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
|
||||||
|
if (name.endsWith(GGUF)) {
|
||||||
|
return name.substringAfterLast('/').removeSuffix(GGUF)
|
||||||
|
}
|
||||||
return name.removePrefix("claude-").replace(DATED_SUFFIX, "")
|
return name.removePrefix("claude-").replace(DATED_SUFFIX, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */
|
/** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */
|
||||||
private val DATED_SUFFIX = Regex("""-\d{8}$""")
|
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
|
* 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
|
* 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
|
* 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
|
* font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
|
||||||
* the script did not subset is a glyph that silently isn't there.
|
* the script did not subset is a glyph that silently isn't there.
|
||||||
*
|
*
|
||||||
@@ -136,6 +136,16 @@ val EDIT_GLYPH = glyph(0xF03EB)
|
|||||||
*/
|
*/
|
||||||
val SAVE_GLYPH = glyph(0xF0193)
|
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)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The size an icon draws at beside a line of text.
|
* The size an icon draws at beside a line of text.
|
||||||
*
|
*
|
||||||
@@ -165,7 +175,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
|
* 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.
|
* 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
|
* 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
|
* gets a push from Google's servers, which would mean this backend talking to Google about
|
||||||
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
|
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
|
||||||
*
|
*
|
||||||
|
* 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
|
* 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
|
* 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
|
* 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
|
// Nothing to tell somebody about the session they are reading. The transcript in front of
|
||||||
// them is already saying it.
|
// them is already saying it.
|
||||||
if (isOnScreen(notification.sessionId)) return
|
if (isOnScreen(notification.sessionId)) return
|
||||||
// The app is up: it says this itself, as a banner over whatever screen they are on. Never
|
// The app is up, so it says this itself as a banner over whatever screen they are on --
|
||||||
// both -- one thing happened, and a drawer filling up behind an app that already showed you
|
// which interrupts, where the drawer's row records: a banner lasts seconds and reaches only
|
||||||
// each one is a drawer nobody reads.
|
// somebody already looking. Both go up, and the banner having done the interrupting is what
|
||||||
if (handOver(notification)) return
|
// makes the row a silent one.
|
||||||
|
val banner = handOver(notification)
|
||||||
val manager = NotificationManagerCompat.from(this)
|
val manager = NotificationManagerCompat.from(this)
|
||||||
// Two different noes, and both are answers rather than faults: the runtime permission
|
// Two different noes, and both are answers rather than faults: the runtime permission
|
||||||
// refused, and notifications switched off for the app in Android's own settings.
|
// refused, and notifications switched off for the app in Android's own settings.
|
||||||
@@ -172,6 +175,7 @@ class NotificationService : Service() {
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setWhen((notification.at * 1000).toLong())
|
.setWhen((notification.at * 1000).toLong())
|
||||||
.setShowWhen(true)
|
.setShowWhen(true)
|
||||||
|
.setSilent(banner)
|
||||||
.build()
|
.build()
|
||||||
manager.notify(notification.sessionId, ALERT_ID, built)
|
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:
|
* 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
|
* [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
|
* 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)
|
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
|
||||||
|
|
||||||
@@ -272,7 +277,11 @@ class NotificationService : Service() {
|
|||||||
private fun handOver(notification: SessionNotification) =
|
private fun handOver(notification: SessionNotification) =
|
||||||
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
|
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) {
|
fun showing(context: Context, sessionId: String) {
|
||||||
onScreen = sessionId
|
onScreen = sessionId
|
||||||
// Whatever was posted about it before is about to be read, so it has nothing left to
|
// 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,215 @@
|
|||||||
|
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.material3.AlertDialog
|
||||||
|
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
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = code,
|
||||||
|
onValueChange = { code = it },
|
||||||
|
label = { Text("Authorization code") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
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,117 @@
|
|||||||
|
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.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
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,
|
||||||
|
/**
|
||||||
|
* Whether to say which settings wait for a restart. False on a spawn form, where nothing is
|
||||||
|
* running yet and every setting is about to be read — saying it there would be a warning about
|
||||||
|
* a state the reader cannot be in.
|
||||||
|
*/
|
||||||
|
warnAboutRestart: Boolean,
|
||||||
|
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()
|
||||||
|
ChipGroup(
|
||||||
|
label = spec.label + restartSuffix(spec, warnAboutRestart),
|
||||||
|
options = spec.options,
|
||||||
|
selected = values[spec.key] ?: default,
|
||||||
|
onSelect = { chosen -> set(if (chosen == default) "" else chosen) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
OutlinedTextField(
|
||||||
|
value = values[spec.key].orEmpty(),
|
||||||
|
onValueChange = set,
|
||||||
|
label = { Text(spec.label + restartSuffix(spec, warnAboutRestart)) },
|
||||||
|
placeholder = { Text(spec.unset) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = keyboardFor(spec.kind)),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
if (warnAboutRestart && specs.any { it.restart }) {
|
||||||
|
Text(
|
||||||
|
"A setting marked “on restart” is saved now and read when this session's process " +
|
||||||
|
"next starts.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a control whose value will not take effect yet.
|
||||||
|
*
|
||||||
|
* On the label rather than beside it, because the reader decides whether to change the thing before
|
||||||
|
* they touch it — a note underneath is read after the decision.
|
||||||
|
*/
|
||||||
|
private fun restartSuffix(spec: ParamSpec, warn: Boolean): String =
|
||||||
|
if (warn && spec.restart) " (on restart)" else ""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,484 @@
|
|||||||
|
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.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.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,
|
||||||
|
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)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
TextButton(onClick = onBack) { 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)
|
||||||
|
Text(
|
||||||
|
"How a model is loaded belongs to the machine, not to a session: " +
|
||||||
|
"one copy of it in memory answers every session using it.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
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) }
|
||||||
|
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))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = typed,
|
||||||
|
onValueChange = { typed = it.filter(Char::isDigit) },
|
||||||
|
label = { Text("Models loaded at once") },
|
||||||
|
placeholder = { Text("one -- a second model replaces the first") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
onClick = { onMaxLoaded(typed.toIntOrNull()) },
|
||||||
|
) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typed != saved) {
|
||||||
|
Text(
|
||||||
|
"Read when this server next starts.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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) }
|
||||||
|
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())) {
|
||||||
|
Text(
|
||||||
|
if (model.status == "loaded" || model.status == "sleeping") {
|
||||||
|
"This model is loaded. Saving takes it out of memory, and the sessions " +
|
||||||
|
"using it load it again with these settings on their next message."
|
||||||
|
} else {
|
||||||
|
"Read when this model is next loaded."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
ProviderParamFields(
|
||||||
|
specs = specs,
|
||||||
|
values = params,
|
||||||
|
onChange = { params = it },
|
||||||
|
// Every one of these is read at load time, and the sentence above already
|
||||||
|
// says when that is -- marking each control "on restart" would repeat it six
|
||||||
|
// times.
|
||||||
|
warnAboutRestart = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onSave(params) }) { Text("Save") } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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
|
* 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.
|
* 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.
|
* The colour is [rawSurface], which is also what a code block inside a reply is given.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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.
|
// rectangle drawn at the same radius as the one behind it reads as a misprint.
|
||||||
.clip(MaterialTheme.shapes.extraSmall)
|
.clip(MaterialTheme.shapes.extraSmall)
|
||||||
.background(rawSurface)
|
.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),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
content = content,
|
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 }
|
||||||
|
}
|
||||||
@@ -31,13 +31,13 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
|||||||
import androidx.lifecycle.repeatOnLifecycle
|
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
|
* Two places carry the same fact and they are doing different jobs: a row in the shade waits
|
||||||
* somebody looking at something else: it makes a sound, it waits however long it has to, and acting
|
* however long it has to, which makes it the record, and a banner is read now or not at all, which
|
||||||
* on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
|
* makes it the interruption. So somebody with the app open gets both -- this, and a silent row
|
||||||
* while these are on screen the stream is delivered here instead, which is arranged by the
|
* behind it that is still there when they go looking and goes by itself when they open the session.
|
||||||
* collection below and nothing else.
|
* 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
|
* 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.
|
* 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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.FilterQuality
|
import androidx.compose.ui.graphics.FilterQuality
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
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.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.isSpecified
|
import androidx.compose.ui.unit.isSpecified
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -140,12 +156,23 @@ fun SessionImageViewer(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
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(
|
Dialog(
|
||||||
onDismissRequest = onClose,
|
onDismissRequest = onClose,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
properties =
|
||||||
|
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
||||||
) {
|
) {
|
||||||
|
ViewerSystemBars(hiddenBars)
|
||||||
Box(
|
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,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
when (val image = bitmap) {
|
when (val image = bitmap) {
|
||||||
@@ -165,7 +192,46 @@ fun SessionImageViewer(
|
|||||||
// beside it are.
|
// beside it are.
|
||||||
CircularProgressIndicator(color = Color.White)
|
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.
|
* 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
|
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
|
||||||
* gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image
|
* gesture returns to the transcript instead of leaving the app. It opens fitted, with the whole
|
||||||
* visible.
|
* image visible without enlarging a smaller one; the 100% control changes to one bitmap pixel per
|
||||||
|
* screen pixel and recenters it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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 scale by remember { mutableFloatStateOf(1f) }
|
||||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
var offsetY 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(
|
Image(
|
||||||
bitmap = image,
|
bitmap = image,
|
||||||
contentDescription = "Attached image",
|
contentDescription = "Attached image",
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Inside,
|
||||||
// Zoomed in, the reader is looking at pixels on purpose.
|
// Zoomed in, the reader is looking at pixels on purpose.
|
||||||
filterQuality = FilterQuality.None,
|
filterQuality = FilterQuality.None,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.pointerInput(Unit) {
|
.onSizeChanged(onViewportChanged)
|
||||||
detectTransformGestures { _, pan, zoom, _ ->
|
.pointerInput(nativeScale) {
|
||||||
// Floor of 1 so the image cannot be pinched smaller than fitted, which is
|
detectTransformGestures { centroid, pan, zoom, _ ->
|
||||||
// already the whole of it; a ceiling so it cannot be lost off-screen.
|
val oldScale = scale
|
||||||
scale = (scale * zoom).coerceIn(1f, 8f)
|
val maximumScale = maxOf(8f, nativeScale)
|
||||||
if (scale > 1f) {
|
val newScale = (oldScale * zoom).coerceIn(1f, maximumScale)
|
||||||
offsetX += pan.x
|
if (newScale > 1f) {
|
||||||
offsetY += pan.y
|
val offset =
|
||||||
|
zoomOffset(
|
||||||
|
Offset(offsetX, offsetY),
|
||||||
|
centroid,
|
||||||
|
pan,
|
||||||
|
oldScale,
|
||||||
|
newScale,
|
||||||
|
Offset(size.width / 2f, size.height / 2f),
|
||||||
|
)
|
||||||
|
offsetX = offset.x
|
||||||
|
offsetY = offset.y
|
||||||
} else {
|
} else {
|
||||||
offsetX = 0f
|
offsetX = 0f
|
||||||
offsetY = 0f
|
offsetY = 0f
|
||||||
}
|
}
|
||||||
|
scale = newScale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.graphicsLayer {
|
.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,26 +1,28 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedCard
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -33,16 +35,30 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.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.LocalContext
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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.
|
* 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
|
* What stays here is the button that adds a session, because that acts on this list and nothing
|
||||||
@@ -53,39 +69,24 @@ fun SessionListScreen(
|
|||||||
settings: ServerSettings,
|
settings: ServerSettings,
|
||||||
reloadToken: Int,
|
reloadToken: Int,
|
||||||
onOpen: (SessionSummary) -> Unit,
|
onOpen: (SessionSummary) -> Unit,
|
||||||
/** Opens one session's subagent, from the expander under its card. */
|
|
||||||
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
|
|
||||||
onSpawn: () -> Unit,
|
onSpawn: () -> Unit,
|
||||||
|
/** A session this list has just deleted, for whoever is showing it elsewhere. */
|
||||||
|
onDeleted: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
||||||
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
|
|
||||||
|
|
||||||
// Which session cards are expanded to show their subagents, and what each expansion fetched.
|
// Which rows the reader has picked out. Empty means selection mode is off, as on the import
|
||||||
// Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt
|
// tab: a selection mode with nothing in it has no controls and no way out but Back.
|
||||||
// from
|
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
// whatever the server last said, and this belongs to the reader's own choice, which survives a
|
|
||||||
// refresh.
|
|
||||||
var expandedSessions by remember { mutableStateOf(setOf<String>()) }
|
|
||||||
var subagentLoads by remember {
|
|
||||||
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadSubagents(sessionId: String) {
|
// The sessions a delete has been confirmed for, or none. A list rather than one session,
|
||||||
subagentLoads = subagentLoads + (sessionId to LoadState.Loading)
|
// because a selection is what the bar below acts on.
|
||||||
scope.launch {
|
var confirmingDelete by remember { mutableStateOf<List<SessionSummary>>(emptyList()) }
|
||||||
subagentLoads =
|
|
||||||
subagentLoads +
|
// Whether an answer is outstanding, which is a different question from whether there is
|
||||||
(sessionId to
|
// anything to draw: see [refresh].
|
||||||
try {
|
var reloading by remember { mutableStateOf(false) }
|
||||||
LoadState.Loaded(
|
|
||||||
withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) }
|
|
||||||
)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
|
// 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,
|
// own card. The two scopes are decided by whether the server answered: it answered and refused,
|
||||||
@@ -94,6 +95,10 @@ fun SessionListScreen(
|
|||||||
// Cleared on the next successful load below -- an entry outlives its session otherwise.
|
// Cleared on the next successful load below -- an entry outlives its session otherwise.
|
||||||
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
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
|
// 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.
|
// the rows are rebuilt from whatever the server last said and this belongs to the request.
|
||||||
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
@@ -104,39 +109,142 @@ fun SessionListScreen(
|
|||||||
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
||||||
|
|
||||||
fun refresh() {
|
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 {
|
scope.launch {
|
||||||
listState =
|
listState =
|
||||||
try {
|
try {
|
||||||
val loaded =
|
val loaded =
|
||||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
|
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
|
||||||
deleteErrors = emptyMap()
|
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
|
// 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
|
// 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
|
// answer rather than in `finally`: a list that failed to arrive says nothing
|
||||||
// about which sessions exist.
|
// about which sessions exist.
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) { transcriptCache.retainOnly(alive) }
|
||||||
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
|
|
||||||
}
|
|
||||||
// A session gone from this answer cannot still be expanded, and an expanded one
|
|
||||||
// that is still here asks again -- its subagents may have changed since the
|
|
||||||
// last
|
|
||||||
// fetch.
|
|
||||||
val ids = loaded.value.map { it.id }.toSet()
|
|
||||||
expandedSessions = expandedSessions intersect ids
|
|
||||||
subagentLoads = subagentLoads.filterKeys { it in ids }
|
|
||||||
expandedSessions.forEach(::loadSubagents)
|
|
||||||
loaded
|
loaded
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
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() }
|
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()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
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) {
|
when (val state = listState) {
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
is LoadState.Loading -> CircularProgressIndicator()
|
||||||
// The message as Api.kt wrote it, with nothing added: it is already a whole
|
// The message as Api.kt wrote it, with nothing added: it is already a whole
|
||||||
@@ -155,31 +263,32 @@ fun SessionListScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Awaiting-answer first (the point of the screen), then most recently active.
|
LazyColumn(
|
||||||
val ordered =
|
state = rows,
|
||||||
state.value.sortedWith(
|
contentPadding =
|
||||||
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
|
PaddingValues(bottom = covered + buttonHeight + BUTTON_RING * 2),
|
||||||
.thenByDescending { it.lastActivity }
|
) {
|
||||||
)
|
uniqueItems(state.value, key = { it.id }) { session ->
|
||||||
LazyColumn {
|
|
||||||
uniqueItems(ordered, key = { it.id }) { session ->
|
|
||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
error = deleteErrors[session.id],
|
error = deleteErrors[session.id],
|
||||||
deleting = session.id in deleting,
|
deleting = session.id in deleting,
|
||||||
onOpen = { onOpen(session) },
|
picked = session.id in selected,
|
||||||
onLongPress = { confirmingDelete = session },
|
// The handle is a selection-mode control, so it is absent rather
|
||||||
expanded = session.id in expandedSessions,
|
// than disabled outside one: this is not a capability being
|
||||||
subagents = subagentLoads[session.id],
|
// withheld, it is a mode the list is not in.
|
||||||
onToggleSubagents = {
|
reorder = reorder.takeIf { selected.isNotEmpty() },
|
||||||
if (session.id in expandedSessions) {
|
onClick = {
|
||||||
expandedSessions = expandedSessions - session.id
|
// In selection mode a tap is a selection, so the reader is
|
||||||
} else {
|
// never one mis-tap away from opening a session they were only
|
||||||
expandedSessions = expandedSessions + session.id
|
// picking rows for.
|
||||||
loadSubagents(session.id)
|
if (selected.isEmpty()) onOpen(session)
|
||||||
}
|
else
|
||||||
|
selected =
|
||||||
|
if (session.id in selected) selected - session.id
|
||||||
|
else selected + session.id
|
||||||
},
|
},
|
||||||
onOpenSubagent = { subagent -> onOpenSubagent(session, subagent) },
|
onLongPress = { selected = selected + session.id },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
@@ -188,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(
|
FloatingActionButton(
|
||||||
onClick = onSpawn,
|
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)
|
Text("+", style = MaterialTheme.typography.headlineMedium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmingDelete?.let { session ->
|
val targets = confirmingDelete
|
||||||
// Reset per session, so a toggle turned on for one conversation is not still on for the
|
if (targets.isNotEmpty()) {
|
||||||
// next. Off to begin with: see [deleteSession].
|
// Reset per selection, so a toggle turned on for one set of conversations is not still
|
||||||
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
|
// 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(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmingDelete = null },
|
onDismissRequest = { confirmingDelete = emptyList() },
|
||||||
title = { Text("Delete \"${session.title}\"?") },
|
title = {
|
||||||
|
Text(
|
||||||
|
if (targets.size == 1) "Delete \"${targets.first().title}\"?"
|
||||||
|
else "Delete ${targets.size} sessions?"
|
||||||
|
)
|
||||||
|
},
|
||||||
text = {
|
text = {
|
||||||
// Two different acts behind one button, so it says which one this is. What
|
// Two different acts behind one button, so it says which one this is. What
|
||||||
// separates them is whether the *driver* keeps its own record of the conversation
|
// separates them is whether the *driver* keeps its own record of the
|
||||||
// -- the Claude Code CLI does, whether this app spawned the session or imported it;
|
// conversation
|
||||||
|
// -- the coding CLIs do, whether this app spawned the session or imported it;
|
||||||
// echo and llama.cpp do not.
|
// echo and llama.cpp do not.
|
||||||
//
|
//
|
||||||
// This used to branch on `imported`, above a comment asserting that "a session
|
// This used to branch on `imported`, above a comment asserting that "a session
|
||||||
// started here has no copy anywhere". That was 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 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.
|
// sentence needs.
|
||||||
//
|
//
|
||||||
// Neither branch promises a restore. The recoverable one says what is known -- the
|
// Neither branch promises a restore. The recoverable one says what is known,
|
||||||
// driver keeps its own record -- rather than that the file is still there, and it
|
// that the driver keeps its own record, rather than that the file is still there,
|
||||||
// names what goes either way, because this app's transcript holds images, peer
|
// and it names what goes either way, because this app's transcript holds images,
|
||||||
// messages and commands the CLI's own record never had.
|
// 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 {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
when {
|
when {
|
||||||
!session.keepsOwnTranscript ->
|
owned.isEmpty() ->
|
||||||
"Kills the process and deletes the conversation. Nothing else " +
|
"Kills the process and deletes the conversation. Nothing else " +
|
||||||
"keeps a copy, so this can't be undone."
|
"keeps a copy, so this can't be undone."
|
||||||
// The sentence below is the one the toggle makes false, which is why it
|
// The sentence below is the one the toggle makes false, which is
|
||||||
// is written twice rather than appended to: leaving "should still be
|
// why it is written twice rather than appended to: "should still be
|
||||||
// there to import again" on screen beside a switch that removes it is
|
// there to import again", left on screen beside a switch that removes
|
||||||
// the reassurance being read at the moment it stops being true.
|
// it, is the reassurance being read as it stops being true.
|
||||||
alsoDeleteForeign ->
|
alsoDeleteForeign ->
|
||||||
"Kills the process and deletes both copies of the conversation: " +
|
"Kills the process and deletes both copies of the conversation: " +
|
||||||
"this app's, and Claude Code's own transcript on the " +
|
"this app's, and $transcriptOwner's own transcript on the " +
|
||||||
"machine. Nothing keeps another, so this can't be undone."
|
"machine. Nothing keeps another, so this can't be undone."
|
||||||
else ->
|
else ->
|
||||||
"Stops the process and deletes this app's copy of the " +
|
"Stops the process and deletes this app's copy of the " +
|
||||||
"conversation, including any images, peer messages and " +
|
"conversation, including any images, peer messages and " +
|
||||||
"commands recorded only here. Claude Code keeps its own " +
|
"commands recorded only here. $transcriptOwner keeps its own " +
|
||||||
"transcript on the machine, so the conversation itself " +
|
"transcript on the machine" +
|
||||||
"should still be there to import again."
|
(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
|
// Only where there is a second copy to decide about. Absent rather than
|
||||||
// disabled, because this is not a capability being withheld: for echo and
|
// disabled, because this is not a capability being withheld: for echo and
|
||||||
// llama.cpp there is no other transcript, and a switch offering to delete one
|
// llama.cpp there is no other transcript, and a switch offering to delete one
|
||||||
// would be asking about something that does not exist.
|
// would be asking about something that does not exist.
|
||||||
if (session.keepsOwnTranscript) {
|
if (owned.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
// Its own row rather than beside the paragraph: a switch is taller than a
|
// Its own row rather than beside the paragraph: a switch is taller
|
||||||
// line of text and re-centres whatever shares a row with it.
|
// than a line of text and re-centres whatever shares a row with it.
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
"Delete Claude Code's transcript too",
|
"Delete $transcriptOwner's transcript too",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -267,38 +420,8 @@ fun SessionListScreen(
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
confirmingDelete = null
|
confirmingDelete = emptyList()
|
||||||
// Marked here rather than after the request returns: the row has to say
|
deleteChosen(targets, alsoDeleteForeign)
|
||||||
// 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(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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// Coloured by consequence: this takes something away, and does so wherever it
|
// Coloured by consequence: this takes something away, and does so wherever it
|
||||||
@@ -307,12 +430,54 @@ fun SessionListScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
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)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SessionCard(
|
private fun SessionCard(
|
||||||
@@ -327,28 +492,53 @@ private fun SessionCard(
|
|||||||
* about a request that has not been answered yet.
|
* about a request that has not been answered yet.
|
||||||
*/
|
*/
|
||||||
deleting: Boolean,
|
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,
|
onLongPress: () -> Unit,
|
||||||
/** Whether the expander below is open. Collapsed by default; see [SessionListScreen]. */
|
|
||||||
expanded: Boolean,
|
|
||||||
/** What the expander's own fetch answered, or null before it has been asked. */
|
|
||||||
subagents: LoadState<List<SubagentSummary>>?,
|
|
||||||
onToggleSubagents: () -> Unit,
|
|
||||||
onOpenSubagent: (SubagentSummary) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
|
val held = reorder?.held == session.id
|
||||||
BusyItem(label = if (deleting) "deleting" else null) {
|
BusyItem(label = if (deleting) "deleting" else null) {
|
||||||
Card(
|
Card(
|
||||||
// Off while the delete is in flight: a card that still opens a session it is deleting
|
colors =
|
||||||
// is a race the reader can start by tapping. On the card rather than in [BusyItem],
|
if (picked)
|
||||||
// which leaves gestures alone so the list still scrolls.
|
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()
|
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 },
|
||||||
|
) {
|
||||||
|
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(
|
.combinedClickable(
|
||||||
enabled = !deleting,
|
enabled = !deleting,
|
||||||
onClick = onOpen,
|
onClick = onClick,
|
||||||
onLongClick = onLongPress,
|
onLongClick = onLongPress,
|
||||||
)
|
)
|
||||||
|
.padding(CARD_PADDING)
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -359,15 +549,23 @@ private fun SessionCard(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
StatusText(session.status)
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
// Machine, then what runs on it, then what it is set to: the same order and
|
// Machine, then what runs on it, then what it is set to: the same order
|
||||||
// separator as the session screen's header and the usage dialog, so one
|
// and separator as the session screen's header and the usage dialog, so
|
||||||
// pair of facts is not written three ways.
|
// one pair of facts is not written three ways.
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
session.setupName,
|
session.machineName,
|
||||||
session.provider,
|
session.provider,
|
||||||
session.model?.let { modelLabel(it) },
|
session.model?.let { modelLabel(it) },
|
||||||
)
|
)
|
||||||
@@ -391,119 +589,32 @@ private fun SessionCard(
|
|||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Nothing at all for a card with no subagents: a disabled expander here would be
|
|
||||||
// noise on every ordinary session's card. Its own row at the bottom rather than
|
|
||||||
// beside the title or the machine line, so opening it never displaces text that was
|
|
||||||
// already on screen -- see UI_RULES on a control not displacing the text beside it.
|
|
||||||
if (session.subagents > 0) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
// The platform's minimum touch height, not the chevron's own ten or so dp:
|
|
||||||
// at the chevron's height a tap meant for it landed on the first subcard
|
|
||||||
// beneath and opened a subagent instead.
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.heightIn(min = 48.dp)
|
|
||||||
.clickable(enabled = !deleting, onClick = onToggleSubagents)
|
|
||||||
.semantics {
|
|
||||||
contentDescription =
|
|
||||||
if (expanded) "Collapse subagents" else "Expand subagents"
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Chevron(if (expanded) Pointing.Up else Pointing.Down)
|
|
||||||
}
|
}
|
||||||
if (expanded) {
|
// Inside the card, so what it moves is the thing it is drawn on. Nothing is held
|
||||||
Spacer(Modifier.height(4.dp))
|
// open for it outside selection mode: the row is then the row it always was.
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
if (reorder != null) {
|
||||||
when (subagents) {
|
ReorderHandle(
|
||||||
null,
|
reorder,
|
||||||
is LoadState.Loading ->
|
session.id,
|
||||||
CircularProgressIndicator(
|
// Dimmed with the rest of the row while something is happening to it, since
|
||||||
modifier = Modifier.width(20.dp).height(20.dp),
|
// a row on its way out is not one to rearrange -- see [BusyItem], whose
|
||||||
strokeWidth = 2.dp,
|
// appearance this matches rather than repeating its dimming rule.
|
||||||
)
|
// The mark lines up with the text on the other side of the card,
|
||||||
is LoadState.Error ->
|
// which means taking the square it is centred in off the gap: see
|
||||||
// Said here rather than left silent: a fetch that failed and an
|
// [HANDLE_MARGIN].
|
||||||
// expander that simply found nothing must not look the same --
|
Modifier.alpha(if (deleting) 0.4f else 1f)
|
||||||
// see UI_RULES on designing the unknown state first.
|
.padding(end = CARD_PADDING - HANDLE_MARGIN),
|
||||||
Text(
|
|
||||||
subagents.message,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
is LoadState.Loaded ->
|
|
||||||
subagents.value.forEach { subagent ->
|
|
||||||
SubagentCard(
|
|
||||||
subagent,
|
|
||||||
onClick = { onOpenSubagent(subagent) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One subagent, indented inside its session's card -- the way dev-updater draws a project's
|
|
||||||
* components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own
|
|
||||||
* filled one, so the nesting reads as one step rather than as another session.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
|
|
||||||
OutlinedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
|
||||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
|
||||||
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
|
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
subagentStatusLabel(subagent.status),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
relativeTime(subagent.lastActivity),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
|
|
||||||
* rather than a branch inside [StatusText], because a subagent's three states are not that
|
|
||||||
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
|
|
||||||
* and never something of its own to have merely stopped.
|
|
||||||
*/
|
|
||||||
private fun subagentStatusLabel(status: String) =
|
|
||||||
when (status) {
|
|
||||||
"running" -> "running"
|
|
||||||
"exited" -> "finished"
|
|
||||||
else -> "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StatusText(status: String) {
|
fun StatusText(status: String) {
|
||||||
val (label, color) =
|
val label = sessionStatusWord(status)
|
||||||
when (status) {
|
val color = sessionStatusColour(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
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (sessionWorking(status)) {
|
if (sessionWorking(status)) {
|
||||||
// The same colour as the word beside it: the two are one signal, and a spinner in the
|
// 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
@@ -44,8 +44,10 @@ import kotlinx.coroutines.withContext
|
|||||||
* screen of its own until 2026-08-30, which put a page transition and a back stack around two
|
* 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.
|
* controls and hid the thing they act on.
|
||||||
*
|
*
|
||||||
* The model and the permission mode are deliberately still on the session's own bar, because those
|
* The model and the permission mode are on the session's own bar as well, because those are changed
|
||||||
* are changed *while* reading a turn -- "not this model, try that one".
|
* *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
|
* 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
|
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
|
||||||
@@ -61,15 +63,36 @@ fun SessionSettingsDialog(
|
|||||||
title: String,
|
title: String,
|
||||||
onRenamed: (String) -> Unit,
|
onRenamed: (String) -> Unit,
|
||||||
/**
|
/**
|
||||||
* How hard the model thinks, as the session reports it, or null for the CLI's own default.
|
* How hard the model thinks, or null for the CLI's own default.
|
||||||
*
|
*
|
||||||
* Taken from the row this dialog was opened over rather than fetched, because unlike the
|
* Owned by the screen behind this rather than held here, like [title]: this dialog is what
|
||||||
* notification switch there is nothing else that changes it: the level is this app's to set and
|
* changes it, and a level kept only for as long as the dialog is open is the old one again the
|
||||||
* the server does not resolve it into something else.
|
* 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?,
|
effort: String?,
|
||||||
|
onEffortChanged: (String?) -> Unit,
|
||||||
/** Whether a level does anything here; the row is left out entirely where it does not. */
|
/** Whether a level does anything here; the row is left out entirely where it does not. */
|
||||||
takesEffort: Boolean,
|
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
|
* 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.
|
* the Reload row below, which is what would discard it.
|
||||||
@@ -85,7 +108,6 @@ fun SessionSettingsDialog(
|
|||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var name by remember(sessionId) { mutableStateOf(title) }
|
var name by remember(sessionId) { mutableStateOf(title) }
|
||||||
var level by remember(sessionId) { mutableStateOf(effort) }
|
|
||||||
var effortError by remember { mutableStateOf<String?>(null) }
|
var effortError by remember { mutableStateOf<String?>(null) }
|
||||||
var saving by remember { mutableStateOf(false) }
|
var saving by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -165,14 +187,14 @@ fun SessionSettingsDialog(
|
|||||||
* control that stays where it was put after a refusal is stating something untrue.
|
* control that stays where it was put after a refusal is stating something untrue.
|
||||||
*/
|
*/
|
||||||
fun setEffort(chosen: String?) {
|
fun setEffort(chosen: String?) {
|
||||||
val was = level
|
val was = effort
|
||||||
level = chosen
|
onEffortChanged(chosen)
|
||||||
effortError = null
|
effortError = null
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
|
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
level = was
|
onEffortChanged(was)
|
||||||
effortError = e.message
|
effortError = e.message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,6 +432,20 @@ fun SessionSettingsDialog(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
choices.forEach { choice ->
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(choice.label, modifier = Modifier.weight(1f))
|
||||||
|
PickerButton(
|
||||||
|
current = choice.current,
|
||||||
|
options = choice.options,
|
||||||
|
onPick = choice.onPick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Left out rather than disabled, the one place this dialog does that: a disabled
|
// 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
|
// 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.
|
// -- the row would be teaching something false about it.
|
||||||
@@ -421,7 +457,7 @@ fun SessionSettingsDialog(
|
|||||||
) {
|
) {
|
||||||
Text("Thinking", modifier = Modifier.weight(1f))
|
Text("Thinking", modifier = Modifier.weight(1f))
|
||||||
PickerButton(
|
PickerButton(
|
||||||
current = level ?: DEFAULT_EFFORT,
|
current = effort ?: DEFAULT_EFFORT,
|
||||||
// The level the CLI picks for itself is in the list as well as in the
|
// 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
|
// button, so leaving a level is not a one-way trip -- the same
|
||||||
// correction the model picker carries.
|
// correction the model picker carries.
|
||||||
@@ -448,6 +484,23 @@ fun SessionSettingsDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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,
|
||||||
|
warnAboutRestart = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -521,6 +574,19 @@ fun SessionSettingsDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* When the server will next look, as a local time.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
@@ -83,7 +85,7 @@ class UsageFeed(
|
|||||||
return when (val state = snapshots) {
|
return when (val state = snapshots) {
|
||||||
is LoadState.Loading -> SessionUsage.Waiting
|
is LoadState.Loading -> SessionUsage.Waiting
|
||||||
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
||||||
is LoadState.Loaded -> usageFor(state.value, session.setup, provider)
|
is LoadState.Loaded -> usageFor(state.value, session.machine, provider, session.model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,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
|
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
|
||||||
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
|
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
|
||||||
* Taken over however many windows came back rather than the three Claude sends today -- the backend
|
* Taken over however many windows this session's provider returned rather than the three Claude
|
||||||
* passes windows it does not recognise straight through.
|
* 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
|
* Every state that is not a measurement takes the ordinary control colour instead. That is the
|
||||||
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
|
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
|
||||||
@@ -142,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
|
* 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
|
* going, and it was a screen away from the place that decision gets made. It reports on this
|
||||||
@@ -159,16 +161,10 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
|
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
|
||||||
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
|
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
|
||||||
// would sit at a stale figure for hours.
|
// would sit at a stale figure for hours.
|
||||||
var now by remember { mutableStateOf(OffsetDateTime.now()) }
|
val now = rememberUsageNow()
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
while (true) {
|
|
||||||
delay(REFRESH_MS)
|
|
||||||
now = OffsetDateTime.now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
|
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
|
||||||
// a problem about a setup somebody chose, on every screen, forever.
|
// 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
|
// 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
|
// is not a state to report -- and the session that meters nothing is exactly the one this
|
||||||
@@ -189,22 +185,15 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
// Both handled above, before the row exists at all.
|
// Both handled above, before the row exists at all.
|
||||||
SessionUsage.NotMetered,
|
SessionUsage.NotMetered,
|
||||||
SessionUsage.Waiting -> Unit
|
SessionUsage.Waiting -> Unit
|
||||||
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
|
is SessionUsage.Unavailable -> UsageNote("Usage unknown -- ${state.why}")
|
||||||
is SessionUsage.Known -> {
|
is SessionUsage.Known -> {
|
||||||
val window = state.windows.firstOrNull { it.kind == "session" }
|
val window = shortestUsageWindow(state.windows)
|
||||||
if (window == null) {
|
if (window == null) {
|
||||||
UsageNote("5-hour usage unknown -- no five-hour window reported")
|
UsageNote("Usage unknown -- no window duration was reported")
|
||||||
} else {
|
} else {
|
||||||
LinearProgressIndicator(
|
UsageProgressIndicator(window, now, Modifier.weight(1f))
|
||||||
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),
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
fiveHourLabel(window, now),
|
usageWindowLabel(window, now),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
@@ -215,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. */
|
/** Anything this row says instead of drawing a bar, so all of them look the same. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun UsageNote(text: String) {
|
private fun UsageNote(text: String) {
|
||||||
@@ -226,31 +265,40 @@ 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
|
* The percentage on its own does not answer the question it gets asked, which is whether to start
|
||||||
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
|
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
|
||||||
*
|
*
|
||||||
|
* The window's *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
|
* 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.
|
* 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 percent = "${window.percent.toInt()}%"
|
||||||
|
val outOf =
|
||||||
|
window.durationMinutes?.takeIf { it > 0 }?.let { " / ${formatMillis(it * 60_000)}" } ?: ""
|
||||||
return when (val end = windowEnd(window.resetsAt, now)) {
|
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
|
// Between blocks a window can have no reset time, and saying so is a fact about nothing:
|
||||||
// nothing: there is no window to run out. The percentage is the whole answer.
|
// there is no window to run out. The percentage is the whole answer.
|
||||||
WindowEnd.NotRunning -> percent
|
WindowEnd.NotRunning -> percent
|
||||||
WindowEnd.Unreadable -> "$percent · reset time unreadable"
|
WindowEnd.Unreadable -> "$percent · reset time unreadable"
|
||||||
is WindowEnd.Ends ->
|
is WindowEnd.Ends ->
|
||||||
// Under a minute, including past the end: the number would round to "0m left", which
|
// 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.
|
// reads as a measurement rather than as the window having run out.
|
||||||
if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon"
|
if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon"
|
||||||
else "$percent · ${formatSpan(end.until)} left"
|
else "$percent · ${formatSpan(end.until)} left$outOf"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One meter's snapshot, out of every machine's: [setup]'s row for [provider].
|
* 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
|
* 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
|
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
|
||||||
@@ -260,14 +308,60 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
|||||||
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
||||||
* machine having no quota rather than the question going unanswered.
|
* machine having no quota rather than the question going unanswered.
|
||||||
*/
|
*/
|
||||||
fun usageFor(snapshots: List<UsageSnapshot>, setup: String, provider: String): SessionUsage {
|
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
|
// 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.
|
// 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 =
|
val mine =
|
||||||
snapshots.firstOrNull { it.setup == setup && it.provider == provider }
|
usagePoolFor(pools, model)
|
||||||
?: return SessionUsage.NotMetered
|
?: return SessionUsage.Unavailable("couldn't tell which usage pool this session uses")
|
||||||
if (mine.state != "ok") {
|
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)
|
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)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,19 +48,22 @@ fun SpawnScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// What the form is made of, and whether we have it yet. A failure here is not the same as a
|
// 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.
|
// 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
|
// Machine first, then one of its providers. Choosing a machine can invalidate the provider, so
|
||||||
// provider is stored by name and resolved against the current setup rather than held as an
|
// 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.
|
// 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 providerName by remember { mutableStateOf<String?>(null) }
|
||||||
var title by remember { mutableStateOf("") }
|
var title by remember { mutableStateOf("") }
|
||||||
var model by remember { mutableStateOf("") }
|
var model by remember { mutableStateOf("") }
|
||||||
|
var 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("") }
|
var cwd by remember { mutableStateOf("") }
|
||||||
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
|
// Set only after the selected provider reports its own default. An empty value is not sent.
|
||||||
// answering "allow Bash?" dozens of times per task is what this app exists to avoid.
|
var permissionMode by remember { mutableStateOf("") }
|
||||||
var permissionMode by remember { mutableStateOf("auto") }
|
|
||||||
// Null until the server has been asked, and null again if it answers "no level chosen" -- the
|
// 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
|
// 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.
|
// arrives is one you can spawn at without having chosen it.
|
||||||
@@ -70,17 +73,12 @@ fun SpawnScreen(
|
|||||||
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
|
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
|
||||||
// form worth keeping, and that one leaves nothing to fill in.
|
// form worth keeping, and that one leaves nothing to fill in.
|
||||||
var spawnError by remember { mutableStateOf<String?>(null) }
|
var spawnError by remember { mutableStateOf<String?>(null) }
|
||||||
// The models on the *chosen machine*, for a llama provider to choose between. Kept separate
|
// Whatever the chosen provider says it takes, by key. Empty until something is typed: an
|
||||||
// from the setups: a Claude session needs none, so failing to list them must not stop the
|
// absent key means the server's own default, which is what every field's placeholder says.
|
||||||
// screen rendering. Refetched when the machine changes, because a model is a file on one
|
var params by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||||
// machine -- see [fetchSetupModels].
|
|
||||||
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
|
|
||||||
var modelKey by remember { mutableStateOf<String?>(null) }
|
|
||||||
var contextSize by remember { mutableStateOf("") }
|
|
||||||
var temperature by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// Separate from the setups fetch below and deliberately not fatal: failing to learn the
|
// 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"
|
// 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.
|
// and says so rather than the whole form refusing to draw.
|
||||||
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
|
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
|
||||||
@@ -88,9 +86,9 @@ fun SpawnScreen(
|
|||||||
defaultsAsked = true
|
defaultsAsked = true
|
||||||
options =
|
options =
|
||||||
try {
|
try {
|
||||||
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
val fetched = withContext(Dispatchers.IO) { fetchMachines(settings) }
|
||||||
val first = fetched.firstOrNull()
|
val first = fetched.firstOrNull()
|
||||||
setupName = first?.name
|
machineName = first?.name
|
||||||
providerName = first?.providers?.firstOrNull()?.name
|
providerName = first?.providers?.firstOrNull()?.name
|
||||||
LoadState.Loaded(fetched)
|
LoadState.Loaded(fetched)
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
@@ -112,7 +110,7 @@ fun SpawnScreen(
|
|||||||
// Nothing below is fillable until the options are here, and a failure to fetch them leaves
|
// 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
|
// no form worth showing -- so this reports and stops, rather than offering empty pickers
|
||||||
// under an error message.
|
// under an error message.
|
||||||
val setups =
|
val machines =
|
||||||
when (val state = options) {
|
when (val state = options) {
|
||||||
is LoadState.Loading -> {
|
is LoadState.Loading -> {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
@@ -124,62 +122,88 @@ fun SpawnScreen(
|
|||||||
}
|
}
|
||||||
is LoadState.Loaded -> state.value
|
is LoadState.Loaded -> state.value
|
||||||
}
|
}
|
||||||
val setup = setups.firstOrNull { it.name == setupName }
|
val machine = machines.firstOrNull { it.name == machineName }
|
||||||
// Whichever machine is chosen now, asked again when that changes. The old machine's list
|
val current = machine?.providers?.firstOrNull { it.name == providerName }
|
||||||
// is dropped first rather than left on screen: a file name from another machine looks
|
// Coding CLIs take a working directory, model, permission mode and thinking level. Keying
|
||||||
// exactly like one from this one.
|
// the extra fields on the kind rather than the provider name keeps a second installation
|
||||||
LaunchedEffect(setup?.id) {
|
|
||||||
models = emptyList()
|
|
||||||
modelKey = null
|
|
||||||
val id = setup?.id ?: return@LaunchedEffect
|
|
||||||
models =
|
|
||||||
runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } }
|
|
||||||
.getOrDefault(emptyList())
|
|
||||||
}
|
|
||||||
val current = setup?.providers?.firstOrNull { it.name == providerName }
|
|
||||||
// Only the Claude CLI has models, a working directory and permission modes; keying the
|
|
||||||
// extra fields on the kind rather than the provider name keeps a second Claude provider
|
|
||||||
// from needing anything here.
|
// from needing anything here.
|
||||||
val isClaude = current?.kind == "claude_cli"
|
val isClaude = current?.kind == "claude_cli"
|
||||||
|
val isCodex = current?.kind == "codex_cli"
|
||||||
|
val isCodingCli = isClaude || isCodex
|
||||||
val isLlama = current?.kind == "llama_cpp"
|
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.
|
// The machine first, because it decides what can be run at all.
|
||||||
ChipGroup(
|
ChipGroup(
|
||||||
label = "Setup",
|
label = "Machine",
|
||||||
options = setups.map { it.name },
|
options = machines.map { it.name },
|
||||||
selected = setupName,
|
selected = machineName,
|
||||||
onSelect = { name ->
|
onSelect = { name ->
|
||||||
setupName = name
|
machineName = name
|
||||||
// The provider list changes with the machine, so a name carried over from the
|
// 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
|
// previous one would be a selection that isn't in the picker. Take that machine's
|
||||||
// first.
|
// first.
|
||||||
providerName =
|
providerName =
|
||||||
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
|
machines.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
setup?.address?.let {
|
machine?.address?.let {
|
||||||
Text(
|
Text(
|
||||||
it,
|
it,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
// The address belongs to the setup above it, not to the 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.
|
// this they read as one block.
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only what this machine actually has. A setup with none says so 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.
|
// empty row that reads as a failure.
|
||||||
if (setup != null && setup.providers.isEmpty()) {
|
if (machine != null && machine.providers.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"\"${setup.name}\" has no providers configured.",
|
"\"${machine.name}\" has no providers configured.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ChipGroup(
|
ChipGroup(
|
||||||
label = "Provider",
|
label = "Provider",
|
||||||
options = setup?.providers?.map { it.name }.orEmpty(),
|
options = machine?.providers?.map { it.name }.orEmpty(),
|
||||||
selected = providerName,
|
selected = providerName,
|
||||||
onSelect = { providerName = it },
|
onSelect = { providerName = it },
|
||||||
)
|
)
|
||||||
@@ -195,59 +219,59 @@ fun SpawnScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isLlama) {
|
if (offersModels) {
|
||||||
// A llama session names one of the models on the machine it will run on, so the
|
when {
|
||||||
// choice is that list rather than free text -- a name that is not on that machine's
|
providerModelsLoading ->
|
||||||
// disk is a session that cannot start.
|
|
||||||
if (models.isEmpty()) {
|
|
||||||
Text(
|
Text(
|
||||||
"No models on ${setup?.name ?: "this machine"}. The Models screen downloads " +
|
"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.",
|
"to the backend; another machine needs the file put there itself.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
providerModels.isEmpty() ->
|
||||||
ChipGroup(
|
Text(
|
||||||
label = "Model",
|
"This machine reported no selectable models.",
|
||||||
// The file, not the whole key: the repository is the same for every
|
style = MaterialTheme.typography.bodySmall,
|
||||||
// quantisation of a model, so the file name is what tells two of them apart.
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
options = models.map { it.file },
|
|
||||||
selected = models.firstOrNull { it.key == modelKey }?.file,
|
|
||||||
onSelect = { file -> modelKey = models.first { it.file == file }.key },
|
|
||||||
)
|
)
|
||||||
}
|
else -> {
|
||||||
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(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isClaude) {
|
|
||||||
if (current.models.isNotEmpty()) {
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
ChipGroup(
|
ChipGroup(
|
||||||
label = "Model",
|
label = "Model",
|
||||||
options = current.models,
|
// The label, and the id is what is sent: for a llama model those differ,
|
||||||
selected = model.ifEmpty { null },
|
// since it is chosen by path and named by what is inside the file.
|
||||||
onSelect = { chosen -> model = if (model == chosen) "" else chosen },
|
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(8.dp))
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = model,
|
value = model,
|
||||||
onValueChange = { model = it },
|
onValueChange = { model = it },
|
||||||
@@ -256,7 +280,20 @@ fun SpawnScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
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 },
|
||||||
|
warnAboutRestart = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = cwd,
|
value = cwd,
|
||||||
onValueChange = { cwd = it },
|
onValueChange = { cwd = it },
|
||||||
@@ -266,15 +303,21 @@ fun SpawnScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
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(
|
ChipGroup(
|
||||||
label = "Permissions",
|
label = "Permissions",
|
||||||
options = PERMISSION_MODES,
|
options = current.permissionModes,
|
||||||
selected = permissionMode,
|
selected = permissionMode,
|
||||||
onSelect = { permissionMode = it },
|
onSelect = { permissionMode = it },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCodingCli) {
|
||||||
// Says what it does to *later* spawns as well, because it does: the level chosen here
|
// 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
|
// 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.
|
// quietly changed a global would be the same control with the fact left out.
|
||||||
@@ -307,37 +350,25 @@ fun SpawnScreen(
|
|||||||
// an intent about new sessions in general, so a spawn that then
|
// an intent about new sessions in general, so a spawn that then
|
||||||
// fails must not also lose the choice. Non-fatal for the same
|
// fails must not also lose the choice. Non-fatal for the same
|
||||||
// reason the fetch above is -- the session is what was asked for.
|
// reason the fetch above is -- the session is what was asked for.
|
||||||
if (isClaude) {
|
if (isCodingCli) {
|
||||||
runCatching { setDefaultEffort(settings, effort) }
|
runCatching { setDefaultEffort(settings, effort) }
|
||||||
}
|
}
|
||||||
spawnSession(
|
spawnSession(
|
||||||
settings,
|
settings,
|
||||||
// The id, not the label: labels are editable and the server
|
// The id, not the label: labels are editable and the server
|
||||||
// resolves by id. Non-null here, since `chosen` came from
|
// resolves by id. Non-null here, since `chosen` came from
|
||||||
// `setup`'s own provider list.
|
// `machine`'s own provider list.
|
||||||
setup = setup.id,
|
machine = machine.id,
|
||||||
provider = chosen.name,
|
provider = chosen.name,
|
||||||
title = title.trim(),
|
title = title.trim(),
|
||||||
model =
|
model = model.trim().takeIf { offersModels },
|
||||||
if (isLlama) modelKey else model.trim().takeIf { isClaude },
|
cwd = cwd.trim().takeIf { takesCwd },
|
||||||
cwd = cwd.trim().takeIf { isClaude },
|
permissionMode = permissionMode.takeIf { it.isNotEmpty() },
|
||||||
permissionMode = permissionMode.takeIf { isClaude },
|
effort = effort.takeIf { isCodingCli },
|
||||||
effort = effort.takeIf { isClaude },
|
// Already only the keys somebody set: a field left blank
|
||||||
// Sent only when set, so blank means "whatever llama.cpp does
|
// removes its key rather than sending an empty value, so
|
||||||
// by default" rather than a zero.
|
// "blank" reaches the server as "your default".
|
||||||
params =
|
params = params,
|
||||||
buildMap {
|
|
||||||
if (isLlama) {
|
|
||||||
contextSize
|
|
||||||
.trim()
|
|
||||||
.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put("contextSize", it) }
|
|
||||||
temperature
|
|
||||||
.trim()
|
|
||||||
.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put("temperature", it) }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onSpawned(spawned)
|
onSpawned(spawned)
|
||||||
@@ -347,7 +378,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")
|
Text(if (busy) "Spawning..." else "Spawn")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* [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,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onOpenSubagent: (SubagentSummary) -> 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()) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
MarkButton("Close panel", onClose) { Chevron(Pointing.Right) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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++ },
|
||||||
|
)
|
||||||
|
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
|
val awaitingColor: Color
|
||||||
@Composable get() = Mocha.Peach
|
@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. */
|
/** Approaching a limit -- still fine, worth seeing. */
|
||||||
val warningColor: Color
|
val warningColor: Color
|
||||||
@Composable get() = Mocha.Yellow
|
@Composable get() = Mocha.Yellow
|
||||||
@@ -199,6 +213,8 @@ val rawSurface: Color
|
|||||||
*/
|
*/
|
||||||
fun catppuccinSyntax(): SyntaxPalette =
|
fun catppuccinSyntax(): SyntaxPalette =
|
||||||
SyntaxPalette(
|
SyntaxPalette(
|
||||||
|
addition = Mocha.Green,
|
||||||
|
deletion = Mocha.Red,
|
||||||
keyword = Mocha.Mauve,
|
keyword = Mocha.Mauve,
|
||||||
string = Mocha.Green,
|
string = Mocha.Green,
|
||||||
literal = Mocha.Peach,
|
literal = Mocha.Peach,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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.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,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expanded) {
|
||||||
|
// Plain text rather than markdown: this is a model talking to itself, so its
|
||||||
|
// half-finished lists and stray backticks are not markup it meant to write, and
|
||||||
|
// rendering them as such makes the working look like an answer.
|
||||||
|
Text(
|
||||||
|
item.text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "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
|
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.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -51,24 +48,31 @@ data class ToolInput(
|
|||||||
private val SUBJECTS: Map<String, Pair<String, Language?>> =
|
private val SUBJECTS: Map<String, Pair<String, Language?>> =
|
||||||
mapOf(
|
mapOf(
|
||||||
"Bash" to ("command" to Language.SHELL),
|
"Bash" to ("command" to Language.SHELL),
|
||||||
|
"Shell" to ("command" to Language.SHELL),
|
||||||
|
"Patch" to ("diff" to Language.DIFF),
|
||||||
"Read" to ("file_path" to null),
|
"Read" to ("file_path" to null),
|
||||||
"Write" to ("file_path" to null),
|
"Write" to ("file_path" to null),
|
||||||
"Edit" to ("file_path" to null),
|
"Edit" to ("file_path" to null),
|
||||||
"Glob" to ("pattern" to null),
|
"Glob" to ("pattern" to null),
|
||||||
"Grep" to ("pattern" to null),
|
"Grep" to ("pattern" to null),
|
||||||
"WebFetch" to ("url" 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. */
|
/** Fields that are the tool's own prose about itself rather than input to it. */
|
||||||
private val DESCRIPTIONS = listOf("description", "prompt")
|
private val DESCRIPTIONS = listOf("description", "prompt")
|
||||||
|
|
||||||
fun parseToolInput(tool: String, input: String): ToolInput {
|
fun parseToolInput(tool: String, input: String): ToolInput {
|
||||||
|
if (input.trim() == "null") return ToolInput(null, null, null, null, emptyList())
|
||||||
val json =
|
val json =
|
||||||
try {
|
try {
|
||||||
JSONObject(input)
|
JSONObject(input)
|
||||||
} catch (_: org.json.JSONException) {
|
} catch (_: org.json.JSONException) {
|
||||||
// Not an object: older transcripts and some tools send a bare string. It is still the
|
// 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(
|
return ToolInput(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -78,15 +82,20 @@ fun parseToolInput(tool: String, input: String): ToolInput {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val (subjectKey, language) = SUBJECTS[tool] ?: (null to null)
|
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 {
|
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 =
|
val rest =
|
||||||
json
|
json
|
||||||
.keys()
|
.keys()
|
||||||
.asSequence()
|
.asSequence()
|
||||||
|
.filterNot(json::isNull)
|
||||||
.filter { it != subjectKey || subject == null }
|
.filter { it != subjectKey || subject == null }
|
||||||
.filter { it !in DESCRIPTIONS || description == null }
|
.filter { it !in DESCRIPTIONS || description == null }
|
||||||
.filter { it != "timeout" || timeout == null }
|
.filter { it != "timeout" || timeout == null }
|
||||||
@@ -96,6 +105,31 @@ fun parseToolInput(tool: String, input: String): ToolInput {
|
|||||||
return ToolInput(subject, language, description, timeout, rest)
|
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.
|
* 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) {
|
RawBlock(modifier) {
|
||||||
parsed.subject?.let { subject ->
|
parsed.subject?.let { subject ->
|
||||||
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
|
// 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(
|
Text(
|
||||||
// Not cached: a tool's subject is one command line, which lexes in microseconds --
|
// Not cached: a tool's subject is one command line, which lexes in microseconds --
|
||||||
// the cache exists for a fence with two hundred lines in it.
|
// the cache exists for a fence with two hundred lines in it.
|
||||||
@@ -121,7 +156,6 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
softWrap = false,
|
softWrap = false,
|
||||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
parsed.rest.forEach {
|
parsed.rest.forEach {
|
||||||
@@ -130,6 +164,7 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
softWrap = false,
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Shape
|
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.platform.LocalDensity
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
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,
|
* 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
|
* 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
|
abstract val key: Any
|
||||||
|
|
||||||
@@ -75,23 +80,15 @@ sealed class TranscriptRow {
|
|||||||
*/
|
*/
|
||||||
abstract val startSeq: Long
|
abstract val startSeq: Long
|
||||||
|
|
||||||
data class Single(val item: TranscriptItem) : TranscriptRow() {
|
data class Single(val item: TranscriptItem, override val key: Any = item.key) :
|
||||||
override val key: Any
|
TranscriptRow() {
|
||||||
get() = item.key
|
|
||||||
|
|
||||||
override val startSeq: Long
|
override val startSeq: Long
|
||||||
get() = item.seq
|
get() = item.seq
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Two or more calls with nothing between them; drawn as one collapsed card. */
|
/** Two or more calls with nothing between them; drawn as one collapsed card. */
|
||||||
data class Tools(val calls: List<TranscriptItem.ToolRun>) : TranscriptRow() {
|
data class Tools(val calls: List<TranscriptItem.ToolRun>, override val key: String) :
|
||||||
/** The run's own name, which every call in it already carries. */
|
TranscriptRow() {
|
||||||
val id: String
|
|
||||||
get() = calls.first().runId
|
|
||||||
|
|
||||||
override val key: Any
|
|
||||||
get() = id
|
|
||||||
|
|
||||||
override val startSeq: Long
|
override val startSeq: Long
|
||||||
get() = calls.first().seq
|
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,
|
* 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.
|
* 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> =
|
fun groupToolRuns(
|
||||||
DebugStats.timed("grouped tool runs") { groupRuns(items) }
|
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>()
|
val rows = mutableListOf<TranscriptRow>()
|
||||||
var run = mutableListOf<TranscriptItem.ToolRun>()
|
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() {
|
fun flush() {
|
||||||
when (run.size) {
|
val first = run.firstOrNull() ?: return
|
||||||
0 -> {}
|
// The first piece keeps the run's name, which survives a page landing in front of it
|
||||||
1 -> rows += TranscriptRow.Single(run.first())
|
// ([adoptRun]). Later pieces qualify that name with their first call; the suffix is the
|
||||||
else -> rows += TranscriptRow.Tools(run.toList())
|
// 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()
|
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.
|
// Grouped by the run each call says it belongs to, not by adjacency worked out here.
|
||||||
// Adjacency is the same answer most of the time and a worse one at the edges: a call
|
// Adjacency is the same answer most of the time and a worse one at the edges: a call
|
||||||
// arriving next to an existing run, or a page of history arriving in front of one, both
|
// arriving next to an existing run, or a page of history arriving in front of one, both
|
||||||
// change which call is *first*.
|
// change which call is *first*.
|
||||||
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
|
val call = item as? TranscriptItem.ToolRun
|
||||||
run += item
|
if (call == null || call.runId != runId) {
|
||||||
} else {
|
|
||||||
flush()
|
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()
|
flush()
|
||||||
@@ -160,7 +199,15 @@ fun ToolGroup(
|
|||||||
*/
|
*/
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
isToolExpanded: (String) -> Boolean,
|
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,
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||||
image: @Composable (String) -> Unit,
|
image: @Composable (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -175,8 +222,10 @@ fun ToolGroup(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val placed = remember { Placed() }
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
|
.onPlaced { placed.top = it.positionInRoot().y }
|
||||||
.clip(MaterialTheme.shapes.medium)
|
.clip(MaterialTheme.shapes.medium)
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||||
) {
|
) {
|
||||||
@@ -196,13 +245,19 @@ fun ToolGroup(
|
|||||||
verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
|
verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
|
||||||
) {
|
) {
|
||||||
group.calls.forEachIndexed { index, call ->
|
group.calls.forEachIndexed { index, call ->
|
||||||
|
val card = remember(call.id) { Placed() }
|
||||||
ToolCard(
|
ToolCard(
|
||||||
tool = call,
|
tool = call,
|
||||||
expanded = isToolExpanded(call.id),
|
expanded = isToolExpanded(call.id),
|
||||||
onToggle = { onToolToggle(call.id) },
|
onToggle = {
|
||||||
|
onToolToggle(call.id, (card.top - placed.top).toInt(), card.height)
|
||||||
|
},
|
||||||
onAnswer = onAnswer,
|
onAnswer = onAnswer,
|
||||||
image = image,
|
image = image,
|
||||||
shape = connectedShape(index, group.calls.size),
|
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.
|
* 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 = {},
|
image: @Composable (String) -> Unit = {},
|
||||||
/** Square where this card faces another in a group; see [connectedShape]. */
|
/** Square where this card faces another in a group; see [connectedShape]. */
|
||||||
shape: Shape = CardDefaults.shape,
|
shape: Shape = CardDefaults.shape,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
|
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 deciding = tool.asks.any { it.answers.isEmpty() }
|
||||||
val open = expanded || deciding
|
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)) {
|
Column(Modifier.padding(GROUP_INSET_LARGE)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(tool.tool, style = MaterialTheme.typography.titleSmall)
|
Text(name, style = MaterialTheme.typography.titleSmall)
|
||||||
if (open) {
|
if (open) {
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
parsed.timeout?.let {
|
parsed.timeout?.let {
|
||||||
@@ -355,24 +425,26 @@ fun ToolCard(
|
|||||||
if (tool.tool != ASK_USER_QUESTION) {
|
if (tool.tool != ASK_USER_QUESTION) {
|
||||||
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
|
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
|
||||||
}
|
}
|
||||||
if (tool.output.isNotEmpty()) {
|
if (output.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text("Output", style = MaterialTheme.typography.labelSmall)
|
Text("Output", style = MaterialTheme.typography.labelSmall)
|
||||||
// What the tool printed, on the surface everything verbatim gets and in the
|
// What the tool printed, on the surface everything verbatim gets and in the
|
||||||
// face it was written for: this is column-aligned far more often than it is
|
// face it was written for: this is column-aligned far more often than it is
|
||||||
// prose, and a proportional font silently destroys the alignment that carried
|
// 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
|
// 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
|
// 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.
|
// the text, so a card that is open through a scroll parses once.
|
||||||
val palette = remember { ansiPalette() }
|
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)) {
|
RawBlock(Modifier.padding(top = 2.dp)) {
|
||||||
Text(
|
Text(
|
||||||
styled,
|
styled,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
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.
|
* The permission ask on the call it is about.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -63,6 +63,45 @@ sealed class TranscriptItem {
|
|||||||
* inside that reply would step the list under them.
|
* inside that reply would step the list under them.
|
||||||
*/
|
*/
|
||||||
val settled: Boolean = false,
|
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()
|
) : TranscriptItem()
|
||||||
|
|
||||||
data class ToolRun(
|
data class ToolRun(
|
||||||
@@ -143,6 +182,29 @@ sealed class TranscriptItem {
|
|||||||
get() = arrived
|
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
|
* 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
|
* than only shown while it waits, because it explains what follows: a conversation that
|
||||||
@@ -231,7 +293,7 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
|
|||||||
* with the seam wherever the reader happened to have paged.
|
* with the seam wherever the reader happened to have paged.
|
||||||
*/
|
*/
|
||||||
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
|
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
|
||||||
val (older, newer) = healSplitMessage(earlier, later)
|
val (older, newer) = healSplitThinking(healSplitMessage(earlier, later))
|
||||||
val startedEarlier =
|
val startedEarlier =
|
||||||
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
|
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
|
||||||
val endedLater =
|
val endedLater =
|
||||||
@@ -261,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.
|
* 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
|
* [foldEvent] never leaves an *unfinished* assistant message with another behind it inside one
|
||||||
* meeting at a join are always the two halves of one reply, and leaving them apart drew a single
|
* page, so an unsettled one at a join is always the far half of the reply the boundary cut, and
|
||||||
* answer as two with a paragraph break through the middle of a sentence.
|
* 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
|
* 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
|
* half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
|
||||||
@@ -278,6 +341,36 @@ private fun healSplitMessage(
|
|||||||
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
|
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
|
||||||
return earlier to later
|
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))
|
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,14 +457,64 @@ 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
|
// 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.
|
// every frame, and the list would jump for the whole of a streamed answer.
|
||||||
val last = items.lastOrNull()
|
val last = items.lastOrNull()
|
||||||
if (last is TranscriptItem.AssistantMsg) {
|
// Only into a reply that is still arriving. A settled one is a turn that ended, and
|
||||||
// A message growing again is not finished, whatever a status said in between.
|
// text after it belongs to the next turn -- a separate message, drawn as its own row.
|
||||||
items.dropLast(1) + last.copy(text = last.text + event.delta, settled = false)
|
// 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 {
|
} 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 ->
|
is SessionEvent.ToolStart ->
|
||||||
|
// 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 +
|
items +
|
||||||
TranscriptItem.ToolRun(
|
TranscriptItem.ToolRun(
|
||||||
entry.seq,
|
entry.seq,
|
||||||
@@ -382,6 +525,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
"",
|
"",
|
||||||
done = false,
|
done = false,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
|
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
|
||||||
is SessionEvent.ToolEnd ->
|
is SessionEvent.ToolEnd ->
|
||||||
// Created when its start is not here, rather than dropped. A fold that only ever
|
// Created when its start is not here, rather than dropped. A fold that only ever
|
||||||
@@ -456,7 +600,10 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
// nothing it belongs above.
|
// nothing it belongs above.
|
||||||
is SessionEvent.MessageDropped -> items
|
is SessionEvent.MessageDropped -> items
|
||||||
is SessionEvent.Settings -> items
|
is SessionEvent.Settings -> items
|
||||||
|
is SessionEvent.BackgroundTasks -> items
|
||||||
is SessionEvent.Status -> settleReply(items, event.state)
|
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.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
||||||
is SessionEvent.Image ->
|
is SessionEvent.Image ->
|
||||||
// Under the call that produced it when there is one, and a row of its own when there is
|
// Under the call that produced it when there is one, and a row of its own when there is
|
||||||
@@ -475,8 +622,27 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
is SessionEvent.Compacted ->
|
is SessionEvent.Compacted ->
|
||||||
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
|
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
|
||||||
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
|
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
|
||||||
// Screen-level state, not transcript rows -- see SessionScreen.
|
// Said rather than skipped: a line the server could not read is a hole in the conversation,
|
||||||
is SessionEvent.UsageDelta -> items
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -487,9 +653,22 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
*/
|
*/
|
||||||
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
|
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
|
||||||
if (sessionWorking(state)) return items
|
if (sessionWorking(state)) return items
|
||||||
val last = items.lastOrNull() as? TranscriptItem.AssistantMsg ?: return items
|
// A block the turn ended in the middle of is over, however it ended. Left open it spins for
|
||||||
if (last.settled) return items
|
// the rest of the conversation, which says the machine is working when nothing is.
|
||||||
return items.dropLast(1) + last.copy(settled = true)
|
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(
|
private fun updateTool(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.SelectionContainer
|
||||||
import androidx.compose.foundation.text.selection.SelectionState
|
import androidx.compose.foundation.text.selection.SelectionState
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
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.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -49,6 +53,8 @@ fun TranscriptList(
|
|||||||
units: List<TranscriptUnit>,
|
units: List<TranscriptUnit>,
|
||||||
state: LazyListState,
|
state: LazyListState,
|
||||||
moreHistory: Boolean,
|
moreHistory: Boolean,
|
||||||
|
historyError: String?,
|
||||||
|
onRetryHistory: () -> Unit,
|
||||||
selection: SelectionState,
|
selection: SelectionState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
below: @Composable () -> Unit,
|
below: @Composable () -> Unit,
|
||||||
@@ -94,15 +100,29 @@ fun TranscriptList(
|
|||||||
DebugStats.count("unit composed")
|
DebugStats.count("unit composed")
|
||||||
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
|
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
|
||||||
}
|
}
|
||||||
// Standing in for everything not fetched yet. Only here while there is more -- its
|
// Standing in for everything not fetched yet. A failed fetch stays actionable here:
|
||||||
// appearance at the top edge is also roughly when the next page is asked for, so what
|
// when the loaded transcript is too short to scroll, this boundary is the only place
|
||||||
// it reports is a fetch in flight rather than an end reached.
|
// the reader can be given another way to ask.
|
||||||
if (moreHistory) {
|
if (moreHistory) {
|
||||||
item(key = "history", contentType = "history") {
|
item(key = "history", contentType = "history") {
|
||||||
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
|
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
|
||||||
|
if (historyError == null) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
|
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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,25 @@ sealed class TranscriptUnit {
|
|||||||
get() = "u$seq:$ordinal"
|
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]. */
|
/** One memory note of a settled reply; see [MemoryNote]. */
|
||||||
data class Memory(
|
data class Memory(
|
||||||
override val seq: Long,
|
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 {
|
} else {
|
||||||
units += TranscriptUnit.Whole(row, rowGap)
|
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
|
* 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.
|
* 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
|
const val USER_SPLIT_CHARS = 4000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -375,6 +415,7 @@ private val TranscriptUnit?.kind: String
|
|||||||
is TranscriptUnit.PeerBlock -> "peer block"
|
is TranscriptUnit.PeerBlock -> "peer block"
|
||||||
is TranscriptUnit.UserChunk -> "user slice"
|
is TranscriptUnit.UserChunk -> "user slice"
|
||||||
is TranscriptUnit.Memory -> "memory note"
|
is TranscriptUnit.Memory -> "memory note"
|
||||||
|
is TranscriptUnit.ReplyFoot -> "reply footer"
|
||||||
is TranscriptUnit.Whole ->
|
is TranscriptUnit.Whole ->
|
||||||
when (val row = row) {
|
when (val row = row) {
|
||||||
is TranscriptRow.Tools -> "tool group"
|
is TranscriptRow.Tools -> "tool group"
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
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".
|
* own, so the only thing its Back could ever have meant was "put this away".
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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
|
// 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;
|
// 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
|
// 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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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(
|
Text(
|
||||||
"Usage",
|
"Usage",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
@@ -65,10 +71,24 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
// Scrolls rather than being trimmed: a machine can report any number of windows and
|
// 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
|
// a provider can report several billing pools, and a dialog is the one place where
|
||||||
// out of room is silent. `fill = false` so a short read-out keeps a short dialog.
|
// 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())) {
|
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)) {
|
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
||||||
Text("Close")
|
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. */
|
/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
private fun UsageBody(
|
||||||
|
state: LoadState<List<UsageSnapshot>>,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
onSignIn: () -> Unit,
|
||||||
|
) {
|
||||||
Column {
|
Column {
|
||||||
when (val current = state) {
|
when (val current = state) {
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
is LoadState.Loading -> CircularProgressIndicator()
|
||||||
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
||||||
is LoadState.Loaded ->
|
is LoadState.Loaded ->
|
||||||
if (current.value.isEmpty()) {
|
if (current.value.isEmpty()) {
|
||||||
// Not an error and not a blank screen: no machine offers a paid service, 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.
|
// there is genuinely nothing to report and saying so is the answer.
|
||||||
Text(
|
Text(
|
||||||
"No machine here runs anything with usage limits.",
|
"This session's provider has no usage limits.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// No card around each machine. A card is a step up the surface ladder, and
|
// No card around each pool. A card is a step up the surface ladder, and inside
|
||||||
// inside a dialog -- itself a raised surface -- the step barely renders while
|
// a dialog -- itself a raised surface -- the step barely renders while costing
|
||||||
// costing 16dp on every side. What separates one machine from the next is the
|
// 16dp on every side. What separates one pool from the next is the line naming
|
||||||
// line naming it.
|
// it.
|
||||||
current.value.forEachIndexed { index, snapshot ->
|
current.value.forEachIndexed { index, snapshot ->
|
||||||
if (index > 0) {
|
if (index > 0) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
@@ -108,18 +145,18 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
|||||||
// read as a section of their own. Small and quiet, because the numbers
|
// read as a section of their own. Small and quiet, because the numbers
|
||||||
// below are what somebody opened this to see.
|
// below are what somebody opened this to see.
|
||||||
Text(
|
Text(
|
||||||
"${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}",
|
usageSectionTitle(snapshot),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
SnapshotState(snapshot)
|
SnapshotState(snapshot, onSignIn)
|
||||||
snapshot.windows.forEachIndexed { windowIndex, window ->
|
snapshot.windows.forEachIndexed { windowIndex, window ->
|
||||||
// Between the bars, not after the last one: a trailing gap here is what
|
// Between the bars, not after the last one: a trailing gap here is what
|
||||||
// put a band of empty dialog above the Close button.
|
// put a band of empty dialog above the Close button.
|
||||||
if (windowIndex > 0) {
|
if (windowIndex > 0) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
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.
|
* 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
|
* The distinction the old single message could not draw. A machine nobody has logged in on is
|
||||||
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
|
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
|
||||||
* the interface nagging about a decision already made. 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
|
@Composable
|
||||||
private fun SnapshotState(snapshot: UsageSnapshot) {
|
private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) {
|
||||||
when (snapshot.state) {
|
when (snapshot.state) {
|
||||||
"ok" -> {}
|
"ok" -> {}
|
||||||
"notLoggedIn" ->
|
"notLoggedIn",
|
||||||
|
"loginRequired" -> {
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -162,7 +223,7 @@ private fun SnapshotState(snapshot: UsageSnapshot) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WindowBar(window: UsageWindow) {
|
private fun WindowBar(window: UsageWindow, now: OffsetDateTime) {
|
||||||
Column {
|
Column {
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -173,12 +234,8 @@ private fun WindowBar(window: UsageWindow) {
|
|||||||
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
|
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
LinearProgressIndicator(
|
UsageProgressIndicator(window, now, Modifier.fillMaxWidth())
|
||||||
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
resetLine(window, now)?.let {
|
||||||
color = quotaColor(window.percent),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
resetLine(window)?.let {
|
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
it,
|
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
|
* 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.
|
* [WindowEnd], and the session bar words them the same way.
|
||||||
*/
|
*/
|
||||||
private fun resetLine(window: UsageWindow): String? =
|
private fun resetLine(window: UsageWindow, now: OffsetDateTime): String? =
|
||||||
when (val end = windowEnd(window.resetsAt, OffsetDateTime.now())) {
|
when (val end = windowEnd(window.resetsAt, now)) {
|
||||||
WindowEnd.NotRunning -> null
|
WindowEnd.NotRunning -> null
|
||||||
WindowEnd.Unreadable -> "reset time unreadable"
|
WindowEnd.Unreadable -> "reset time unreadable"
|
||||||
is WindowEnd.Ends ->
|
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)
|
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
|
@Test
|
||||||
fun `every language the fence table knows has a scanner`() {
|
fun `every language the fence table knows has a scanner`() {
|
||||||
Language.entries.forEach { spansOf("x", it) }
|
Language.entries.forEach { spansOf("x", it) }
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,7 @@ GLYPHS=(
|
|||||||
U+F0193 # md-content_save
|
U+F0193 # md-content_save
|
||||||
U+F0224 # md-file_outline
|
U+F0224 # md-file_outline
|
||||||
U+F201 # fa-line_chart -- Font Awesome's, asked for by name
|
U+F201 # fa-line_chart -- Font Awesome's, asked for by name
|
||||||
|
U+F035C # md-menu -- the burger, as a row's drag handle
|
||||||
)
|
)
|
||||||
|
|
||||||
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
|
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" "$@"; }
|
api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; }
|
||||||
|
|
||||||
echo "==> Importing"
|
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 \
|
SESSION=$(api -H 'Content-Type: application/json' -X POST \
|
||||||
"https://127.0.0.1:$PORT/sessions" \
|
"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)
|
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
|
||||||
echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events"
|
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
|
# emulator" is three places for the memory check that was missing from all of
|
||||||
# them.
|
# 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.
|
# which can also be sourced directly for one-off commands.
|
||||||
set -eu
|
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
|
spawn) # ./ui-sandbox.sh spawn [title] -- an echo session; prints its id
|
||||||
api /sessions -X POST -H 'content-type: application/json' \
|
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"])'
|
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
@@ -150,7 +150,7 @@ if [ -f "$ROOT/config.ron" ]; then
|
|||||||
/^tokens: \[/ { in_tokens = 1; next }
|
/^tokens: \[/ { in_tokens = 1; next }
|
||||||
# The server writes the list back compactly, with the last entry
|
# The server writes the list back compactly, with the last entry
|
||||||
# and the close on one line: " ),],". Reading the close only
|
# 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.
|
# carried a second copy of that block into the new config.
|
||||||
in_tokens && /\],/ {
|
in_tokens && /\],/ {
|
||||||
sub(/\],.*/, "")
|
sub(/\],.*/, "")
|
||||||
@@ -213,13 +213,24 @@ while [ "$i" -le 8 ]; do
|
|||||||
i=$((i + 1))
|
i=$((i + 1))
|
||||||
done
|
done
|
||||||
|
|
||||||
# A CLI that does nothing, so importing one of these is free and safe.
|
# A CLI that does nothing during a session and offers one deterministic login
|
||||||
# Everything the spawn path cares about is here: it holds the fifo open,
|
# during `auth login`, so both paths are free and safe. Everything the spawn
|
||||||
# records a real pid, writes nothing, and dies on a signal. A real
|
# 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
|
# `claude --resume` against an invented session id would either fail in a
|
||||||
# way that tests nothing or start a turn on somebody's account.
|
# way that tests nothing or start a turn on somebody's account.
|
||||||
cat >"$ROOT/fake-claude" <<FAKE
|
cat >"$ROOT/fake-claude" <<FAKE
|
||||||
#!/bin/sh
|
#!/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
|
# Slow to start, on purpose. An import against this finishes in
|
||||||
# milliseconds otherwise, so every state on the way -- the row marked
|
# milliseconds otherwise, so every state on the way -- the row marked
|
||||||
# "importing", the queue behind it, the event that clears them -- is over
|
# "importing", the queue behind it, the event that clears them -- is over
|
||||||
@@ -346,7 +357,7 @@ tokens: [
|
|||||||
sha256: "$hash",
|
sha256: "$hash",
|
||||||
),
|
),
|
||||||
$salvaged],
|
$salvaged],
|
||||||
setups: [
|
machines: [
|
||||||
(
|
(
|
||||||
id: "local",
|
id: "local",
|
||||||
name: "sandbox",
|
name: "sandbox",
|
||||||
|
|||||||
+401
-61
@@ -28,7 +28,15 @@ pub struct Config {
|
|||||||
/// credential. A list (of one, today) so per-device tokens with individual
|
/// credential. A list (of one, today) so per-device tokens with individual
|
||||||
/// revocation are a config entry later, not a migration.
|
/// revocation are a config entry later, not a migration.
|
||||||
pub tokens: Vec<TokenEntry>,
|
pub tokens: Vec<TokenEntry>,
|
||||||
pub setups: Vec<SetupConfig>,
|
/// `setups` is the persisted spelling before the machine/provider boundary
|
||||||
|
/// was named correctly. Read it once so an update does not discard the
|
||||||
|
/// machines already configured; every subsequent write uses `machines`.
|
||||||
|
#[serde(alias = "setups")]
|
||||||
|
pub machines: Vec<MachineConfig>,
|
||||||
|
/// Every session, in the order the reader has put them in -- what `GET
|
||||||
|
/// /sessions` answers in and what every screen draws. A drag on the phone
|
||||||
|
/// rewrites this (`POST /sessions/order`), and a spawn appends, so a new
|
||||||
|
/// session arrives at the bottom rather than displacing anything.
|
||||||
pub sessions: Vec<SessionConfig>,
|
pub sessions: Vec<SessionConfig>,
|
||||||
/// What a new session's thinking level is when nothing chose one.
|
/// What a new session's thinking level is when nothing chose one.
|
||||||
///
|
///
|
||||||
@@ -52,8 +60,8 @@ pub struct Config {
|
|||||||
/// host and offered the whole cross-product.
|
/// host and offered the whole cross-product.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SetupConfig {
|
pub struct MachineConfig {
|
||||||
/// Stable identifier, minted when the setup is added and never
|
/// Stable identifier, minted when the machine is added and never
|
||||||
/// changed. Sessions reference this rather than the label, so
|
/// changed. Sessions reference this rather than the label, so
|
||||||
/// renaming a machine on the phone does not orphan its sessions --
|
/// renaming a machine on the phone does not orphan its sessions --
|
||||||
/// which is the whole reason the two are separate fields.
|
/// which is the whole reason the two are separate fields.
|
||||||
@@ -62,19 +70,19 @@ pub struct SetupConfig {
|
|||||||
/// How to reach it, absent for this machine.
|
/// How to reach it, absent for this machine.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub ssh: Option<SshConfig>,
|
pub ssh: Option<SshConfig>,
|
||||||
/// What can be spawned here. Names are unique within a setup, and only
|
/// What can be spawned here. Names are unique within a machine, and only
|
||||||
/// within it: two machines may each have a `claude-cli`, which is the point.
|
/// within it: two machines may each have a `claude-cli`, which is the point.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub providers: Vec<ProviderConfig>,
|
pub providers: Vec<ProviderConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SetupConfig {
|
impl MachineConfig {
|
||||||
pub fn provider(&self, name: &str) -> Option<&ProviderConfig> {
|
pub fn provider(&self, name: &str) -> Option<&ProviderConfig> {
|
||||||
self.providers.iter().find(|provider| provider.name == name)
|
self.providers.iter().find(|provider| provider.name == name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One thing a setup can run: which driver, and how to invoke it.
|
/// One thing a machine can run: which driver, and how to invoke it.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProviderConfig {
|
pub struct ProviderConfig {
|
||||||
@@ -88,6 +96,46 @@ pub struct ProviderConfig {
|
|||||||
/// too; this is a shortcut list, not a restriction.
|
/// too; this is a shortcut list, not a restriction.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub models: Vec<String>,
|
pub models: Vec<String>,
|
||||||
|
/// MCP servers whose tools this provider's sessions can use, on top of
|
||||||
|
/// whatever the provider runs itself.
|
||||||
|
///
|
||||||
|
/// On the provider rather than the machine, because it is a statement
|
||||||
|
/// about what a session can do rather than about where it runs -- and
|
||||||
|
/// because only a driver that runs its own agent loop can use one. Today
|
||||||
|
/// that is llama.cpp; the coding CLIs have their own MCP configuration
|
||||||
|
/// and this would be a second, quieter answer to the same question.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub mcp_servers: Vec<McpServerConfig>,
|
||||||
|
/// How each model this provider serves is loaded, by model key -- the
|
||||||
|
/// settings in [`LLAMA_MODEL_PARAMS`].
|
||||||
|
///
|
||||||
|
/// On the model rather than on the session because one loaded model is
|
||||||
|
/// what several sessions talk to: a machine's `llama-server` holds it
|
||||||
|
/// once, and a context size or a layer count that two sessions disagreed
|
||||||
|
/// about would be one of them being ignored. See `session::llama::router`.
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub model_settings: BTreeMap<String, BTreeMap<String, String>>,
|
||||||
|
/// How many models this provider's server keeps loaded at once before it
|
||||||
|
/// evicts the least recently used. `None` is one, which is the right
|
||||||
|
/// answer for a machine with one GPU -- see `router`'s `DEFAULT_MAX_LOADED`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_loaded: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An MCP server reached over HTTP.
|
||||||
|
///
|
||||||
|
/// A URL and nothing else: this backend connects to remote servers rather than
|
||||||
|
/// spawning local ones, so there is no command, no arguments and no
|
||||||
|
/// environment to configure. See `session::llama::mcp` for why that is the
|
||||||
|
/// shape -- in short, it is what llama.cpp's own web UI does, and it keeps the
|
||||||
|
/// tools on the machine with a route out rather than the machine with the GPU.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct McpServerConfig {
|
||||||
|
/// Prefixes every tool this server offers, so two servers with a `search`
|
||||||
|
/// are two tools. Also what a failure to connect is named by.
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderConfig {
|
impl ProviderConfig {
|
||||||
@@ -100,7 +148,7 @@ impl ProviderConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How to reach a setup that isn't this machine, with the system `ssh` client
|
/// How to reach a machine that isn't this machine, with the system `ssh` client
|
||||||
/// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
|
/// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
|
||||||
/// one place to configure connections. A remote session is the identical
|
/// one place to configure connections. A remote session is the identical
|
||||||
/// command with `ssh host …` in front, and nothing downstream knows.
|
/// command with `ssh host …` in front, and nothing downstream knows.
|
||||||
@@ -156,6 +204,8 @@ pub enum DriverKind {
|
|||||||
/// The Claude Code CLI over stream-json. Named for the CLI specifically:
|
/// The Claude Code CLI over stream-json. Named for the CLI specifically:
|
||||||
/// bare "claude" would suggest the credit-billed API, which this is not.
|
/// bare "claude" would suggest the credit-billed API, which this is not.
|
||||||
ClaudeCli,
|
ClaudeCli,
|
||||||
|
/// The Codex CLI's persistent app-server JSONL protocol.
|
||||||
|
CodexCli,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DriverKind {
|
impl DriverKind {
|
||||||
@@ -176,7 +226,7 @@ impl DriverKind {
|
|||||||
pub fn max_image_edge(self) -> Option<u32> {
|
pub fn max_image_edge(self) -> Option<u32> {
|
||||||
match self {
|
match self {
|
||||||
DriverKind::ClaudeCli => Some(1568),
|
DriverKind::ClaudeCli => Some(1568),
|
||||||
DriverKind::Echo | DriverKind::LlamaCpp => None,
|
DriverKind::Echo | DriverKind::LlamaCpp | DriverKind::CodexCli => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +249,7 @@ impl DriverKind {
|
|||||||
pub fn usage_provider(self) -> Option<&'static str> {
|
pub fn usage_provider(self) -> Option<&'static str> {
|
||||||
match self {
|
match self {
|
||||||
Self::ClaudeCli => Some(crate::usage::CLAUDE),
|
Self::ClaudeCli => Some(crate::usage::CLAUDE),
|
||||||
|
Self::CodexCli => Some(crate::usage::CODEX),
|
||||||
Self::Echo => Some(crate::usage::ECHO),
|
Self::Echo => Some(crate::usage::ECHO),
|
||||||
Self::LlamaCpp => None,
|
Self::LlamaCpp => None,
|
||||||
}
|
}
|
||||||
@@ -207,12 +258,13 @@ impl DriverKind {
|
|||||||
/// The executable a provider of this kind runs when it names none.
|
/// The executable a provider of this kind runs when it names none.
|
||||||
///
|
///
|
||||||
/// Here rather than at each spawn site because it is not only the spawn
|
/// Here rather than at each spawn site because it is not only the spawn
|
||||||
/// that runs it: `usage` runs the Claude CLI too, to have it refresh its
|
/// that runs it: `usage` runs provider CLIs too, so a default that
|
||||||
/// own OAuth token, and a default that disagreed with the driver's would
|
/// disagreed with the driver's would ask the wrong binary on a machine
|
||||||
/// ask the wrong binary on a machine with two installs.
|
/// with two installs.
|
||||||
pub fn default_program(self) -> &'static str {
|
pub fn default_program(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::ClaudeCli => "claude",
|
Self::ClaudeCli => "claude",
|
||||||
|
Self::CodexCli => "codex",
|
||||||
Self::LlamaCpp => "llama-server",
|
Self::LlamaCpp => "llama-server",
|
||||||
// Echo is translated in-process; nothing is spawned for it.
|
// Echo is translated in-process; nothing is spawned for it.
|
||||||
Self::Echo => "echo",
|
Self::Echo => "echo",
|
||||||
@@ -222,18 +274,26 @@ impl DriverKind {
|
|||||||
/// Whether the conversation exists outside this app, so that deleting the
|
/// Whether the conversation exists outside this app, so that deleting the
|
||||||
/// session here does not end it.
|
/// session here does not end it.
|
||||||
///
|
///
|
||||||
/// The Claude Code CLI owns its own transcript and is resumable from it
|
/// Claude Code and Codex own their transcripts and can resume them
|
||||||
/// whatever started it, so a session this app spawned is every bit as
|
/// independently of this app. Echo has nothing to keep, and a llama
|
||||||
/// recoverable as one it imported. Echo has nothing to keep, and a llama
|
|
||||||
/// session's conversation is folded out of *this* app's transcript.
|
/// session's conversation is folded out of *this* app's transcript.
|
||||||
///
|
///
|
||||||
/// Asked before warning somebody that a deletion cannot be undone, which is
|
/// Asked before warning somebody that a deletion cannot be undone, which is
|
||||||
/// the one sentence that has to be true: said of a session that can in fact
|
/// the one sentence that has to be true: said of a session that can in fact
|
||||||
/// be brought back, it spends the credibility the warning needs.
|
/// be brought back, it spends the credibility the warning needs.
|
||||||
pub fn keeps_own_transcript(self) -> bool {
|
pub fn keeps_own_transcript(self) -> bool {
|
||||||
|
self.own_transcript_name().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The product whose durable transcript survives an ordinary app delete.
|
||||||
|
/// Reported to the phone because a provider's configured name is not the
|
||||||
|
/// name of its storage, and calling Codex's rollout a Claude transcript is
|
||||||
|
/// especially misleading on an irreversible switch.
|
||||||
|
pub fn own_transcript_name(self) -> Option<&'static str> {
|
||||||
match self {
|
match self {
|
||||||
Self::ClaudeCli => true,
|
Self::ClaudeCli => Some("Claude Code"),
|
||||||
Self::Echo | Self::LlamaCpp => false,
|
Self::CodexCli => Some("Codex"),
|
||||||
|
Self::Echo | Self::LlamaCpp => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,19 +302,254 @@ impl DriverKind {
|
|||||||
///
|
///
|
||||||
/// Reported from here rather than decided on the phone, and asked of the
|
/// Reported from here rather than decided on the phone, and asked of the
|
||||||
/// *kind* rather than branched on: the alternative is the session-type
|
/// *kind* rather than branched on: the alternative is the session-type
|
||||||
/// `if` this app does not have anywhere else. `--effort` is the Claude
|
/// `if` this app does not have anywhere else. Coding CLIs take an effort
|
||||||
/// CLI's; a llama session's sampling is `params`, and echo does not think.
|
/// setting; a llama session's sampling is `params`, and echo does not think.
|
||||||
///
|
///
|
||||||
/// It matters more than a control that would simply do nothing, because
|
/// It matters more than a control that would simply do nothing, because
|
||||||
/// choosing a level stops the process -- so on a session that cannot use
|
/// choosing a level stops the process -- so on a session that cannot use
|
||||||
/// one it is a button whose only effect is the cost.
|
/// one it is a button whose only effect is the cost.
|
||||||
pub fn takes_effort(self) -> bool {
|
pub fn takes_effort(self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::ClaudeCli => true,
|
Self::ClaudeCli | Self::CodexCli => true,
|
||||||
Self::Echo | Self::LlamaCpp => false,
|
Self::Echo | Self::LlamaCpp => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Permission choices the phone can offer for this kind, in display order.
|
||||||
|
/// The driver remains responsible for translating these stable values into
|
||||||
|
/// its CLI's arguments or control protocol.
|
||||||
|
pub fn permission_modes(self) -> &'static [&'static str] {
|
||||||
|
match self {
|
||||||
|
Self::ClaudeCli => &["manual", "acceptEdits", "auto", "bypassPermissions", "plan"],
|
||||||
|
Self::CodexCli => &["workspace-write", "read-only", "danger-full-access"],
|
||||||
|
// Named by the driver that enforces them rather than repeated
|
||||||
|
// here: this list and the one the gate matches on being two
|
||||||
|
// literals is how a mode comes to be offered and then refused.
|
||||||
|
Self::LlamaCpp => crate::session::llama::PERMISSION_MODES,
|
||||||
|
Self::Echo => &[],
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The settings this kind of session takes beyond the shared ones, for
|
||||||
|
/// the phone to offer.
|
||||||
|
///
|
||||||
|
/// Declared rather than drawn: a spawn screen with a field per llama
|
||||||
|
/// setting is a screen that has to be edited every time a driver grows
|
||||||
|
/// one, and this app already has the `params` map to carry them. So the
|
||||||
|
/// server says what a provider takes and the phone renders it, which is
|
||||||
|
/// the same arrangement `permission_modes` uses and for the same reason
|
||||||
|
/// -- the alternative is two lists that disagree, one of them in Kotlin.
|
||||||
|
///
|
||||||
|
/// It is also what keeps these *reachable at all*. Several were hardcoded
|
||||||
|
/// to the values measured on one machine, which is fine as a default and
|
||||||
|
/// wrong as a constant: the next machine has a different GPU and a
|
||||||
|
/// different number of cores, and nobody running this app can edit the
|
||||||
|
/// source.
|
||||||
|
pub fn params(self) -> &'static [ParamSpec] {
|
||||||
|
match self {
|
||||||
|
Self::LlamaCpp => LLAMA_PARAMS,
|
||||||
|
Self::Echo | Self::ClaudeCli | Self::CodexCli => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a *model* this kind serves takes, which is nothing for a kind
|
||||||
|
/// that does not load models of its own. Separate from [`params`] because
|
||||||
|
/// the two have different owners, not different shapes: a session's ride
|
||||||
|
/// on its requests, a model's decide how the machine loads it.
|
||||||
|
pub fn model_params(self) -> &'static [ParamSpec] {
|
||||||
|
match self {
|
||||||
|
Self::LlamaCpp => LLAMA_MODEL_PARAMS,
|
||||||
|
Self::Echo | Self::ClaudeCli | Self::CodexCli => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mode used when a new-session form first selects this kind.
|
||||||
|
pub fn default_permission_mode(self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Self::ClaudeCli => Some("auto"),
|
||||||
|
Self::CodexCli => Some("workspace-write"),
|
||||||
|
Self::LlamaCpp => Some(crate::session::llama::DEFAULT_PERMISSION_MODE),
|
||||||
|
Self::Echo => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One setting a provider takes, and enough about it to draw a control.
|
||||||
|
///
|
||||||
|
/// Deliberately thin: a key, words for a person, and which shape the value
|
||||||
|
/// has. Anything richer -- units, validation, dependencies between settings --
|
||||||
|
/// would be a schema language, and what the phone needs is a text field or a
|
||||||
|
/// row of chips.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ParamSpec {
|
||||||
|
/// The `SessionConfig::params` key this writes.
|
||||||
|
pub key: &'static str,
|
||||||
|
pub label: &'static str,
|
||||||
|
/// What happens when it is not set, in words. Shown where a control shows
|
||||||
|
/// its placeholder, so "blank" always means something specific rather than
|
||||||
|
/// leaving the reader to guess whether it means zero.
|
||||||
|
pub unset: &'static str,
|
||||||
|
/// Flattened, so a spec is one flat object: `kind` beside the rest rather
|
||||||
|
/// than an object of its own with `kind` inside it.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub kind: ParamKind,
|
||||||
|
/// Whether changing it waits for the process to start again.
|
||||||
|
///
|
||||||
|
/// The honest half of offering these live. A sampling setting rides on the
|
||||||
|
/// next request; a server flag was decided when the model was loaded, and
|
||||||
|
/// a control that silently did nothing until some later restart would be
|
||||||
|
/// worse than one that is not there.
|
||||||
|
pub restart: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What shape a [`ParamSpec`]'s value has.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||||
|
pub enum ParamKind {
|
||||||
|
Integer,
|
||||||
|
Decimal,
|
||||||
|
Text,
|
||||||
|
/// A fixed set. **The first option is what leaving it unset means**, and
|
||||||
|
/// choosing it clears the setting rather than storing a value -- so the
|
||||||
|
/// default is a state the picker can return to, and the stored config
|
||||||
|
/// does not fill up with values nobody chose.
|
||||||
|
Choice {
|
||||||
|
options: &'static [&'static str],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a llama.cpp **session** takes: everything that rides on a request.
|
||||||
|
///
|
||||||
|
/// Nothing here waits for a restart, and that is a property of the split
|
||||||
|
/// rather than a coincidence. A machine's `llama-server` holds one copy of a
|
||||||
|
/// model for every session using it, so how that model is *loaded* cannot be
|
||||||
|
/// one session's to decide -- those settings are [`LLAMA_MODEL_PARAMS`],
|
||||||
|
/// against the model on its machine.
|
||||||
|
const LLAMA_PARAMS: &[ParamSpec] = &[
|
||||||
|
ParamSpec {
|
||||||
|
key: "tools",
|
||||||
|
label: "Tools",
|
||||||
|
// Worth a control rather than a constant because of what it costs:
|
||||||
|
// a prompt measured 2,181 tokens with all seven and 698 with none,
|
||||||
|
// every turn, before anything is said. A filter this backend applies
|
||||||
|
// to what the machine's server offers, so unlike the flag it replaced
|
||||||
|
// it takes effect on the next message.
|
||||||
|
unset: "all of them -- or a comma-separated list, or \"none\"",
|
||||||
|
kind: ParamKind::Text,
|
||||||
|
restart: false,
|
||||||
|
},
|
||||||
|
ParamSpec {
|
||||||
|
key: "thinking",
|
||||||
|
label: "Thinking",
|
||||||
|
// Rides on the request, like the sampling settings below it: the level
|
||||||
|
// is a chat-template argument rather than a server flag, so a session
|
||||||
|
// changes how hard it thinks without reloading its model.
|
||||||
|
unset: "however hard the model thinks by default",
|
||||||
|
// Every level any of these templates uses, because which of them a
|
||||||
|
// *particular* model takes is the model's business and only the loaded
|
||||||
|
// one can answer it -- the driver asks it (`thinking_options`) and says
|
||||||
|
// what it takes when a level it cannot is chosen. "off" is the one that
|
||||||
|
// is not a level: it asks the template for no thinking at all.
|
||||||
|
kind: ParamKind::Choice {
|
||||||
|
options: &["auto", "off", "low", "medium", "high", "xhigh", "max"],
|
||||||
|
},
|
||||||
|
restart: false,
|
||||||
|
},
|
||||||
|
ParamSpec {
|
||||||
|
key: "temperature",
|
||||||
|
label: "Temperature",
|
||||||
|
unset: "llama.cpp's default",
|
||||||
|
kind: ParamKind::Decimal,
|
||||||
|
restart: false,
|
||||||
|
},
|
||||||
|
ParamSpec {
|
||||||
|
key: "topP",
|
||||||
|
label: "Top P",
|
||||||
|
unset: "llama.cpp's default",
|
||||||
|
kind: ParamKind::Decimal,
|
||||||
|
restart: false,
|
||||||
|
},
|
||||||
|
ParamSpec {
|
||||||
|
key: "topK",
|
||||||
|
label: "Top K",
|
||||||
|
unset: "llama.cpp's default",
|
||||||
|
kind: ParamKind::Integer,
|
||||||
|
restart: false,
|
||||||
|
},
|
||||||
|
ParamSpec {
|
||||||
|
key: "maxTokens",
|
||||||
|
label: "Reply limit",
|
||||||
|
unset: "no limit",
|
||||||
|
kind: ParamKind::Integer,
|
||||||
|
restart: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// What a llama.cpp **model** takes: everything that decides how it is loaded.
|
||||||
|
///
|
||||||
|
/// Per model on its machine rather than per session, because one loaded copy
|
||||||
|
/// is what every session on that model is talking to. Changing one reloads
|
||||||
|
/// that model -- for everybody using it, which is the honest consequence of
|
||||||
|
/// sharing it and is what the machine's provider view says before saving.
|
||||||
|
///
|
||||||
|
/// Every key here is written into the router's preset file as
|
||||||
|
/// `llama-server`'s own argument name, so adding a setting is a row here and a
|
||||||
|
/// row in `session::llama::router`'s `section`.
|
||||||
|
pub const LLAMA_MODEL_PARAMS: &[ParamSpec] = &[
|
||||||
|
ParamSpec {
|
||||||
|
key: "contextSize",
|
||||||
|
label: "Context size",
|
||||||
|
unset: "the model's own trained context",
|
||||||
|
kind: ParamKind::Integer,
|
||||||
|
// Every one of these does, which is what makes them the model's: see
|
||||||
|
// the doc comment above.
|
||||||
|
restart: true,
|
||||||
|
},
|
||||||
|
ParamSpec {
|
||||||
|
key: "gpuLayers",
|
||||||
|
label: "Layers on the GPU",
|
||||||
|
unset: "as many as fit",
|
||||||
|
kind: ParamKind::Integer,
|
||||||
|
restart: true,
|
||||||
|
},
|
||||||
|
ParamSpec {
|
||||||
|
key: "threads",
|
||||||
|
label: "Threads",
|
||||||
|
unset: "one per core",
|
||||||
|
kind: ParamKind::Integer,
|
||||||
|
restart: true,
|
||||||
|
},
|
||||||
|
ParamSpec {
|
||||||
|
key: "slots",
|
||||||
|
label: "Sessions answered at once",
|
||||||
|
// One, so that a second session's turn waits rather than splitting the
|
||||||
|
// model's cache. Measured 2026-09-19 on the 27B here: 41.5 tok/s
|
||||||
|
// plain, 61.4 with the draft head at one slot, and 28 with the head at
|
||||||
|
// four -- speculating against a split cache is slower than not
|
||||||
|
// speculating at all. Worth raising on a machine where several
|
||||||
|
// sessions really are used together and drafting does not pay.
|
||||||
|
unset: "one -- a second session's turn waits for the first",
|
||||||
|
kind: ParamKind::Integer,
|
||||||
|
restart: true,
|
||||||
|
},
|
||||||
|
ParamSpec {
|
||||||
|
key: "speculative",
|
||||||
|
label: "Speculative decoding",
|
||||||
|
unset: "on, for a model whose file carries a draft head",
|
||||||
|
kind: ParamKind::Choice {
|
||||||
|
options: &["auto", "off"],
|
||||||
|
},
|
||||||
|
restart: true,
|
||||||
|
},
|
||||||
|
ParamSpec {
|
||||||
|
key: "specDraftNMax",
|
||||||
|
label: "Tokens drafted ahead",
|
||||||
|
unset: "llama.cpp's own default",
|
||||||
|
kind: ParamKind::Integer,
|
||||||
|
restart: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -270,12 +565,15 @@ pub struct TokenEntry {
|
|||||||
pub struct SessionConfig {
|
pub struct SessionConfig {
|
||||||
/// Stable identifier; names the session's directory and its routes.
|
/// Stable identifier; names the session's directory and its routes.
|
||||||
pub id: String,
|
pub id: String,
|
||||||
/// Id of the [`SetupConfig`] this session runs on -- the id, not the label,
|
/// Id of the [`MachineConfig`] this session runs on -- the id, not the label,
|
||||||
/// so the machine can be renamed without losing its sessions.
|
/// so the machine can be renamed without losing its sessions.
|
||||||
pub setup: String,
|
/// `setup` is accepted only as the on-disk migration from builds that used
|
||||||
/// Name of the provider within that setup. Both stored by name rather than
|
/// that word for a machine. The API and newly written records say `machine`.
|
||||||
/// resolved, so an edited setup takes effect on the next relaunch; a session
|
#[serde(alias = "setup")]
|
||||||
/// whose setup or provider is gone reports as exited and can still be
|
pub machine: String,
|
||||||
|
/// Name of the provider within that machine. Both stored by name rather than
|
||||||
|
/// resolved, so an edited machine takes effect on the next relaunch; a session
|
||||||
|
/// whose machine or provider is gone reports as exited and can still be
|
||||||
/// deleted.
|
/// deleted.
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -283,20 +581,17 @@ pub struct SessionConfig {
|
|||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub cwd: Option<PathBuf>,
|
pub cwd: Option<PathBuf>,
|
||||||
/// Claude permission mode chosen at spawn. Kept as a string because it is
|
/// Provider permission mode chosen at spawn. Kept as a string because the
|
||||||
/// passed straight to `--permission-mode` rather than interpreted here, so
|
/// driver maps it onto its CLI rather than shared code interpreting it.
|
||||||
/// the CLI stays the one authority on which modes exist.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub permission_mode: Option<String>,
|
pub permission_mode: Option<String>,
|
||||||
/// How hard the model thinks, passed straight to `--effort`. A string for
|
/// How hard the model thinks. A string for the same reason
|
||||||
/// the same reason `permission_mode` is: the CLI owns which levels exist.
|
/// `permission_mode` is: the CLI owns which levels exist.
|
||||||
///
|
///
|
||||||
/// Unlike the model and the mode, there is no control request that changes
|
/// This is settled at launch and `None` means whatever the CLI's own
|
||||||
/// one -- checked against 2.1.258, whose only two are `set_model` and
|
/// default is. That is a state the phone has to be able to *choose*, not
|
||||||
/// `set_permission_mode` -- so this is settled at launch and `None` means
|
/// just start in, which is why it is an option rather than a level with a
|
||||||
/// whatever the CLI's own default is. That is a state the phone has to be
|
/// default written here.
|
||||||
/// able to *choose*, not just start in, which is why it is an option
|
|
||||||
/// rather than a level with a default written here.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub effort: Option<String>,
|
pub effort: Option<String>,
|
||||||
/// Settings the driver interprets, chosen at spawn.
|
/// Settings the driver interprets, chosen at spawn.
|
||||||
@@ -390,17 +685,17 @@ fn not_set(flag: &bool) -> bool {
|
|||||||
!*flag
|
!*flag
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The name of the echo provider, and of the setup this machine gets on first
|
/// The name of the echo provider, and of the local machine created on first
|
||||||
/// run.
|
/// run.
|
||||||
///
|
///
|
||||||
/// Echo is seeded into the config rather than conjured at read time. An
|
/// Echo is seeded into the config rather than conjured at read time. An
|
||||||
/// implicit provider is one a person cannot see in the file or edit from the
|
/// implicit provider is one a person cannot see in the file or edit from the
|
||||||
/// phone; if somebody deletes it, that was a choice.
|
/// phone; if somebody deletes it, that was a choice.
|
||||||
pub const ECHO_PROVIDER: &str = "echo";
|
pub const ECHO_PROVIDER: &str = "echo";
|
||||||
pub const LOCAL_SETUP: &str = "this machine";
|
pub const LOCAL_MACHINE: &str = "this machine";
|
||||||
/// The id of the setup a fresh install seeds. Fixed rather than random so a
|
/// The id of the machine a fresh install seeds. Fixed rather than random so a
|
||||||
/// hand-written config can name it without looking one up.
|
/// hand-written config can name it without looking one up.
|
||||||
pub const LOCAL_SETUP_ID: &str = "local";
|
pub const LOCAL_MACHINE_ID: &str = "local";
|
||||||
|
|
||||||
/// Where `ai-server --enroll-link` leaves a token for the running server to
|
/// Where `ai-server --enroll-link` leaves a token for the running server to
|
||||||
/// adopt: beside the config, since it is config in transit.
|
/// adopt: beside the config, since it is config in transit.
|
||||||
@@ -409,15 +704,15 @@ pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn setup(&self, id: &str) -> Option<&SetupConfig> {
|
pub fn machine(&self, id: &str) -> Option<&MachineConfig> {
|
||||||
self.setups.iter().find(|setup| setup.id == id)
|
self.machines.iter().find(|machine| machine.id == id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A setup by the label a person sees, for messages and for the one place a
|
/// A machine by the label a person sees, for messages and for the one place a
|
||||||
/// name still arrives from outside. Nothing else should look one up this
|
/// name still arrives from outside. Nothing else should look one up this
|
||||||
/// way, since labels are editable and ids are not.
|
/// way, since labels are editable and ids are not.
|
||||||
pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> {
|
pub fn machine_named(&self, name: &str) -> Option<&MachineConfig> {
|
||||||
self.setups.iter().find(|setup| setup.name == name)
|
self.machines.iter().find(|machine| machine.name == name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This machine, offering whatever was found on it.
|
/// This machine, offering whatever was found on it.
|
||||||
@@ -426,10 +721,10 @@ impl Config {
|
|||||||
/// be *discovered*: a hardcoded list is a claim about what is installed, and
|
/// be *discovered*: a hardcoded list is a claim about what is installed, and
|
||||||
/// this one was wrong -- every fresh install asserted a `claude-cli`
|
/// this one was wrong -- every fresh install asserted a `claude-cli`
|
||||||
/// provider whether or not `claude` existed.
|
/// provider whether or not `claude` existed.
|
||||||
pub fn seed(providers: Vec<ProviderConfig>) -> SetupConfig {
|
pub fn seed(providers: Vec<ProviderConfig>) -> MachineConfig {
|
||||||
SetupConfig {
|
MachineConfig {
|
||||||
id: LOCAL_SETUP_ID.to_string(),
|
id: LOCAL_MACHINE_ID.to_string(),
|
||||||
name: LOCAL_SETUP.to_string(),
|
name: LOCAL_MACHINE.to_string(),
|
||||||
ssh: None,
|
ssh: None,
|
||||||
providers,
|
providers,
|
||||||
}
|
}
|
||||||
@@ -444,6 +739,9 @@ impl Config {
|
|||||||
kind: DriverKind::Echo,
|
kind: DriverKind::Echo,
|
||||||
command: None,
|
command: None,
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
|
mcp_servers: Vec::new(),
|
||||||
|
model_settings: BTreeMap::new(),
|
||||||
|
max_loaded: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,11 +802,11 @@ mod tests {
|
|||||||
let path = dir.path().join("config.ron");
|
let path = dir.path().join("config.ron");
|
||||||
|
|
||||||
// A missing file is the ordinary first-run state, not an error.
|
// A missing file is the ordinary first-run state, not an error.
|
||||||
// Nothing is conjured to fill it: the seed setup is written by the
|
// Nothing is conjured to fill it: the seed machine is written by the
|
||||||
// manager, so the file always says what there is.
|
// manager, so the file always says what there is.
|
||||||
let first_run = Config::load(&path).expect("load");
|
let first_run = Config::load(&path).expect("load");
|
||||||
assert!(first_run.tokens.is_empty());
|
assert!(first_run.tokens.is_empty());
|
||||||
assert!(first_run.setups.is_empty());
|
assert!(first_run.machines.is_empty());
|
||||||
assert!(first_run.sessions.is_empty());
|
assert!(first_run.sessions.is_empty());
|
||||||
|
|
||||||
let config = Config {
|
let config = Config {
|
||||||
@@ -516,7 +814,7 @@ mod tests {
|
|||||||
name: "phone".to_string(),
|
name: "phone".to_string(),
|
||||||
sha256: "ab".repeat(32),
|
sha256: "ab".repeat(32),
|
||||||
}],
|
}],
|
||||||
setups: vec![
|
machines: vec![
|
||||||
Config::seed(vec![
|
Config::seed(vec![
|
||||||
Config::echo_provider(),
|
Config::echo_provider(),
|
||||||
ProviderConfig {
|
ProviderConfig {
|
||||||
@@ -524,9 +822,12 @@ mod tests {
|
|||||||
kind: DriverKind::ClaudeCli,
|
kind: DriverKind::ClaudeCli,
|
||||||
command: Some("/usr/bin/claude".to_string()),
|
command: Some("/usr/bin/claude".to_string()),
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
|
mcp_servers: Vec::new(),
|
||||||
|
model_settings: BTreeMap::new(),
|
||||||
|
max_loaded: None,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
SetupConfig {
|
MachineConfig {
|
||||||
id: "vm".to_string(),
|
id: "vm".to_string(),
|
||||||
name: "the vm".to_string(),
|
name: "the vm".to_string(),
|
||||||
ssh: Some(SshConfig {
|
ssh: Some(SshConfig {
|
||||||
@@ -542,13 +843,16 @@ mod tests {
|
|||||||
kind: DriverKind::ClaudeCli,
|
kind: DriverKind::ClaudeCli,
|
||||||
command: None,
|
command: None,
|
||||||
models: vec!["haiku".to_string()],
|
models: vec!["haiku".to_string()],
|
||||||
|
mcp_servers: Vec::new(),
|
||||||
|
model_settings: BTreeMap::new(),
|
||||||
|
max_loaded: None,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
default_effort: Some("low".to_string()),
|
default_effort: Some("low".to_string()),
|
||||||
sessions: vec![SessionConfig {
|
sessions: vec![SessionConfig {
|
||||||
id: "abc123".to_string(),
|
id: "abc123".to_string(),
|
||||||
setup: "vm".to_string(),
|
machine: "vm".to_string(),
|
||||||
provider: "claude-cli".to_string(),
|
provider: "claude-cli".to_string(),
|
||||||
title: "test".to_string(),
|
title: "test".to_string(),
|
||||||
model: None,
|
model: None,
|
||||||
@@ -568,14 +872,14 @@ mod tests {
|
|||||||
|
|
||||||
let loaded = Config::load(&path).expect("reload");
|
let loaded = Config::load(&path).expect("reload");
|
||||||
assert_eq!(loaded.tokens[0].name, "phone");
|
assert_eq!(loaded.tokens[0].name, "phone");
|
||||||
assert_eq!(loaded.sessions[0].setup, "vm");
|
assert_eq!(loaded.sessions[0].machine, "vm");
|
||||||
// The label and the id are separate, and the session holds the id.
|
// The label and the id are separate, and the session holds the id.
|
||||||
assert_eq!(loaded.setup("vm").expect("setup").name, "the vm");
|
assert_eq!(loaded.machine("vm").expect("machine").name, "the vm");
|
||||||
assert_eq!(loaded.sessions[0].provider, "claude-cli");
|
assert_eq!(loaded.sessions[0].provider, "claude-cli");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded
|
loaded
|
||||||
.setup("vm")
|
.machine("vm")
|
||||||
.expect("setup")
|
.expect("machine")
|
||||||
.ssh
|
.ssh
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("ssh")
|
.expect("ssh")
|
||||||
@@ -583,15 +887,21 @@ mod tests {
|
|||||||
Some(2222),
|
Some(2222),
|
||||||
);
|
);
|
||||||
// The same provider name on two machines is the point, not a
|
// The same provider name on two machines is the point, not a
|
||||||
// collision: names are unique within a setup and only within one.
|
// collision: names are unique within a machine and only within one.
|
||||||
assert!(
|
assert!(
|
||||||
loaded
|
loaded
|
||||||
.setup(LOCAL_SETUP_ID)
|
.machine(LOCAL_MACHINE_ID)
|
||||||
.expect("local")
|
.expect("local")
|
||||||
.provider("claude-cli")
|
.provider("claude-cli")
|
||||||
.is_some()
|
.is_some()
|
||||||
);
|
);
|
||||||
assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none());
|
assert!(
|
||||||
|
loaded
|
||||||
|
.machine(LOCAL_MACHINE_ID)
|
||||||
|
.expect("local")
|
||||||
|
.ssh
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
// The house rule both halves of `format` depend on: what is written
|
// The house rule both halves of `format` depend on: what is written
|
||||||
// is the *body* of the struct, with no outer parentheses and
|
// is the *body* of the struct, with no outer parentheses and
|
||||||
@@ -613,6 +923,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_setup_spelling_from_existing_configs_but_writes_machine_spelling() {
|
||||||
|
let old = r#"
|
||||||
|
setups: [(
|
||||||
|
id: "vm",
|
||||||
|
name: "the vm",
|
||||||
|
providers: [],
|
||||||
|
)],
|
||||||
|
sessions: [(
|
||||||
|
id: "abc123",
|
||||||
|
setup: "vm",
|
||||||
|
provider: "echo",
|
||||||
|
title: "old words",
|
||||||
|
created: 1234.5,
|
||||||
|
)],
|
||||||
|
"#;
|
||||||
|
let config: Config = format::parse(old).expect("old setup spelling still loads");
|
||||||
|
assert_eq!(config.machines[0].id, "vm");
|
||||||
|
assert_eq!(config.sessions[0].machine, "vm");
|
||||||
|
|
||||||
|
let written = format::render(&config).expect("render migrated config");
|
||||||
|
assert!(written.contains("machines:"), "{written}");
|
||||||
|
assert!(written.contains("machine: \"vm\""), "{written}");
|
||||||
|
assert!(!written.contains("setups:"), "{written}");
|
||||||
|
assert!(!written.contains("setup: \"vm\""), "{written}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
/// The seed is this machine and nothing more: a name, no ssh, and
|
/// The seed is this machine and nothing more: a name, no ssh, and
|
||||||
/// exactly the providers it was handed.
|
/// exactly the providers it was handed.
|
||||||
@@ -624,7 +961,7 @@ mod tests {
|
|||||||
/// this can check is that the seed does not invent anything.
|
/// this can check is that the seed does not invent anything.
|
||||||
fn the_seed_is_this_machine_and_claims_only_what_it_was_given() {
|
fn the_seed_is_this_machine_and_claims_only_what_it_was_given() {
|
||||||
let seed = Config::seed(vec![Config::echo_provider()]);
|
let seed = Config::seed(vec![Config::echo_provider()]);
|
||||||
assert_eq!(seed.name, LOCAL_SETUP);
|
assert_eq!(seed.name, LOCAL_MACHINE);
|
||||||
assert!(seed.ssh.is_none());
|
assert!(seed.ssh.is_none());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seed.provider(ECHO_PROVIDER).expect("echo").kind,
|
seed.provider(ECHO_PROVIDER).expect("echo").kind,
|
||||||
@@ -643,6 +980,9 @@ mod tests {
|
|||||||
kind: DriverKind::ClaudeCli,
|
kind: DriverKind::ClaudeCli,
|
||||||
command: Some("/usr/bin/claude".to_string()),
|
command: Some("/usr/bin/claude".to_string()),
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
|
mcp_servers: Vec::new(),
|
||||||
|
model_settings: BTreeMap::new(),
|
||||||
|
max_loaded: None,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
//! Reading and changing files on the machine a setup names.
|
//! Reading and changing files on a configured machine.
|
||||||
//!
|
//!
|
||||||
//! Every operation here is one small POSIX shell script handed to `Transport`,
|
//! Every operation here is one small POSIX shell script handed to `Transport`,
|
||||||
//! the way `setups::discover` and `import::list` already ask a machine a
|
//! the way `machines::discover` and `import::list` already ask a machine a
|
||||||
//! question. That is what makes the local and the ssh case one implementation:
|
//! question. That is what makes the local and the ssh case one implementation:
|
||||||
//! a second one written against `std::fs` would be the one that gets tested,
|
//! a second one written against `std::fs` would be the one that gets tested,
|
||||||
//! and the remote half -- the ordering of entries, what a symlink reports, how
|
//! and the remote half -- the ordering of entries, what a symlink reports, how
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
//! Just enough of the GGUF container to read a model's own name out of it.
|
||||||
|
//!
|
||||||
|
//! A `.gguf` file opens with a key/value table, and `general.name` in it is
|
||||||
|
//! what the people who published the model called it -- "Qwen3-0.6B",
|
||||||
|
//! "Qwen3.8-27B GSQ-RCO". Everything else this server knows a model by is
|
||||||
|
//! filesystem trivia: `owner/repo/file.gguf` is where it was downloaded from,
|
||||||
|
//! which is an address rather than a name, and on a phone it is a line of path
|
||||||
|
//! where a word would do.
|
||||||
|
//!
|
||||||
|
//! **Read as far as the answer and no further.** The same table holds the
|
||||||
|
//! tokenizer, which for a modern model is a 150,000-entry string array and
|
||||||
|
//! most of several megabytes; `general.*` is written first by every converter
|
||||||
|
//! in practice, so stopping at the name costs a few kilobytes instead. That is
|
||||||
|
//! what makes this affordable to run over every model in a directory, and what
|
||||||
|
//! lets the remote case work from a bounded prefix of the file rather than the
|
||||||
|
//! whole of it.
|
||||||
|
//!
|
||||||
|
//! Anything unreadable is [`None`] rather than an error, at every level. A
|
||||||
|
//! model with no name, a truncated prefix, a container version this does not
|
||||||
|
//! know and a file that is not GGUF at all are one answer here -- "this file
|
||||||
|
//! does not tell us" -- and the caller has a file name to fall back on. There
|
||||||
|
//! is nothing a reader could do with the distinction.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
/// How many bytes of a model file are worth fetching to look for its name.
|
||||||
|
///
|
||||||
|
/// Only the remote path needs a number: a local read stops when it finds the
|
||||||
|
/// key, but a file on another machine has to be asked for a fixed amount
|
||||||
|
/// before anything can be parsed. Measured 2026-09-19 against the two models
|
||||||
|
/// on this machine, `general.name` ends at byte **130** and **94** -- every
|
||||||
|
/// converter writes `general.*` before the tokenizer arrays that make up the
|
||||||
|
/// rest of the table. 8 KiB is two orders of magnitude of slack for that and
|
||||||
|
/// still makes listing a directory of models one round trip's worth of bytes
|
||||||
|
/// rather than a download, which is what decides the number: this is paid per
|
||||||
|
/// model every time a spawn screen opens.
|
||||||
|
pub const PREFIX_BYTES: u64 = 8 * 1024;
|
||||||
|
|
||||||
|
/// The longest string this will allocate for, so a corrupt length field
|
||||||
|
/// cannot ask for a gigabyte. Longer than any key or `general.*` value.
|
||||||
|
const MAX_STRING: u64 = 64 * 1024;
|
||||||
|
|
||||||
|
/// What `general.name` says, or `None` for every way of not finding out.
|
||||||
|
///
|
||||||
|
/// `read` is consumed only as far as the key: pass a file to read a local
|
||||||
|
/// model, or a cursor over a prefix to read one whose bytes came from
|
||||||
|
/// somewhere else.
|
||||||
|
pub fn name(read: &mut impl Read) -> Option<String> {
|
||||||
|
match find(read, |key| key == "general.name") {
|
||||||
|
Some((STRING, read)) => string(read),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this model carries a multi-token-prediction head.
|
||||||
|
///
|
||||||
|
/// Worth asking because `llama-server` **exits** when told to use one that is
|
||||||
|
/// not there -- `--spec-type draft-mtp` on a plain model is "context type MTP
|
||||||
|
/// requested but model doesn't contain MTP layers" and then a server that
|
||||||
|
/// never comes up. So the flag can only be passed once this has said yes, and
|
||||||
|
/// a `false` here is the same answer as an unreadable file: don't ask for it.
|
||||||
|
///
|
||||||
|
/// Matched on the key's tail rather than its whole name, because the key is
|
||||||
|
/// prefixed with the architecture (`qwen35.nextn_predict_layers`) and the
|
||||||
|
/// architecture is whatever the next model is. The value is not read: a model
|
||||||
|
/// that declares the key at all is one whose tensors carry the head, and the
|
||||||
|
/// two disagreeing is a broken file rather than a state to handle.
|
||||||
|
pub fn has_mtp_head(read: &mut impl Read) -> bool {
|
||||||
|
find(read, |key| key.ends_with(".nextn_predict_layers")).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Steps through the metadata table to the first key `wanted` accepts,
|
||||||
|
/// returning its value's type tag and the reader positioned at the value.
|
||||||
|
fn find<R: Read>(read: &mut R, wanted: impl Fn(&str) -> bool) -> Option<(u32, &mut R)> {
|
||||||
|
let mut magic = [0u8; 4];
|
||||||
|
read.read_exact(&mut magic).ok()?;
|
||||||
|
if &magic != b"GGUF" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let _version = u32s(read)?;
|
||||||
|
let _tensors = u64s(read)?;
|
||||||
|
let count = u64s(read)?;
|
||||||
|
for _ in 0..count {
|
||||||
|
let found = string(read)?;
|
||||||
|
let kind = u32s(read)?;
|
||||||
|
if wanted(&found) {
|
||||||
|
return Some((kind, read));
|
||||||
|
}
|
||||||
|
skip_value(kind, read)?;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// The value type tags, in the container's own numbering. Only the two this
|
||||||
|
// has to act on are named; the rest are widths, and `scalar_width` is where
|
||||||
|
// the numbering is written down once.
|
||||||
|
const STRING: u32 = 8;
|
||||||
|
const ARRAY: u32 = 9;
|
||||||
|
|
||||||
|
/// How many bytes a scalar of this type occupies, or `None` for a type that
|
||||||
|
/// is not a scalar -- which includes a tag this build does not know, since a
|
||||||
|
/// value of unknown length cannot be stepped over.
|
||||||
|
fn scalar_width(kind: u32) -> Option<u64> {
|
||||||
|
match kind {
|
||||||
|
// u8, i8, bool
|
||||||
|
0 | 1 | 7 => Some(1),
|
||||||
|
// u16, i16
|
||||||
|
2 | 3 => Some(2),
|
||||||
|
// u32, i32, f32
|
||||||
|
4..=6 => Some(4),
|
||||||
|
// u64, i64, f64
|
||||||
|
10..=12 => Some(8),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Steps over one value of `kind` without keeping it.
|
||||||
|
///
|
||||||
|
/// Recursive only in the sense that an array's elements are values; GGUF
|
||||||
|
/// arrays do not nest, so the recursion is one level deep by construction.
|
||||||
|
fn skip_value(kind: u32, read: &mut impl Read) -> Option<()> {
|
||||||
|
match kind {
|
||||||
|
STRING => {
|
||||||
|
let len = u64s(read)?;
|
||||||
|
skip(len, read)
|
||||||
|
}
|
||||||
|
ARRAY => {
|
||||||
|
let element = u32s(read)?;
|
||||||
|
let count = u64s(read)?;
|
||||||
|
match scalar_width(element) {
|
||||||
|
// The whole array at once: this is the tokenizer's scores and
|
||||||
|
// token types, and stepping over them one at a time is a
|
||||||
|
// syscall per token.
|
||||||
|
Some(width) => skip(count.checked_mul(width)?, read),
|
||||||
|
None if element == STRING => {
|
||||||
|
for _ in 0..count {
|
||||||
|
let len = u64s(read)?;
|
||||||
|
skip(len, read)?;
|
||||||
|
}
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
// An array of arrays, or of something this build has no width
|
||||||
|
// for: the rest of the table can no longer be located.
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => skip(scalar_width(kind)?, read),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discards `count` bytes, failing if the input ends first.
|
||||||
|
///
|
||||||
|
/// Chunked against a bounded buffer rather than read into a `Vec` of the
|
||||||
|
/// stated size: the sizes here come out of the file, and the file may be a
|
||||||
|
/// truncated prefix or not a GGUF at all.
|
||||||
|
fn skip(count: u64, read: &mut impl Read) -> Option<()> {
|
||||||
|
let mut scratch = [0u8; 8192];
|
||||||
|
let mut left = count;
|
||||||
|
while left > 0 {
|
||||||
|
let want = left.min(scratch.len() as u64) as usize;
|
||||||
|
read.read_exact(&mut scratch[..want]).ok()?;
|
||||||
|
left -= want as u64;
|
||||||
|
}
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string(read: &mut impl Read) -> Option<String> {
|
||||||
|
let len = u64s(read)?;
|
||||||
|
if len > MAX_STRING {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut bytes = vec![0u8; len as usize];
|
||||||
|
read.read_exact(&mut bytes).ok()?;
|
||||||
|
String::from_utf8(bytes).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u32s(read: &mut impl Read) -> Option<u32> {
|
||||||
|
let mut bytes = [0u8; 4];
|
||||||
|
read.read_exact(&mut bytes).ok()?;
|
||||||
|
Some(u32::from_le_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u64s(read: &mut impl Read) -> Option<u64> {
|
||||||
|
let mut bytes = [0u8; 8];
|
||||||
|
read.read_exact(&mut bytes).ok()?;
|
||||||
|
Some(u64::from_le_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Builds a GGUF header holding exactly these keys, so the parser is
|
||||||
|
/// tested against the layout rather than against a fixture nobody here
|
||||||
|
/// can regenerate.
|
||||||
|
fn header(entries: &[(&str, Value)]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::from(*b"GGUF");
|
||||||
|
out.extend(3u32.to_le_bytes());
|
||||||
|
out.extend(0u64.to_le_bytes());
|
||||||
|
out.extend((entries.len() as u64).to_le_bytes());
|
||||||
|
for (key, value) in entries {
|
||||||
|
put_string(&mut out, key);
|
||||||
|
value.write(&mut out);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Value {
|
||||||
|
Str(&'static str),
|
||||||
|
U32(u32),
|
||||||
|
Strings(Vec<&'static str>),
|
||||||
|
Floats(Vec<f32>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Value {
|
||||||
|
fn write(&self, out: &mut Vec<u8>) {
|
||||||
|
match self {
|
||||||
|
Self::Str(text) => {
|
||||||
|
out.extend(STRING.to_le_bytes());
|
||||||
|
put_string(out, text);
|
||||||
|
}
|
||||||
|
Self::U32(number) => {
|
||||||
|
out.extend(4u32.to_le_bytes());
|
||||||
|
out.extend(number.to_le_bytes());
|
||||||
|
}
|
||||||
|
Self::Strings(items) => {
|
||||||
|
out.extend(ARRAY.to_le_bytes());
|
||||||
|
out.extend(STRING.to_le_bytes());
|
||||||
|
out.extend((items.len() as u64).to_le_bytes());
|
||||||
|
for item in items {
|
||||||
|
put_string(out, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::Floats(items) => {
|
||||||
|
out.extend(ARRAY.to_le_bytes());
|
||||||
|
out.extend(6u32.to_le_bytes());
|
||||||
|
out.extend((items.len() as u64).to_le_bytes());
|
||||||
|
for item in items {
|
||||||
|
out.extend(item.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_string(out: &mut Vec<u8>, text: &str) {
|
||||||
|
out.extend((text.len() as u64).to_le_bytes());
|
||||||
|
out.extend(text.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_name_is_read_past_every_other_kind_of_value() {
|
||||||
|
let bytes = header(&[
|
||||||
|
("general.architecture", Value::Str("qwen3")),
|
||||||
|
("general.file_type", Value::U32(7)),
|
||||||
|
("qwen3.attention.head_count", Value::U32(16)),
|
||||||
|
("tokenizer.ggml.scores", Value::Floats(vec![0.5; 64])),
|
||||||
|
("tokenizer.ggml.tokens", Value::Strings(vec!["a", "b", "c"])),
|
||||||
|
("general.name", Value::Str("Qwen3-0.6B")),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
name(&mut bytes.as_slice()),
|
||||||
|
Some("Qwen3-0.6B".to_string()),
|
||||||
|
"every value before the name has to be steppable over",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// The remote case: a prefix is all there is, and running off the end of
|
||||||
|
/// it is "we don't know" rather than a failure worth reporting. The
|
||||||
|
/// caller has the file name.
|
||||||
|
fn a_truncated_file_has_no_name_rather_than_failing() {
|
||||||
|
let bytes = header(&[
|
||||||
|
("tokenizer.ggml.tokens", Value::Strings(vec!["a", "b", "c"])),
|
||||||
|
("general.name", Value::Str("Qwen3-0.6B")),
|
||||||
|
]);
|
||||||
|
for cut in [4, 12, 24, bytes.len() - 4] {
|
||||||
|
assert_eq!(name(&mut &bytes[..cut]), None, "cut at {cut}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_that_is_not_gguf_has_no_name() {
|
||||||
|
assert_eq!(name(&mut b"not a model at all".as_slice()), None);
|
||||||
|
assert_eq!(name(&mut b"".as_slice()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// A name that is not a string is not a name. The alternative is
|
||||||
|
/// rendering a number as one, which reads as a model called "7".
|
||||||
|
fn a_name_of_the_wrong_type_is_not_read() {
|
||||||
|
let bytes = header(&[("general.name", Value::U32(7))]);
|
||||||
|
assert_eq!(name(&mut bytes.as_slice()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// The head is found by the tail of the key, because the whole key is
|
||||||
|
/// prefixed with whatever architecture the model is.
|
||||||
|
fn an_mtp_head_is_found_whatever_the_architecture_is_called() {
|
||||||
|
let with = header(&[
|
||||||
|
("general.architecture", Value::Str("qwen35")),
|
||||||
|
("qwen35.block_count", Value::U32(64)),
|
||||||
|
("qwen35.nextn_predict_layers", Value::U32(1)),
|
||||||
|
]);
|
||||||
|
assert!(has_mtp_head(&mut with.as_slice()));
|
||||||
|
|
||||||
|
let without = header(&[
|
||||||
|
("general.architecture", Value::Str("qwen3")),
|
||||||
|
("qwen3.block_count", Value::U32(28)),
|
||||||
|
]);
|
||||||
|
assert!(!has_mtp_head(&mut without.as_slice()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// A prefix that stops short says no, and that is the direction it has to
|
||||||
|
/// fail in: `--spec-type draft-mtp` on a model with no head is a server
|
||||||
|
/// that exits, so "we could not tell" and "it has none" both mean don't
|
||||||
|
/// ask for it.
|
||||||
|
fn a_truncated_file_reports_no_mtp_head() {
|
||||||
|
let bytes = header(&[("qwen35.nextn_predict_layers", Value::U32(1))]);
|
||||||
|
assert!(!has_mtp_head(&mut &bytes[..12]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// The real thing, when this machine happens to have one. Skipped rather
|
||||||
|
/// than failed where it does not: the models directory is not part of the
|
||||||
|
/// checkout, and a test that needs gigabytes to run is one nobody runs.
|
||||||
|
fn a_real_model_on_this_machine_reads_back_its_name() {
|
||||||
|
let Some(home) = std::env::var_os("HOME") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let dir = std::path::Path::new(&home).join(".local/share/ai-app/models");
|
||||||
|
let mut found = Vec::new();
|
||||||
|
collect_gguf(&dir, &mut found);
|
||||||
|
for path in found {
|
||||||
|
let mut file = std::fs::File::open(&path).expect("open");
|
||||||
|
let read = name(&mut file);
|
||||||
|
assert!(
|
||||||
|
read.is_some_and(|name| !name.trim().is_empty()),
|
||||||
|
"{} has a name in it and this did not read one",
|
||||||
|
path.display(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_gguf(dir: &std::path::Path, found: &mut Vec<std::path::PathBuf>) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_gguf(&path, found);
|
||||||
|
} else if path.extension().is_some_and(|e| e == "gguf") {
|
||||||
|
found.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
//! itself which of the known programs it has, and the answer becomes its
|
//! itself which of the known programs it has, and the answer becomes its
|
||||||
//! providers. That is a security property, not a convenience: **no route accepts
|
//! providers. That is a security property, not a convenience: **no route accepts
|
||||||
//! a command from the phone.** If it did, the enrolled token could introduce
|
//! a command from the phone.** If it did, the enrolled token could introduce
|
||||||
//! arbitrary programs to run on every machine a setup names.
|
//! arbitrary programs to run on every machine already configured here.
|
||||||
//!
|
//!
|
||||||
//! It is also the better interface: nobody wants to type an absolute path on a
|
//! It is also the better interface: nobody wants to type an absolute path on a
|
||||||
//! phone keyboard, and a machine that has moved its binaries answers correctly
|
//! phone keyboard, and a machine that has moved its binaries answers correctly
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
//! is editing `config.ron` on the backend, which is exactly the authority the
|
//! is editing `config.ron` on the backend, which is exactly the authority the
|
||||||
//! phone is not being given.
|
//! phone is not being given.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::config::{DriverKind, ProviderConfig};
|
use crate::config::{DriverKind, ProviderConfig};
|
||||||
use crate::session::transport::{Launch, Transport};
|
use crate::session::transport::{Launch, Transport};
|
||||||
@@ -25,8 +27,9 @@ use crate::session::transport::{Launch, Transport};
|
|||||||
/// session stores.
|
/// session stores.
|
||||||
const PROBES: &[(&str, &str, DriverKind)] = &[
|
const PROBES: &[(&str, &str, DriverKind)] = &[
|
||||||
("claude-cli", "claude", DriverKind::ClaudeCli),
|
("claude-cli", "claude", DriverKind::ClaudeCli),
|
||||||
|
("codex-cli", "codex", DriverKind::CodexCli),
|
||||||
// Named for the program rather than for where it runs: it runs
|
// Named for the program rather than for where it runs: it runs
|
||||||
// wherever the setup is, and "local" was true only while a llama
|
// wherever the machine is, and "local" was true only while a llama
|
||||||
// session could not be spawned on another machine.
|
// session could not be spawned on another machine.
|
||||||
("llama-cpp", "llama-server", DriverKind::LlamaCpp),
|
("llama-cpp", "llama-server", DriverKind::LlamaCpp),
|
||||||
];
|
];
|
||||||
@@ -55,12 +58,7 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
|||||||
// and nowhere else. Offering it on a remote machine would be a choice that
|
// and nowhere else. Offering it on a remote machine would be a choice that
|
||||||
// changes nothing.
|
// changes nothing.
|
||||||
if matches!(transport, Transport::Here) {
|
if matches!(transport, Transport::Here) {
|
||||||
providers.push(ProviderConfig {
|
providers.push(crate::config::Config::echo_provider());
|
||||||
name: crate::config::ECHO_PROVIDER.to_string(),
|
|
||||||
kind: DriverKind::Echo,
|
|
||||||
command: None,
|
|
||||||
models: Vec::new(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
for (name, binary, kind) in PROBES {
|
for (name, binary, kind) in PROBES {
|
||||||
let path = found
|
let path = found
|
||||||
@@ -81,11 +79,139 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
|||||||
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
|
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
},
|
},
|
||||||
|
mcp_servers: mcp_defaults(*kind),
|
||||||
|
// What a probe cannot know: how this machine's models are loaded
|
||||||
|
// is configured after the fact, and a re-probe keeps it -- see
|
||||||
|
// `SessionManager::update_machine`.
|
||||||
|
model_settings: Default::default(),
|
||||||
|
max_loaded: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(providers)
|
Ok(providers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One model a picker can offer, and what to call it there.
|
||||||
|
///
|
||||||
|
/// Two fields rather than one string because for one provider they differ:
|
||||||
|
/// a llama.cpp model is chosen by the path it lives at and read as the name
|
||||||
|
/// its own metadata gives it. Every other provider's id is already the name,
|
||||||
|
/// and says so by repeating it -- which is what keeps the picker free of a
|
||||||
|
/// branch on the session kind.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OfferedModel {
|
||||||
|
/// What a spawn or a model change is given. Opaque to the phone.
|
||||||
|
pub id: String,
|
||||||
|
/// What a person reads on the chip.
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OfferedModel {
|
||||||
|
/// A model whose id is its own name, which is every provider but llama.
|
||||||
|
fn plain(id: impl Into<String>) -> Self {
|
||||||
|
let id = id.into();
|
||||||
|
Self {
|
||||||
|
label: id.clone(),
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The MCP servers a newly discovered provider of this kind starts with.
|
||||||
|
///
|
||||||
|
/// A default rather than something to be typed in: a llama session with no web
|
||||||
|
/// search is the state somebody would then have to find out how to leave, and
|
||||||
|
/// Exa is what llama.cpp's own web UI offers under the same name. It is an
|
||||||
|
/// ordinary config entry once written, so removing it is deleting a line.
|
||||||
|
///
|
||||||
|
/// Only llama.cpp, because only a driver that runs its own agent loop can use
|
||||||
|
/// one -- the coding CLIs configure MCP themselves and a second answer here
|
||||||
|
/// would quietly disagree with theirs.
|
||||||
|
fn mcp_defaults(kind: DriverKind) -> Vec<crate::config::McpServerConfig> {
|
||||||
|
match kind {
|
||||||
|
DriverKind::LlamaCpp => vec![crate::config::McpServerConfig {
|
||||||
|
name: "exa".to_string(),
|
||||||
|
url: crate::session::llama::EXA_MCP_URL.to_string(),
|
||||||
|
}],
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Models the selected provider currently offers on this machine.
|
||||||
|
///
|
||||||
|
/// Codex's catalog is account- and CLI-version-specific, so it is asked at the
|
||||||
|
/// moment the picker opens rather than copied into `config.ron`. A llama.cpp
|
||||||
|
/// provider offers the GGUFs on the machine it runs on, through this same call
|
||||||
|
/// -- there was a second route answering that alone, and it went when this one
|
||||||
|
/// learned to, because a picker offering a model the spawn screen does not, or
|
||||||
|
/// naming it differently, is two answers to one question. Other providers
|
||||||
|
/// retain the shortcut list discovery stored for them.
|
||||||
|
pub async fn provider_models(
|
||||||
|
transport: &Transport,
|
||||||
|
provider: &ProviderConfig,
|
||||||
|
models_dir: &std::path::Path,
|
||||||
|
) -> Result<Vec<OfferedModel>> {
|
||||||
|
if provider.kind == DriverKind::LlamaCpp {
|
||||||
|
let dir = crate::models::dir_on(transport, models_dir);
|
||||||
|
let found = crate::models::on_machine(transport, &dir).await?;
|
||||||
|
let labels = crate::models::labels(&found);
|
||||||
|
return Ok(found
|
||||||
|
.into_iter()
|
||||||
|
.zip(labels)
|
||||||
|
.map(|(model, label)| OfferedModel {
|
||||||
|
id: model.key,
|
||||||
|
label,
|
||||||
|
})
|
||||||
|
.collect());
|
||||||
|
}
|
||||||
|
if provider.kind != DriverKind::CodexCli {
|
||||||
|
return Ok(provider.models.iter().map(OfferedModel::plain).collect());
|
||||||
|
}
|
||||||
|
let transport = transport.clone();
|
||||||
|
let program = provider.program().to_string();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let launch = Launch::new(program, vec!["app-server".into(), "--stdio".into()], None);
|
||||||
|
let initial = json!({
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {"clientInfo": {"name": "ai-app", "title": "AI Sessions", "version": env!("CARGO_PKG_VERSION")}}
|
||||||
|
});
|
||||||
|
let requests = [
|
||||||
|
json!({"method": "initialized"}),
|
||||||
|
json!({"id": 2, "method": "model/list", "params": {"includeHidden": false, "limit": 100}}),
|
||||||
|
];
|
||||||
|
let answer = transport.request_json_blocking(&launch, &initial, &requests, 2)?;
|
||||||
|
Ok(parse_codex_models(&answer)?
|
||||||
|
.into_iter()
|
||||||
|
.map(OfferedModel::plain)
|
||||||
|
.collect())
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_codex_models(answer: &Value) -> Result<Vec<String>> {
|
||||||
|
if let Some(message) = answer.pointer("/error/message").and_then(Value::as_str) {
|
||||||
|
anyhow::bail!("Codex could not list models: {message}");
|
||||||
|
}
|
||||||
|
let entries = answer
|
||||||
|
.pointer("/result/data")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.context("Codex returned no model catalog")?;
|
||||||
|
let mut models = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
!entry
|
||||||
|
.get("hidden")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.filter_map(|entry| entry.get("model").and_then(Value::as_str))
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
models.dedup();
|
||||||
|
Ok(models)
|
||||||
|
}
|
||||||
|
|
||||||
/// Adds what to do to failures whose own wording does not say.
|
/// Adds what to do to failures whose own wording does not say.
|
||||||
///
|
///
|
||||||
/// ssh's messages are written for someone at a terminal on the backend, which is
|
/// ssh's messages are written for someone at a terminal on the backend, which is
|
||||||
@@ -115,7 +241,7 @@ fn explain(err: anyhow::Error) -> anyhow::Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A short, stable, filename-safe id derived from a label. Derived once when a
|
/// A short, stable, filename-safe id derived from a label. Derived once when a
|
||||||
/// setup is added and then fixed, so the label stays editable. Collisions are
|
/// machine is added and then fixed, so the label stays editable. Collisions are
|
||||||
/// resolved by the caller, which is the only place that knows what exists.
|
/// resolved by the caller, which is the only place that knows what exists.
|
||||||
pub fn id_from(label: &str) -> String {
|
pub fn id_from(label: &str) -> String {
|
||||||
let slug: String = label
|
let slug: String = label
|
||||||
@@ -177,7 +303,7 @@ pub fn shorten_home(path: &str) -> String {
|
|||||||
///
|
///
|
||||||
/// The common case of [`Transport::capture_with_input`]: nothing on stdin, a
|
/// The common case of [`Transport::capture_with_input`]: nothing on stdin, a
|
||||||
/// failure reported as the machine's own words (ssh's "Permission denied" is the
|
/// failure reported as the machine's own words (ssh's "Permission denied" is the
|
||||||
/// useful half of why a setup cannot be reached), and the output read as text
|
/// useful half of why a machine cannot be reached), and the output read as text
|
||||||
/// because every caller here is asking a question whose answer is words.
|
/// because every caller here is asking a question whose answer is words.
|
||||||
impl Transport {
|
impl Transport {
|
||||||
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
||||||
@@ -212,4 +338,26 @@ mod tests {
|
|||||||
assert_eq!(shorten_home(&sibling), sibling);
|
assert_eq!(shorten_home(&sibling), sibling);
|
||||||
assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts");
|
assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_models_are_the_selectable_non_hidden_catalog_entries() {
|
||||||
|
let answer = json!({"result": {"data": [
|
||||||
|
{"model": "gpt-small", "hidden": false},
|
||||||
|
{"model": "gpt-hidden", "hidden": true},
|
||||||
|
{"model": "gpt-large", "hidden": false}
|
||||||
|
]}});
|
||||||
|
assert_eq!(
|
||||||
|
parse_codex_models(&answer).unwrap(),
|
||||||
|
vec!["gpt-small".to_string(), "gpt-large".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_codex_catalog_is_not_reported_as_an_empty_one() {
|
||||||
|
let answer = json!({"error": {"message": "login required"}});
|
||||||
|
assert_eq!(
|
||||||
|
parse_codex_models(&answer).unwrap_err().to_string(),
|
||||||
|
"Codex could not list models: login required"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+25
-16
@@ -14,12 +14,14 @@
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
mod files;
|
mod files;
|
||||||
|
mod gguf;
|
||||||
|
mod machines;
|
||||||
mod media;
|
mod media;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod provider_auth;
|
||||||
mod resume;
|
mod resume;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod session;
|
mod session;
|
||||||
mod setups;
|
|
||||||
mod ssh;
|
mod ssh;
|
||||||
mod usage;
|
mod usage;
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ use session::SessionManager;
|
|||||||
|
|
||||||
const DEFAULT_PORT: u16 = 8443;
|
const DEFAULT_PORT: u16 = 8443;
|
||||||
|
|
||||||
/// Serves AI coding sessions (Claude Code, llama.cpp) to the phone app.
|
/// Serves AI coding sessions (Codex, Claude Code, llama.cpp) to the phone app.
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
struct Args {
|
struct Args {
|
||||||
/// TLS port for the whole API surface.
|
/// TLS port for the whole API surface.
|
||||||
@@ -143,7 +145,7 @@ async fn main() -> Result<()> {
|
|||||||
let config_path = args
|
let config_path = args
|
||||||
.config
|
.config
|
||||||
.unwrap_or_else(|| config_home("ai-app").join("config.ron"));
|
.unwrap_or_else(|| config_home("ai-app").join("config.ron"));
|
||||||
// Before the manager exists, on purpose: constructing it and seeding setups
|
// Before the manager exists, on purpose: constructing it and seeding machines
|
||||||
// touches sessions and subprocesses this invocation has no business with
|
// touches sessions and subprocesses this invocation has no business with
|
||||||
// while another instance is serving. Only the hash reaches disk, in the
|
// while another instance is serving. Only the hash reaches disk, in the
|
||||||
// spool `auth.rs` reads; the link goes to stdout alone.
|
// spool `auth.rs` reads; the link goes to stdout alone.
|
||||||
@@ -173,7 +175,6 @@ async fn main() -> Result<()> {
|
|||||||
let models_dir = args
|
let models_dir = args
|
||||||
.models_dir
|
.models_dir
|
||||||
.unwrap_or_else(|| data_home("ai-app").join("models"));
|
.unwrap_or_else(|| data_home("ai-app").join("models"));
|
||||||
let models = Arc::new(models::ModelStore::new(models_dir.clone()));
|
|
||||||
let manager = Arc::new(
|
let manager = Arc::new(
|
||||||
SessionManager::new(config_path.clone(), data_dir, models_dir.clone())
|
SessionManager::new(config_path.clone(), data_dir, models_dir.clone())
|
||||||
.with_context(|| format!("failed to load {}", config_path.display()))?
|
.with_context(|| format!("failed to load {}", config_path.display()))?
|
||||||
@@ -188,19 +189,19 @@ async fn main() -> Result<()> {
|
|||||||
// After construction rather than inside it: seeding asks this machine what
|
// After construction rather than inside it: seeding asks this machine what
|
||||||
// it has, and a constructor that quietly runs a subprocess is a surprise to
|
// it has, and a constructor that quietly runs a subprocess is a surprise to
|
||||||
// every caller including the tests.
|
// every caller including the tests.
|
||||||
manager.seed_setup().await?;
|
manager.seed_machine().await?;
|
||||||
|
|
||||||
tracing::info!("config: {}", config_path.display());
|
tracing::info!("config: {}", config_path.display());
|
||||||
tracing::info!("models: {}", models_dir.display());
|
tracing::info!("models: {}", models_dir.display());
|
||||||
for setup in manager.setups() {
|
for machine in manager.machines() {
|
||||||
match &setup.ssh {
|
match &machine.ssh {
|
||||||
Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address),
|
Some(ssh) => tracing::info!(" machine \"{}\" -> {}", machine.name, ssh.address),
|
||||||
// No parenthetical naming the local machine: the default setup is
|
// No parenthetical naming the local machine: the default machine is
|
||||||
// *called* "this machine", and the line read "setup this machine
|
// *called* "this machine", so repeating a local qualifier read
|
||||||
// (this machine)".
|
// like a stutter.
|
||||||
None => tracing::info!(" setup \"{}\" runs here", setup.name),
|
None => tracing::info!(" machine \"{}\" runs here", machine.name),
|
||||||
}
|
}
|
||||||
for provider in &setup.providers {
|
for provider in &machine.providers {
|
||||||
tracing::info!(" provider {} ({:?})", provider.name, provider.kind);
|
tracing::info!(" provider {} ({:?})", provider.name, provider.kind);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,11 +264,12 @@ async fn main() -> Result<()> {
|
|||||||
.context("failed to load TLS cert/key")?;
|
.context("failed to load TLS cert/key")?;
|
||||||
|
|
||||||
// No providers listed here any more: which machines can be asked, and about
|
// No providers listed here any more: which machines can be asked, and about
|
||||||
// what, comes from the setups at the moment the screen is opened -- so a
|
// what, comes from the machines at the moment the screen is opened -- so a
|
||||||
// machine added from the phone reports its limits without a restart.
|
// machine added from the phone reports its limits without a restart.
|
||||||
// The fixture is the manager's, because that is where the `/usage` command
|
// The fixture is the manager's, because that is where the `/usage` command
|
||||||
// that sets it is typed; the monitor is what serves it.
|
// that sets it is typed; the monitor is what serves it.
|
||||||
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture()));
|
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture()));
|
||||||
|
let provider_logins = Arc::new(provider_auth::LoginManager::new(Arc::clone(&monitor)));
|
||||||
|
|
||||||
// The one thing in here that acts without a request behind it: a session
|
// The one thing in here that acts without a request behind it: a session
|
||||||
// switched to auto-resume waits out its account's usage limit and picks
|
// switched to auto-resume waits out its account's usage limit and picks
|
||||||
@@ -279,8 +281,14 @@ async fn main() -> Result<()> {
|
|||||||
// The bearer-token middleware wraps the entire router -- routes and fallback
|
// The bearer-token middleware wraps the entire router -- routes and fallback
|
||||||
// alike -- here and only here, so a new route can't forget auth.
|
// alike -- here and only here, so a new route can't forget auth.
|
||||||
let app = routes::router(Arc::clone(&manager))
|
let app = routes::router(Arc::clone(&manager))
|
||||||
.merge(routes::usage_router(monitor, Arc::clone(&manager)))
|
.merge(routes::usage_router(
|
||||||
.merge(routes::models_router(Arc::clone(&models)))
|
Arc::clone(&monitor),
|
||||||
|
Arc::clone(&manager),
|
||||||
|
))
|
||||||
|
.merge(routes::provider_auth_router(
|
||||||
|
Arc::clone(&provider_logins),
|
||||||
|
Arc::clone(&manager),
|
||||||
|
))
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
Arc::clone(&manager),
|
Arc::clone(&manager),
|
||||||
auth::require_token,
|
auth::require_token,
|
||||||
@@ -322,6 +330,7 @@ async fn main() -> Result<()> {
|
|||||||
// above: a throwaway session is one nobody meant to keep, and the whole point
|
// above: a throwaway session is one nobody meant to keep, and the whole point
|
||||||
// is that nothing has to remember to clean it up.
|
// is that nothing has to remember to clean it up.
|
||||||
manager.stop_throwaway_sessions();
|
manager.stop_throwaway_sessions();
|
||||||
|
provider_logins.cancel_all();
|
||||||
manager.detach_all();
|
manager.detach_all();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+472
-490
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,470 @@
|
|||||||
|
//! Interactive provider login carried between a CLI on a configured machine
|
||||||
|
//! and the phone. The CLI remains the only credential writer: this layer keeps
|
||||||
|
//! its short-lived process and relays only the authorization URL and the code
|
||||||
|
//! a person copies back from the browser.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use rand::Rng;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::config::{MachineConfig, ProviderConfig};
|
||||||
|
use crate::session::transport::Transport;
|
||||||
|
use crate::usage::UsageMonitor;
|
||||||
|
|
||||||
|
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||||
|
const AUTHORIZATION_URL_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
const OUTPUT_POLL: Duration = Duration::from_millis(50);
|
||||||
|
const START_WAIT: Duration = Duration::from_secs(16);
|
||||||
|
|
||||||
|
type Key = (String, String);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "state", rename_all = "camelCase")]
|
||||||
|
pub enum LoginState {
|
||||||
|
Starting,
|
||||||
|
WaitingForCode {
|
||||||
|
#[serde(rename = "authorizationUrl")]
|
||||||
|
authorization_url: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
detail: Option<String>,
|
||||||
|
},
|
||||||
|
Submitting,
|
||||||
|
Succeeded,
|
||||||
|
Failed {
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginState {
|
||||||
|
fn terminal(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::Succeeded | Self::Failed { .. } | Self::Cancelled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LoginInfo {
|
||||||
|
pub attempt: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub state: LoginState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Attempt {
|
||||||
|
id: String,
|
||||||
|
state: Arc<Mutex<LoginState>>,
|
||||||
|
input: mpsc::Sender<Input>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Attempt {
|
||||||
|
fn info(&self) -> LoginInfo {
|
||||||
|
LoginInfo {
|
||||||
|
attempt: self.id.clone(),
|
||||||
|
state: self.state.lock().unwrap().clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Input {
|
||||||
|
Code(String),
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active login per machine and provider. Completed attempts stay until a
|
||||||
|
/// new one replaces them, so a phone that briefly loses its connection can ask
|
||||||
|
/// how the operation ended rather than being handed an ambiguous 404.
|
||||||
|
pub struct LoginManager {
|
||||||
|
attempts: Mutex<HashMap<Key, Attempt>>,
|
||||||
|
usage: Arc<UsageMonitor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginManager {
|
||||||
|
pub fn new(usage: Arc<UsageMonitor>) -> Self {
|
||||||
|
Self {
|
||||||
|
attempts: Mutex::new(HashMap::new()),
|
||||||
|
usage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&self, machine: MachineConfig, provider: ProviderConfig) -> LoginInfo {
|
||||||
|
let provider_key = provider
|
||||||
|
.kind
|
||||||
|
.usage_provider()
|
||||||
|
.expect("a login route only accepts a metered provider")
|
||||||
|
.to_string();
|
||||||
|
let key = (machine.id.clone(), provider_key);
|
||||||
|
let mut attempts = self.attempts.lock().unwrap();
|
||||||
|
if let Some(attempt) = attempts.get(&key)
|
||||||
|
&& !attempt.state.lock().unwrap().terminal()
|
||||||
|
{
|
||||||
|
return attempt.info();
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = attempt_id();
|
||||||
|
let state = Arc::new(Mutex::new(LoginState::Starting));
|
||||||
|
let (input, commands) = mpsc::channel();
|
||||||
|
let attempt = Attempt {
|
||||||
|
id: id.clone(),
|
||||||
|
state: Arc::clone(&state),
|
||||||
|
input,
|
||||||
|
};
|
||||||
|
attempts.insert(key, attempt.clone());
|
||||||
|
drop(attempts);
|
||||||
|
|
||||||
|
// Seed the cache before the worker takes the gate. A usage request in
|
||||||
|
// that small handoff window sees a fresh, truthful state and cannot
|
||||||
|
// start a second CLI against the same credential.
|
||||||
|
let gate = self.usage.claude_authentication_started(&machine);
|
||||||
|
let usage = Arc::clone(&self.usage);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _guard = gate.lock().unwrap();
|
||||||
|
run_login(&machine, &provider, commands, &state);
|
||||||
|
usage.claude_authentication_finished(&machine.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
attempt.info()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wait_until_ready(&self, machine: &str, provider: &str, attempt: &str) -> LoginInfo {
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
let info = self.read(machine, provider, attempt).unwrap_or(LoginInfo {
|
||||||
|
attempt: attempt.to_string(),
|
||||||
|
state: LoginState::Failed {
|
||||||
|
detail: "the sign-in attempt disappeared".to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if !matches!(info.state, LoginState::Starting) || started.elapsed() >= START_WAIT {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
std::thread::sleep(OUTPUT_POLL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read(&self, machine: &str, provider: &str, attempt: &str) -> Option<LoginInfo> {
|
||||||
|
let attempts = self.attempts.lock().unwrap();
|
||||||
|
let found = attempts.get(&(machine.to_string(), provider.to_string()))?;
|
||||||
|
(found.id == attempt).then(|| found.info())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn submit(
|
||||||
|
&self,
|
||||||
|
machine: &str,
|
||||||
|
provider: &str,
|
||||||
|
attempt: &str,
|
||||||
|
code: &str,
|
||||||
|
) -> Result<LoginInfo> {
|
||||||
|
let code = valid_code(code)?;
|
||||||
|
let attempts = self.attempts.lock().unwrap();
|
||||||
|
let found = attempts
|
||||||
|
.get(&(machine.to_string(), provider.to_string()))
|
||||||
|
.filter(|found| found.id == attempt)
|
||||||
|
.context("no such sign-in attempt")?;
|
||||||
|
if found.state.lock().unwrap().terminal() {
|
||||||
|
return Ok(found.info());
|
||||||
|
}
|
||||||
|
*found.state.lock().unwrap() = LoginState::Submitting;
|
||||||
|
if found.input.send(Input::Code(code.to_string())).is_err() {
|
||||||
|
*found.state.lock().unwrap() = LoginState::Failed {
|
||||||
|
detail: "the sign-in process has stopped".to_string(),
|
||||||
|
};
|
||||||
|
anyhow::bail!("the sign-in process has stopped");
|
||||||
|
}
|
||||||
|
Ok(found.info())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel(&self, machine: &str, provider: &str, attempt: &str) -> Result<LoginInfo> {
|
||||||
|
let attempts = self.attempts.lock().unwrap();
|
||||||
|
let found = attempts
|
||||||
|
.get(&(machine.to_string(), provider.to_string()))
|
||||||
|
.filter(|found| found.id == attempt)
|
||||||
|
.context("no such sign-in attempt")?;
|
||||||
|
if !found.state.lock().unwrap().terminal() {
|
||||||
|
let _ = found.input.send(Input::Cancel);
|
||||||
|
}
|
||||||
|
Ok(found.info())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interactive helpers are unlike sessions: nothing adopts them after a
|
||||||
|
/// server restart. End every one while the process is still here to reap
|
||||||
|
/// the child it launched.
|
||||||
|
pub fn cancel_all(&self) {
|
||||||
|
let attempts = self.attempts.lock().unwrap();
|
||||||
|
for attempt in attempts.values() {
|
||||||
|
if !attempt.state.lock().unwrap().terminal() {
|
||||||
|
let _ = attempt.input.send(Input::Cancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pending: Vec<_> = attempts
|
||||||
|
.values()
|
||||||
|
.map(|attempt| Arc::clone(&attempt.state))
|
||||||
|
.collect();
|
||||||
|
drop(attempts);
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(2);
|
||||||
|
while Instant::now() < deadline
|
||||||
|
&& pending
|
||||||
|
.iter()
|
||||||
|
.any(|state| !state.lock().unwrap().terminal())
|
||||||
|
{
|
||||||
|
std::thread::sleep(OUTPUT_POLL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_login(
|
||||||
|
machine: &MachineConfig,
|
||||||
|
provider: &ProviderConfig,
|
||||||
|
commands: mpsc::Receiver<Input>,
|
||||||
|
state: &Arc<Mutex<LoginState>>,
|
||||||
|
) {
|
||||||
|
if let Err(err) = run_login_inner(machine, provider, commands, state) {
|
||||||
|
*state.lock().unwrap() = LoginState::Failed {
|
||||||
|
detail: format!("couldn't sign in to Claude on {}: {err:#}", machine.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_login_inner(
|
||||||
|
machine: &MachineConfig,
|
||||||
|
provider: &ProviderConfig,
|
||||||
|
commands: mpsc::Receiver<Input>,
|
||||||
|
state: &Arc<Mutex<LoginState>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let transport = Transport::for_machine(machine);
|
||||||
|
let args = vec![
|
||||||
|
"BROWSER=/bin/false".to_string(),
|
||||||
|
provider.program().to_string(),
|
||||||
|
"auth".to_string(),
|
||||||
|
"login".to_string(),
|
||||||
|
"--claudeai".to_string(),
|
||||||
|
];
|
||||||
|
let host = match &transport {
|
||||||
|
Transport::Here => None,
|
||||||
|
Transport::Ssh { ssh, .. } => Some(ssh),
|
||||||
|
};
|
||||||
|
let mut command = crate::ssh::command(host, "env", &args, None, None);
|
||||||
|
command
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let mut child = command
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("couldn't run {} auth login", provider.program()))?;
|
||||||
|
let mut stdin = child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.context("the login process has no stdin")?;
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.context("the login process has no stdout")?;
|
||||||
|
let stderr = child
|
||||||
|
.stderr
|
||||||
|
.take()
|
||||||
|
.context("the login process has no stderr")?;
|
||||||
|
let (output, lines) = mpsc::channel();
|
||||||
|
read_lines(stdout, output.clone());
|
||||||
|
read_lines(stderr, output);
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut authorization_url = None;
|
||||||
|
let mut last_line = None;
|
||||||
|
loop {
|
||||||
|
while let Ok(line) = lines.try_recv() {
|
||||||
|
if let Some(url) = authorization_url_in(&line) {
|
||||||
|
authorization_url = Some(url.to_string());
|
||||||
|
*state.lock().unwrap() = LoginState::WaitingForCode {
|
||||||
|
authorization_url: url.to_string(),
|
||||||
|
detail: None,
|
||||||
|
};
|
||||||
|
} else if line.to_ascii_lowercase().contains("invalid code") {
|
||||||
|
if let Some(url) = &authorization_url {
|
||||||
|
*state.lock().unwrap() = LoginState::WaitingForCode {
|
||||||
|
authorization_url: url.clone(),
|
||||||
|
detail: Some(
|
||||||
|
"That code was not accepted. Copy the complete code and try again."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if !line.trim().is_empty() {
|
||||||
|
last_line = Some(line.trim().chars().take(500).collect::<String>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match commands.recv_timeout(OUTPUT_POLL) {
|
||||||
|
Ok(Input::Code(code)) => {
|
||||||
|
*state.lock().unwrap() = LoginState::Submitting;
|
||||||
|
writeln!(stdin, "{code}").context("couldn't send the login code")?;
|
||||||
|
stdin.flush().context("couldn't send the login code")?;
|
||||||
|
}
|
||||||
|
Ok(Input::Cancel) => {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
*state.lock().unwrap() = LoginState::Cancelled;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
anyhow::bail!("the phone disconnected from the sign-in attempt");
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(status) = child
|
||||||
|
.try_wait()
|
||||||
|
.context("couldn't check the login process")?
|
||||||
|
{
|
||||||
|
*state.lock().unwrap() = if status.success() {
|
||||||
|
LoginState::Succeeded
|
||||||
|
} else {
|
||||||
|
LoginState::Failed {
|
||||||
|
detail: last_line
|
||||||
|
.unwrap_or_else(|| format!("Claude's login process exited with {status}")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if authorization_url.is_none() && started.elapsed() >= AUTHORIZATION_URL_TIMEOUT {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
anyhow::bail!("the Claude CLI did not provide an authorization URL");
|
||||||
|
}
|
||||||
|
if started.elapsed() >= LOGIN_TIMEOUT {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
anyhow::bail!("the sign-in attempt expired; start it again");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_lines(reader: impl Read + Send + 'static, output: mpsc::Sender<String>) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for line in BufReader::new(reader).lines().map_while(Result::ok) {
|
||||||
|
let _ = output.send(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorization_url_in(line: &str) -> Option<&str> {
|
||||||
|
let start = line.find("https://")?;
|
||||||
|
let tail = &line[start..];
|
||||||
|
let end = tail
|
||||||
|
.find(|character: char| character.is_whitespace() || character == '\u{1b}')
|
||||||
|
.unwrap_or(tail.len());
|
||||||
|
let url = &tail[..end];
|
||||||
|
(url.starts_with("https://claude.com/") || url.starts_with("https://platform.claude.com/"))
|
||||||
|
.then_some(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_code(code: &str) -> Result<&str> {
|
||||||
|
let code = code.trim();
|
||||||
|
anyhow::ensure!(!code.is_empty(), "the login code is empty");
|
||||||
|
anyhow::ensure!(code.len() <= 4096, "the login code is too long");
|
||||||
|
anyhow::ensure!(
|
||||||
|
!code.chars().any(char::is_control),
|
||||||
|
"the login code contains a line break or control character"
|
||||||
|
);
|
||||||
|
Ok(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attempt_id() -> String {
|
||||||
|
let mut bytes = [0u8; 16];
|
||||||
|
rand::rng().fill_bytes(&mut bytes);
|
||||||
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::DriverKind;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_only_anthropics_https_login_url() {
|
||||||
|
assert_eq!(
|
||||||
|
authorization_url_in("visit: https://claude.com/cai/oauth/authorize?state=x"),
|
||||||
|
Some("https://claude.com/cai/oauth/authorize?state=x")
|
||||||
|
);
|
||||||
|
assert!(authorization_url_in("visit: http://claude.com/nope").is_none());
|
||||||
|
assert!(authorization_url_in("visit: https://example.com/nope").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_code_is_one_bounded_line() {
|
||||||
|
assert_eq!(valid_code(" abc#state ").unwrap(), "abc#state");
|
||||||
|
assert!(valid_code("\n").is_err());
|
||||||
|
assert!(valid_code("a\nb").is_err());
|
||||||
|
assert!(valid_code(&"x".repeat(4097)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn relays_a_headless_cli_login_without_taking_over_its_credentials() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let cli = dir.path().join("fake-claude");
|
||||||
|
std::fs::write(
|
||||||
|
&cli,
|
||||||
|
"#!/bin/sh\necho 'https://claude.com/cai/oauth/authorize?state=test'\nIFS= read -r code\n[ \"$code\" = 'the-code' ]\n",
|
||||||
|
)
|
||||||
|
.expect("write fake CLI");
|
||||||
|
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o700))
|
||||||
|
.expect("make fake CLI executable");
|
||||||
|
|
||||||
|
let monitor = Arc::new(UsageMonitor::new(Default::default()));
|
||||||
|
let logins = LoginManager::new(monitor);
|
||||||
|
let machine = MachineConfig {
|
||||||
|
id: "vm".to_string(),
|
||||||
|
name: "test vm".to_string(),
|
||||||
|
ssh: None,
|
||||||
|
providers: Vec::new(),
|
||||||
|
};
|
||||||
|
let provider = ProviderConfig {
|
||||||
|
name: "claude-cli".to_string(),
|
||||||
|
kind: DriverKind::ClaudeCli,
|
||||||
|
command: Some(cli.display().to_string()),
|
||||||
|
models: Vec::new(),
|
||||||
|
mcp_servers: Vec::new(),
|
||||||
|
model_settings: Default::default(),
|
||||||
|
max_loaded: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let started = logins.start(machine, provider);
|
||||||
|
let ready = logins.wait_until_ready("vm", "claude", &started.attempt);
|
||||||
|
assert!(matches!(ready.state, LoginState::WaitingForCode { .. }));
|
||||||
|
let wire = serde_json::to_value(&ready).expect("serialize login state");
|
||||||
|
assert!(wire.get("authorizationUrl").is_some(), "{wire}");
|
||||||
|
assert!(wire.get("authorization_url").is_none(), "{wire}");
|
||||||
|
let submitted = logins
|
||||||
|
.submit("vm", "claude", &started.attempt, "the-code")
|
||||||
|
.expect("submit code");
|
||||||
|
assert!(matches!(submitted.state, LoginState::Submitting));
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(2);
|
||||||
|
loop {
|
||||||
|
let finished = logins
|
||||||
|
.read("vm", "claude", &started.attempt)
|
||||||
|
.expect("attempt remains readable");
|
||||||
|
if matches!(finished.state, LoginState::Succeeded) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"login did not finish: {finished:?}"
|
||||||
|
);
|
||||||
|
std::thread::sleep(OUTPUT_POLL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-11
@@ -48,7 +48,7 @@ const AT_LEAST: f64 = 60.0;
|
|||||||
/// How long after the limit was hit to stop waiting.
|
/// How long after the limit was hit to stop waiting.
|
||||||
///
|
///
|
||||||
/// Something has to bound it, or a machine that can never be asked -- an
|
/// Something has to bound it, or a machine that can never be asked -- an
|
||||||
/// unplugged laptop, a setup somebody edited away -- is retried for ever with
|
/// unplugged laptop, a machine somebody edited away -- is retried for ever with
|
||||||
/// nothing on screen saying so. A day is past the longest window Claude
|
/// nothing on screen saying so. A day is past the longest window Claude
|
||||||
/// reports, so reaching this means the wait was never going to end on its own.
|
/// reports, so reaching this means the wait was never going to end on its own.
|
||||||
const GIVE_UP: f64 = 24.0 * 60.0 * 60.0;
|
const GIVE_UP: f64 = 24.0 * 60.0 * 60.0;
|
||||||
@@ -123,7 +123,7 @@ async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
|
|||||||
Step::Send => match manager.resume_now(&owed.session_id) {
|
Step::Send => match manager.resume_now(&owed.session_id) {
|
||||||
Ok(message) => tracing::info!(
|
Ok(message) => tracing::info!(
|
||||||
"the limit on {} has lifted; sent \"{message}\" to {}",
|
"the limit on {} has lifted; sent \"{message}\" to {}",
|
||||||
owed.setup,
|
owed.machine,
|
||||||
owed.session_id
|
owed.session_id
|
||||||
),
|
),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -144,7 +144,7 @@ async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
|
|||||||
// to read on a phone.
|
// to read on a phone.
|
||||||
let why = match snapshot.as_ref().map(|snapshot| &snapshot.state) {
|
let why = match snapshot.as_ref().map(|snapshot| &snapshot.state) {
|
||||||
Some(UsageState::Ok) => "the limit has not lifted in a day".to_string(),
|
Some(UsageState::Ok) => "the limit has not lifted in a day".to_string(),
|
||||||
_ => format!("{} could not be asked for a day", owed.setup),
|
_ => format!("{} could not be asked for a day", owed.machine),
|
||||||
};
|
};
|
||||||
if let Err(err) = manager.abandon_resume(&owed.session_id, &why) {
|
if let Err(err) = manager.abandon_resume(&owed.session_id, &why) {
|
||||||
tracing::error!("couldn't clear {}'s resume: {err:#}", owed.session_id);
|
tracing::error!("couldn't clear {}'s resume: {err:#}", owed.session_id);
|
||||||
@@ -164,18 +164,18 @@ async fn snapshot_for(
|
|||||||
manager: &SessionManager,
|
manager: &SessionManager,
|
||||||
owed: &OwedResume,
|
owed: &OwedResume,
|
||||||
) -> Option<UsageSnapshot> {
|
) -> Option<UsageSnapshot> {
|
||||||
let setups: Vec<_> = manager
|
let machines: Vec<_> = manager
|
||||||
.setups()
|
.machines()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|setup| setup.id == owed.setup)
|
.filter(|machine| machine.id == owed.machine)
|
||||||
.collect();
|
.collect();
|
||||||
if setups.is_empty() {
|
if machines.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let provider = owed.provider;
|
let provider = owed.provider;
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
monitor
|
monitor
|
||||||
.snapshots(&setups)
|
.snapshots(&machines)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|snapshot| snapshot.provider == provider)
|
.find(|snapshot| snapshot.provider == provider)
|
||||||
})
|
})
|
||||||
@@ -258,7 +258,7 @@ mod tests {
|
|||||||
fn owed(since: f64) -> OwedResume {
|
fn owed(since: f64) -> OwedResume {
|
||||||
OwedResume {
|
OwedResume {
|
||||||
session_id: "s1".to_string(),
|
session_id: "s1".to_string(),
|
||||||
setup: "local".to_string(),
|
machine: "local".to_string(),
|
||||||
provider: crate::usage::CLAUDE,
|
provider: crate::usage::CLAUDE,
|
||||||
scheduled: ScheduledResume { at: since, since },
|
scheduled: ScheduledResume { at: since, since },
|
||||||
}
|
}
|
||||||
@@ -267,8 +267,10 @@ mod tests {
|
|||||||
fn snapshot(state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
|
fn snapshot(state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
|
||||||
UsageSnapshot {
|
UsageSnapshot {
|
||||||
provider: crate::usage::CLAUDE.to_string(),
|
provider: crate::usage::CLAUDE.to_string(),
|
||||||
setup: "local".to_string(),
|
machine: "local".to_string(),
|
||||||
setup_name: "this machine".to_string(),
|
machine_name: "this machine".to_string(),
|
||||||
|
limit_id: None,
|
||||||
|
limit_name: None,
|
||||||
state,
|
state,
|
||||||
windows,
|
windows,
|
||||||
fetched_at: 0.0,
|
fetched_at: 0.0,
|
||||||
@@ -280,6 +282,7 @@ mod tests {
|
|||||||
kind: "session".to_string(),
|
kind: "session".to_string(),
|
||||||
label: "5-hour window".to_string(),
|
label: "5-hour window".to_string(),
|
||||||
percent,
|
percent,
|
||||||
|
duration_minutes: Some(300),
|
||||||
resets_at: resets_at.map(str::to_string),
|
resets_at: resets_at.map(str::to_string),
|
||||||
active: true,
|
active: true,
|
||||||
}
|
}
|
||||||
|
|||||||
+777
-204
File diff suppressed because it is too large.
Load diff
+249
-62
@@ -47,7 +47,6 @@
|
|||||||
//! same way as the rest, against 2.1.237 on 2026-08-29.
|
//! same way as the rest, against 2.1.237 on 2026-08-29.
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -57,7 +56,9 @@ use serde_json::{Value, json};
|
|||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued};
|
use super::driver::{
|
||||||
|
AttachmentRef, BackgroundTask, Driver, Event, EventSink, SessionStatus, Unqueued,
|
||||||
|
};
|
||||||
use super::process;
|
use super::process;
|
||||||
use super::subagent::Subagents;
|
use super::subagent::Subagents;
|
||||||
use super::transport::{Launch, Streams, Transport};
|
use super::transport::{Launch, Streams, Transport};
|
||||||
@@ -215,6 +216,7 @@ impl ClaudeDriver {
|
|||||||
session_dir: &Path,
|
session_dir: &Path,
|
||||||
sink: EventSink,
|
sink: EventSink,
|
||||||
subagents: Arc<Subagents>,
|
subagents: Arc<Subagents>,
|
||||||
|
initial_status: SessionStatus,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let state = Arc::new(Mutex::new(Translator::new(
|
let state = Arc::new(Mutex::new(Translator::new(
|
||||||
session_dir.to_path_buf(),
|
session_dir.to_path_buf(),
|
||||||
@@ -262,6 +264,9 @@ impl ClaudeDriver {
|
|||||||
Self::start(meta, provider, transport, session_dir)?
|
Self::start(meta, provider, transport, session_dir)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if !started_here && matches!(initial_status, SessionStatus::Idle | SessionStatus::Waiting) {
|
||||||
|
state.lock().unwrap().mark_settled();
|
||||||
|
}
|
||||||
|
|
||||||
// A process this driver has just started has been asked for nothing,
|
// A process this driver has just started has been asked for nothing,
|
||||||
// which is what idle means. Said here because nothing else will: the
|
// which is what idle means. Said here because nothing else will: the
|
||||||
@@ -321,14 +326,22 @@ impl ClaudeDriver {
|
|||||||
format!("{} {}", provider.name, transport.describe()),
|
format!("{} {}", provider.name, transport.describe()),
|
||||||
));
|
));
|
||||||
|
|
||||||
Ok(Self {
|
let driver = Self {
|
||||||
sink,
|
sink,
|
||||||
queue,
|
queue,
|
||||||
to_child,
|
to_child,
|
||||||
state,
|
state,
|
||||||
session_dir: session_dir.to_path_buf(),
|
session_dir: session_dir.to_path_buf(),
|
||||||
reading,
|
reading,
|
||||||
})
|
};
|
||||||
|
if !started_here {
|
||||||
|
// Claude 2.1.261 sends a full background-task snapshot after a
|
||||||
|
// repeated initialize. That repairs a completion edge missed by a
|
||||||
|
// backend that was down while the CLI kept running; older CLIs
|
||||||
|
// accept the request and simply send no snapshot.
|
||||||
|
driver.send_control(json!({"subtype": "initialize"}), None);
|
||||||
|
}
|
||||||
|
Ok(driver)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts a new CLI for this session, with its streams in the session
|
/// Starts a new CLI for this session, with its streams in the session
|
||||||
@@ -393,9 +406,9 @@ impl ClaudeDriver {
|
|||||||
|
|
||||||
// Fresh logs, because the offsets that index them start at zero and
|
// Fresh logs, because the offsets that index them start at zero and
|
||||||
// everything the previous process said is already in the transcript.
|
// everything the previous process said is already in the transcript.
|
||||||
let stdin = make_fifo(&session_dir.join(STDIN_FIFO))?;
|
let stdin = process::make_fifo(&session_dir.join(STDIN_FIFO))?;
|
||||||
let stdout = create_log(&session_dir.join(STDOUT_LOG))?;
|
let stdout = process::create_log(&session_dir.join(STDOUT_LOG))?;
|
||||||
let stderr = create_log(&session_dir.join(STDERR_LOG))?;
|
let stderr = process::create_log(&session_dir.join(STDERR_LOG))?;
|
||||||
|
|
||||||
let program = provider.program();
|
let program = provider.program();
|
||||||
let launch = Launch::new(program, args, meta.cwd.as_deref());
|
let launch = Launch::new(program, args, meta.cwd.as_deref());
|
||||||
@@ -499,12 +512,16 @@ impl ClaudeDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Driver for ClaudeDriver {
|
impl Driver for ClaudeDriver {
|
||||||
|
fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
|
||||||
|
self.state.lock().unwrap().background_tasks()
|
||||||
|
}
|
||||||
|
|
||||||
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
|
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
|
||||||
let mut content = Vec::new();
|
let mut content = Vec::new();
|
||||||
// An image goes into the message itself; the model looks at it. Any
|
// An image goes into the message itself; the model looks at it. Any
|
||||||
// other file stays where the upload put it and the message says where,
|
// other file stays where the upload put it and the message says where,
|
||||||
// because the CLI can read a file by path and a model cannot be handed
|
// because the CLI can read a file by path and a model cannot be handed
|
||||||
// a trace any other way. Named after the text, so the words come first.
|
// a trace any other way.
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
for id in &attachments {
|
for id in &attachments {
|
||||||
let sent = if crate::media::media_type_for(id).is_some() {
|
let sent = if crate::media::media_type_for(id).is_some() {
|
||||||
@@ -518,18 +535,6 @@ impl Driver for ClaudeDriver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut body = text.clone();
|
|
||||||
for path in files {
|
|
||||||
if !body.is_empty() {
|
|
||||||
body.push_str("\n\n");
|
|
||||||
}
|
|
||||||
body.push_str(&format!("Attached file: {}", path.display()));
|
|
||||||
}
|
|
||||||
if !body.is_empty() {
|
|
||||||
content.push(json!({"type": "text", "text": body}));
|
|
||||||
}
|
|
||||||
let line =
|
|
||||||
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string();
|
|
||||||
let mut queue = self.queue.lock().unwrap();
|
let mut queue = self.queue.lock().unwrap();
|
||||||
// Saying so beats writing into a fifo that nothing is reading, which is
|
// Saying so beats writing into a fifo that nothing is reading, which is
|
||||||
// what this used to do -- the message went nowhere and looked exactly
|
// what this used to do -- the message went nowhere and looked exactly
|
||||||
@@ -542,6 +547,15 @@ impl Driver for ClaudeDriver {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Built under the lock because whether this is a steer decides what the
|
||||||
|
// CLI is told, and the answer must be the same one the branch below acts
|
||||||
|
// on -- see `super::driver::message_body`.
|
||||||
|
let body = super::driver::message_body(&text, &files, queue.running);
|
||||||
|
if !body.is_empty() {
|
||||||
|
content.push(json!({"type": "text", "text": body}));
|
||||||
|
}
|
||||||
|
let line =
|
||||||
|
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string();
|
||||||
if queue.running {
|
if queue.running {
|
||||||
// Into the running turn, now. Announced when the CLI shows it has
|
// Into the running turn, now. Announced when the CLI shows it has
|
||||||
// been round the model again -- see `Queue`.
|
// been round the model again -- see `Queue`.
|
||||||
@@ -803,11 +817,39 @@ async fn follow(
|
|||||||
process::Liveness::Dead if complete > 0 => {}
|
process::Liveness::Dead if complete > 0 => {}
|
||||||
process::Liveness::Dead => {
|
process::Liveness::Dead => {
|
||||||
queue.lock().unwrap().close(&sink, "the session ended");
|
queue.lock().unwrap().close(&sink, "the session ended");
|
||||||
|
if !process::stopping(&session_dir) {
|
||||||
let detail = stderr_tail(&stderr_path);
|
let detail = stderr_tail(&stderr_path);
|
||||||
|
// A token the CLI will not accept leaves the chat
|
||||||
|
// *unrecoverable* rather than merely failed: every later
|
||||||
|
// start passes the same `--resume` and dies the same way.
|
||||||
|
// So it is forgotten, and `Cleared` marks where the model's
|
||||||
|
// context stopped -- as for a Codex thread with no rollout.
|
||||||
|
let refused =
|
||||||
|
missing_conversation(&detail) && read_resume_token(&session_dir).is_some();
|
||||||
|
// Both the message and the divider follow the removal
|
||||||
|
// rather than the diagnosis: one that failed leaves the
|
||||||
|
// session as stuck as it was.
|
||||||
|
let recovered = refused && forget_resume_token(&session_dir);
|
||||||
if !detail.is_empty() {
|
if !detail.is_empty() {
|
||||||
let _ = sink.send(Event::Error {
|
// The CLI's sentence reads like the chat is lost when
|
||||||
message: format!("{label} exited:\n{detail}"),
|
// one more message is all it needs, and this is the
|
||||||
});
|
// only place anyone sees it. Its words stay on top,
|
||||||
|
// being what a person would search for.
|
||||||
|
let message = if recovered {
|
||||||
|
format!(
|
||||||
|
"{label} exited:\n{detail}\n\nThat conversation is gone from the \
|
||||||
|
CLI, so this session has stopped trying to resume it. Send a \
|
||||||
|
message to carry on in a new one -- everything above is kept, \
|
||||||
|
but the model starts without it."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("{label} exited:\n{detail}")
|
||||||
|
};
|
||||||
|
let _ = sink.send(Event::Error { message });
|
||||||
|
}
|
||||||
|
if recovered {
|
||||||
|
let _ = sink.send(Event::Cleared);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let _ = sink.send(Event::Status {
|
let _ = sink.send(Event::Status {
|
||||||
state: SessionStatus::Exited,
|
state: SessionStatus::Exited,
|
||||||
@@ -868,8 +910,12 @@ fn translate_line(
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
let opens_a_model_call = starts_a_model_call(&message);
|
let opens_a_model_call = starts_a_model_call(&message);
|
||||||
|
let parent_running = queue.lock().unwrap().running;
|
||||||
let (events, new_session_id, before) = {
|
let (events, new_session_id, before) = {
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock().unwrap();
|
||||||
|
if parent_running {
|
||||||
|
state.mark_running();
|
||||||
|
}
|
||||||
let before = state.session_id.clone();
|
let before = state.session_id.clone();
|
||||||
let events = state.translate(&message);
|
let events = state.translate(&message);
|
||||||
let after = state.session_id.clone();
|
let after = state.session_id.clone();
|
||||||
@@ -938,10 +984,14 @@ fn translate_line(
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Either status the turn can end in -- see `SessionStatus::Waiting`.
|
||||||
|
// A turn that ended with a subagent still running is over for this
|
||||||
|
// queue's purposes: the CLI will read the next message, and holding
|
||||||
|
// one back until the subagent reported would sit on it indefinitely.
|
||||||
if matches!(
|
if matches!(
|
||||||
event,
|
event,
|
||||||
Event::Status {
|
Event::Status {
|
||||||
state: SessionStatus::Idle
|
state: SessionStatus::Idle | SessionStatus::Waiting
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// The case that must not be missed: a message written after the
|
// The case that must not be missed: a message written after the
|
||||||
@@ -1045,44 +1095,6 @@ fn stderr_tail(path: &Path) -> String {
|
|||||||
tail_of(&kept)
|
tail_of(&kept)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates the stdin fifo if it is not already there, and opens it read-write
|
|
||||||
/// for the process to inherit.
|
|
||||||
///
|
|
||||||
/// Read-write is the whole trick: a fifo opened read-only delivers EOF as soon
|
|
||||||
/// as the last writer closes, so the process would exit the moment this server
|
|
||||||
/// did -- exactly what leaving it running has to prevent. Holding it open for
|
|
||||||
/// writing means the process is its own last writer.
|
|
||||||
fn make_fifo(path: &Path) -> Result<std::fs::File> {
|
|
||||||
if !path.exists() {
|
|
||||||
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
|
|
||||||
.with_context(|| format!("{} is not a usable path", path.display()))?;
|
|
||||||
// SAFETY: a nul-terminated path this call only reads, and a mode with
|
|
||||||
// no bits the kernel can object to. Owner-only, like everything else in
|
|
||||||
// a session directory: this carries what the person typed.
|
|
||||||
let made = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
|
|
||||||
if made != 0 {
|
|
||||||
return Err(std::io::Error::last_os_error())
|
|
||||||
.with_context(|| format!("creating the fifo {}", path.display()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::fs::OpenOptions::new()
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(path)
|
|
||||||
.with_context(|| format!("opening the fifo {}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A fresh, empty, owner-only log for one of the process's output streams.
|
|
||||||
fn create_log(path: &Path) -> Result<std::fs::File> {
|
|
||||||
std::fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.write(true)
|
|
||||||
.truncate(true)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(path)
|
|
||||||
.with_context(|| format!("creating {}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn read_resume_token(session_dir: &Path) -> Option<String> {
|
pub(super) fn read_resume_token(session_dir: &Path) -> Option<String> {
|
||||||
let text = std::fs::read_to_string(session_dir.join(RESUME_FILE)).ok()?;
|
let text = std::fs::read_to_string(session_dir.join(RESUME_FILE)).ok()?;
|
||||||
serde_json::from_str::<Value>(&text)
|
serde_json::from_str::<Value>(&text)
|
||||||
@@ -1099,6 +1111,38 @@ pub(super) fn write_resume_token(session_dir: &Path, session_id: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop a resume token the CLI has refused, so the next start makes a session
|
||||||
|
/// rather than repeating the failure. Answers whether it is really gone: the
|
||||||
|
/// caller promises somebody the session has stopped resuming, and a failed
|
||||||
|
/// removal would make that a promise this server cannot keep.
|
||||||
|
fn forget_resume_token(session_dir: &Path) -> bool {
|
||||||
|
let path = session_dir.join(RESUME_FILE);
|
||||||
|
match std::fs::remove_file(&path) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(
|
||||||
|
"couldn't forget the refused resume token at {}: {err}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the CLI refused `--resume` because the token names nothing it has.
|
||||||
|
///
|
||||||
|
/// Two sentences say it and both recover the same way; matched on the phrases
|
||||||
|
/// that carry the fact, since the rest names the id. Measured against 2.1.237:
|
||||||
|
/// a valid UUID with nothing behind it gives the first, a non-UUID matching no
|
||||||
|
/// title the second.
|
||||||
|
fn missing_conversation(detail: &str) -> bool {
|
||||||
|
let detail = detail.to_ascii_lowercase();
|
||||||
|
detail.contains("no conversation found with session id")
|
||||||
|
|| (detail.contains("--resume requires a valid session id")
|
||||||
|
&& detail.contains("does not match any session title"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Where an uploaded attachment is, as a path the CLI can be told.
|
/// Where an uploaded attachment is, as a path the CLI can be told.
|
||||||
///
|
///
|
||||||
/// Absolute, because the CLI's working directory is the session's and the
|
/// Absolute, because the CLI's working directory is the session's and the
|
||||||
@@ -1572,4 +1616,147 @@ mod tests {
|
|||||||
assert!(received.try_recv().is_err());
|
assert!(received.try_recv().is_err());
|
||||||
assert!(queue.closed);
|
assert!(queue.closed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Both refusals verbatim from 2.1.237, and the exits that must *not* be
|
||||||
|
/// read as one: a session that merely failed still has a conversation, and
|
||||||
|
/// forgetting its token would discard the model's context for nothing.
|
||||||
|
#[test]
|
||||||
|
fn both_refused_resume_tokens_are_recognised() {
|
||||||
|
assert!(missing_conversation(
|
||||||
|
"No conversation found with session ID: a1c6c855-86cd-4f47-8b50-eaea85be3579"
|
||||||
|
));
|
||||||
|
assert!(missing_conversation(
|
||||||
|
"Error: --resume requires a valid session ID or session title when used with \
|
||||||
|
--print. Usage: claude -p --resume <session-id|title>. Provided value \
|
||||||
|
\"not-a-real-session\" is not a UUID and does not match any session title."
|
||||||
|
));
|
||||||
|
assert!(!missing_conversation("Error: connection closed"));
|
||||||
|
assert!(!missing_conversation(
|
||||||
|
"Credit balance is too low to run this request"
|
||||||
|
));
|
||||||
|
// The usage line alone is a different complaint: `--resume` was passed
|
||||||
|
// wrongly, not given a token that named nothing.
|
||||||
|
assert!(!missing_conversation(
|
||||||
|
"Error: --resume requires a valid session ID or session title when used with --print."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The composition the reader performs, not just the matcher: `stderr_tail`
|
||||||
|
/// both truncates and trims, so the phrase can survive the CLI and still
|
||||||
|
/// not reach `missing_conversation`.
|
||||||
|
#[test]
|
||||||
|
fn the_refusal_survives_the_stderr_tail_that_carries_it() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("stderr.log");
|
||||||
|
// Verbatim from 2.1.237, trailing newline included -- a shell's error
|
||||||
|
// ends with one, which is exactly what `tail_of` exists to trim.
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
"No conversation found with session ID: a1c6c855-86cd-4f47-8b50-eaea85be3579\n",
|
||||||
|
)
|
||||||
|
.expect("write stderr");
|
||||||
|
assert!(missing_conversation(&stderr_tail(&path)));
|
||||||
|
|
||||||
|
// And still found when noise precedes it, since the refusal is the last
|
||||||
|
// thing the CLI writes and the tail is taken from the end.
|
||||||
|
let mut noisy = "some earlier warning\n".repeat(STDERR_LINES_KEPT * 2);
|
||||||
|
noisy.push_str("No conversation found with session ID: a1c6c855\n");
|
||||||
|
std::fs::write(&path, noisy).expect("write noisy stderr");
|
||||||
|
assert!(missing_conversation(&stderr_tail(&path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dead process, a refusal in its stderr log, and a token on disk --
|
||||||
|
/// driven through the reader that performs the recovery rather than through
|
||||||
|
/// its parts, because the wiring is what the parts cannot check.
|
||||||
|
async fn exit_with_stderr(stderr: &str) -> (tempfile::TempDir, Vec<Event>) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
// Empty but present: an unreadable stdout is a different path that
|
||||||
|
// returns before any of this.
|
||||||
|
std::fs::write(dir.path().join(STDOUT_LOG), "").expect("stdout log");
|
||||||
|
std::fs::write(dir.path().join(STDERR_LOG), stderr).expect("stderr log");
|
||||||
|
write_resume_token(dir.path(), "a1c6c855-86cd-4f47-8b50-eaea85be3579");
|
||||||
|
|
||||||
|
let (sink, mut events) = mpsc::unbounded_channel();
|
||||||
|
follow(
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
// No `/proc` entry, so `liveness()` reads `Dead` -- the state this
|
||||||
|
// arm exists for, without having to kill anything.
|
||||||
|
process::Record {
|
||||||
|
pid: u32::MAX,
|
||||||
|
started: 0,
|
||||||
|
detail: process::Detail::Stdio { stdout_read: 0 },
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
Arc::new(Mutex::new(Translator::new(
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
Arc::new(Subagents::new(dir.path().to_path_buf())),
|
||||||
|
))),
|
||||||
|
sink,
|
||||||
|
Arc::new(Mutex::new(Queue::default())),
|
||||||
|
Arc::new(AtomicBool::new(true)),
|
||||||
|
"claude-cli on vm".to_string(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let seen = std::iter::from_fn(|| events.try_recv().ok()).collect();
|
||||||
|
(dir, seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_session_whose_conversation_vanished_is_left_able_to_start_again() {
|
||||||
|
let (dir, seen) = exit_with_stderr(
|
||||||
|
"No conversation found with session ID: a1c6c855-86cd-4f47-8b50-eaea85be3579\n",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let reported = seen
|
||||||
|
.iter()
|
||||||
|
.find_map(|event| match event {
|
||||||
|
Event::Error { message } => Some(message.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("the exit is reported");
|
||||||
|
// The CLI's own words, and then what to do about them.
|
||||||
|
assert!(
|
||||||
|
reported.contains("No conversation found with session ID"),
|
||||||
|
"{reported}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
reported.contains("Send a message to carry on"),
|
||||||
|
"{reported}"
|
||||||
|
);
|
||||||
|
// The divider, so the transcript says where the model's context ended.
|
||||||
|
assert!(seen.contains(&Event::Cleared), "{seen:?}");
|
||||||
|
// And the point of all of it: the next start has no token to repeat.
|
||||||
|
assert_eq!(read_resume_token(dir.path()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same reader on an exit that is *not* a refusal. The token is what
|
||||||
|
/// the session is still worth resuming from, so it has to survive.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_ordinary_failure_keeps_the_token_it_can_still_resume_from() {
|
||||||
|
let (dir, seen) = exit_with_stderr("Error: connection closed\n").await;
|
||||||
|
|
||||||
|
assert!(!seen.contains(&Event::Cleared), "{seen:?}");
|
||||||
|
assert_eq!(
|
||||||
|
read_resume_token(dir.path()).as_deref(),
|
||||||
|
Some("a1c6c855-86cd-4f47-8b50-eaea85be3579")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refused_token_is_forgotten_so_the_next_start_makes_a_session() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
write_resume_token(dir.path(), "a1c6c855-86cd-4f47-8b50-eaea85be3579");
|
||||||
|
assert!(read_resume_token(dir.path()).is_some());
|
||||||
|
|
||||||
|
assert!(forget_resume_token(dir.path()));
|
||||||
|
|
||||||
|
// Gone, so `launch` pushes `--name` instead of `--resume` and the CLI
|
||||||
|
// is asked for a session it can actually make.
|
||||||
|
assert_eq!(read_resume_token(dir.path()), None);
|
||||||
|
// Removing an absent one succeeds too -- the check and the remove are
|
||||||
|
// separate, so the file can go between them.
|
||||||
|
assert!(forget_resume_token(dir.path()));
|
||||||
|
assert_eq!(read_resume_token(dir.path()), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,73 @@ use tokio::sync::mpsc;
|
|||||||
/// renders them identically.
|
/// renders them identically.
|
||||||
pub type ImageRef = String;
|
pub type ImageRef = String;
|
||||||
|
|
||||||
|
/// Stores image bytes where the files route serves them and returns their transcript reference.
|
||||||
|
/// Both CLI dialects produce images in different envelopes; the durable file and naming rule are
|
||||||
|
/// part of the common event model and must not vary with that envelope.
|
||||||
|
pub(in crate::session) fn store_image(
|
||||||
|
session_dir: &std::path::Path,
|
||||||
|
media_type: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Option<ImageRef> {
|
||||||
|
let extension = crate::media::extension_for(media_type).unwrap_or("png");
|
||||||
|
let name = format!("{}.{extension}", super::random_hex());
|
||||||
|
let dir = session_dir.join("files");
|
||||||
|
if let Err(err) = wg_app_link::private::create_dir(&dir)
|
||||||
|
.map_err(std::io::Error::other)
|
||||||
|
.and_then(|()| std::fs::write(dir.join(&name), bytes))
|
||||||
|
{
|
||||||
|
tracing::error!("couldn't save produced image: {err}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefixed to a message that was typed while a turn was already running.
|
||||||
|
///
|
||||||
|
/// A steer goes to the CLI the moment it arrives, but *when the model reads
|
||||||
|
/// it* is not ours to decide: it lands at the next model call if there is
|
||||||
|
/// one, and a turn that ends first delivers it as the opening line of the
|
||||||
|
/// next turn instead -- Claude's from the fifo, Codex's requeued after
|
||||||
|
/// `activeTurnNotSteerable`. Read there it looks like a reply to the answer
|
||||||
|
/// just given, so the model acts as if the person had seen that answer, which
|
||||||
|
/// is exactly what they had not. Nothing else distinguishes the two cases by
|
||||||
|
/// the time the model sees them, so the note is the only thing that can carry
|
||||||
|
/// the fact.
|
||||||
|
const STEERING_NOTE: &str = "[Sent while you were still working, so it was written without \
|
||||||
|
having seen the rest of that turn. Treat it as steering the work in progress, not as a \
|
||||||
|
reply to anything you said after it was sent.]";
|
||||||
|
|
||||||
|
/// The text a CLI receives for one message: the note above when this is a
|
||||||
|
/// steer, the typed words, and the paths of any attachment the model has to
|
||||||
|
/// read from disk rather than being handed inline.
|
||||||
|
///
|
||||||
|
/// The paths come after the words because a model reading a list of files
|
||||||
|
/// before the request treats the list as the request.
|
||||||
|
pub(in crate::session) fn message_body(
|
||||||
|
text: &str,
|
||||||
|
files: &[std::path::PathBuf],
|
||||||
|
steering: bool,
|
||||||
|
) -> String {
|
||||||
|
let mut body = String::new();
|
||||||
|
if steering {
|
||||||
|
body.push_str(STEERING_NOTE);
|
||||||
|
}
|
||||||
|
for part in std::iter::once(text.to_string()).chain(
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.map(|path| format!("Attached file: {}", path.display())),
|
||||||
|
) {
|
||||||
|
if part.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !body.is_empty() {
|
||||||
|
body.push_str("\n\n");
|
||||||
|
}
|
||||||
|
body.push_str(&part);
|
||||||
|
}
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
/// The name an upload is stored and served under: an image is
|
/// The name an upload is stored and served under: an image is
|
||||||
/// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
|
/// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
|
||||||
/// keeps its own name after the hex, `<hex>-<name>`, because the name is what
|
/// keeps its own name after the hex, `<hex>-<name>`, because the name is what
|
||||||
@@ -135,6 +202,44 @@ pub enum Event {
|
|||||||
AssistantText {
|
AssistantText {
|
||||||
delta: String,
|
delta: String,
|
||||||
},
|
},
|
||||||
|
/// The authoritative text of the assistant message whose deltas immediately
|
||||||
|
/// precede this event.
|
||||||
|
///
|
||||||
|
/// Some providers stream a provisional rendering and revise it before the
|
||||||
|
/// item completes. This stays append-only like every other transcript
|
||||||
|
/// correction: readers replace the open message rather than editing an old
|
||||||
|
/// line, and replay therefore reaches the same text as the live stream.
|
||||||
|
AssistantTextFinal {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// The model's working, streamed the same way its reply is: the
|
||||||
|
/// reasoning it produced before -- or between -- the things it said.
|
||||||
|
///
|
||||||
|
/// Its own kind rather than [`Event::AssistantText`], because it is not
|
||||||
|
/// what the session said. The phone draws it as a card of its own, shut,
|
||||||
|
/// and no driver folds it back into the next prompt: a provider that
|
||||||
|
/// wants its own reasoning back sends it back itself.
|
||||||
|
///
|
||||||
|
/// Only a provider that actually streams its working sends this.
|
||||||
|
/// llama.cpp does, as `reasoning_content`; nothing is inferred for one
|
||||||
|
/// that does not, since a card that appeared whenever a turn was slow
|
||||||
|
/// would be a guess wearing a measurement's clothes.
|
||||||
|
Thinking {
|
||||||
|
delta: String,
|
||||||
|
},
|
||||||
|
/// The thinking immediately above this finished, having taken `ms`.
|
||||||
|
///
|
||||||
|
/// Measured by the driver rather than worked out by a reader from two
|
||||||
|
/// event timestamps. A reader only knows when an event *arrived*, so the
|
||||||
|
/// last delta of a block followed by a slow tool call is indistinguishable
|
||||||
|
/// from thinking that went on that long -- and a transcript replayed on a
|
||||||
|
/// phone has to reach the same figure the live stream showed.
|
||||||
|
///
|
||||||
|
/// A block with no end is one still being thought, which is what the card
|
||||||
|
/// draws a spinner for.
|
||||||
|
ThinkingDone {
|
||||||
|
ms: u64,
|
||||||
|
},
|
||||||
ToolStart {
|
ToolStart {
|
||||||
id: String,
|
id: String,
|
||||||
tool: String,
|
tool: String,
|
||||||
@@ -208,6 +313,48 @@ pub enum Event {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
turn_start: Option<u64>,
|
turn_start: Option<u64>,
|
||||||
},
|
},
|
||||||
|
/// **Retired on 2026-09-06, hours after it was added.** Kept only so the
|
||||||
|
/// transcripts written while it existed still read: a session that ran a
|
||||||
|
/// background task in that window has these lines for ever, and a
|
||||||
|
/// transcript is append-only, so there is no pass that could remove them.
|
||||||
|
///
|
||||||
|
/// Never constructed. It reported a background task finishing, and drawing
|
||||||
|
/// a row per one turned out to be a screen of notices about work the
|
||||||
|
/// reader was not asking after -- see PLAN.md's "Two turns must never be
|
||||||
|
/// drawn as one". The phone folds it to no row at all, which is what makes
|
||||||
|
/// keeping it cheap.
|
||||||
|
///
|
||||||
|
/// Deleting the variant instead is what broke every live session, and
|
||||||
|
/// [`Event::Unreadable`] is the reason that cannot happen again. This is
|
||||||
|
/// still here rather than left to that: an unreadable line draws a
|
||||||
|
/// placeholder, correctly, and one per background task is the same wall
|
||||||
|
/// the row was removed for.
|
||||||
|
TaskNote {
|
||||||
|
about: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
title: Option<String>,
|
||||||
|
status: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
summary: Option<String>,
|
||||||
|
},
|
||||||
|
/// A line in a transcript that this build cannot read: a kind a newer
|
||||||
|
/// server wrote, a kind an older one wrote that has since been dropped, or
|
||||||
|
/// a line whose contents do not fit the kind it names.
|
||||||
|
///
|
||||||
|
/// **Only ever made when reading, never sent by a driver**, and it is the
|
||||||
|
/// reason a transcript can outlive a change to this enum. See
|
||||||
|
/// `Indexed::parse_at` for the incident: removing a variant after
|
||||||
|
/// transcripts had recorded it made every read of those files fail, so
|
||||||
|
/// every session in them lost its status, its history and its ability to
|
||||||
|
/// be sent to.
|
||||||
|
///
|
||||||
|
/// It carries the word the line called itself so a reader is told what
|
||||||
|
/// they are missing rather than that something is missing. `kind` is not
|
||||||
|
/// an enum for the obvious reason: the whole point of this variant is the
|
||||||
|
/// words that are not in one.
|
||||||
|
Unreadable {
|
||||||
|
kind: String,
|
||||||
|
},
|
||||||
/// The manager's record of a question being answered, so a rendered
|
/// The manager's record of a question being answered, so a rendered
|
||||||
/// question card resolves on every device rather than only the one that
|
/// question card resolves on every device rather than only the one that
|
||||||
/// answered.
|
/// answered.
|
||||||
@@ -218,6 +365,14 @@ pub enum Event {
|
|||||||
id: String,
|
id: String,
|
||||||
answers: Vec<String>,
|
answers: Vec<String>,
|
||||||
},
|
},
|
||||||
|
/// How many background tasks the provider says are alive now.
|
||||||
|
///
|
||||||
|
/// State rather than a transcript row: the phone draws it beside the
|
||||||
|
/// session status. A distinct event keeps the count current while a
|
||||||
|
/// session is open; `GET /sessions` supplies the opening snapshot.
|
||||||
|
BackgroundTasks {
|
||||||
|
count: usize,
|
||||||
|
},
|
||||||
Status {
|
Status {
|
||||||
state: SessionStatus,
|
state: SessionStatus,
|
||||||
},
|
},
|
||||||
@@ -236,6 +391,26 @@ pub enum Event {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
permission_mode: Option<String>,
|
permission_mode: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// How much context this session's model has to hold a conversation in.
|
||||||
|
///
|
||||||
|
/// The denominator the phone draws [`Event::UsageDelta`]'s `context`
|
||||||
|
/// against, and its own event rather than a field on that one because it
|
||||||
|
/// is not a per-turn measurement: it is fixed when the process starts and
|
||||||
|
/// changes only when a different one is started, which is what a model
|
||||||
|
/// change does. Reported the moment it is known, so the figure and what it
|
||||||
|
/// is out of arrive together rather than the first turn drawing a
|
||||||
|
/// numerator with no denominator.
|
||||||
|
///
|
||||||
|
/// **Only ever sent by a driver that actually knows.** A window nobody has
|
||||||
|
/// measured is not an unlimited one: llama.cpp answers it exactly, because
|
||||||
|
/// the number is a flag the server was started with and `/props` reads it
|
||||||
|
/// back, while a coding CLI's context is the vendor's business and
|
||||||
|
/// nothing in either control protocol states it. Those send nothing, the
|
||||||
|
/// session has no limit, and the phone draws the figure on its own -- see
|
||||||
|
/// `SessionSummary::context_limit`.
|
||||||
|
ContextWindow {
|
||||||
|
tokens: u64,
|
||||||
|
},
|
||||||
/// Per-turn token counts, where the dialect reports them.
|
/// Per-turn token counts, where the dialect reports them.
|
||||||
UsageDelta {
|
UsageDelta {
|
||||||
/// What this turn cost: the tokens it was charged for.
|
/// What this turn cost: the tokens it was charged for.
|
||||||
@@ -252,6 +427,28 @@ pub enum Event {
|
|||||||
/// able to draw.
|
/// able to draw.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
context: Option<u64>,
|
context: Option<u64>,
|
||||||
|
/// How fast the reply came out, as the provider measured it.
|
||||||
|
///
|
||||||
|
/// `None` wherever nothing measured it, which is most providers: a
|
||||||
|
/// coding CLI reports what a turn cost and never how long the model
|
||||||
|
/// took over it, and dividing tokens by the wall time this server
|
||||||
|
/// waited would count the network, the tool calls and the reader's own
|
||||||
|
/// permission answers as generation. A figure that is right most of
|
||||||
|
/// the time is no use here, because nothing on screen could say which
|
||||||
|
/// times those were.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
tokens_per_second: Option<f64>,
|
||||||
|
/// How long the provider spent reading the prompt before it began
|
||||||
|
/// answering -- see [`SessionStatus::Reading`], which is this while it
|
||||||
|
/// is happening.
|
||||||
|
///
|
||||||
|
/// The provider's own measurement or nothing, for the same reason
|
||||||
|
/// `tokens_per_second` above is: the wait this server watched also
|
||||||
|
/// contains the request and whatever else the machine was doing. It is worth reporting because it is the larger half of a
|
||||||
|
/// turn on a long conversation -- 22 seconds against 3 of generation,
|
||||||
|
/// measured on a 14,000-token prompt.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
prefill_ms: Option<u64>,
|
||||||
},
|
},
|
||||||
/// A compaction that finished, and how much context it recovered.
|
/// A compaction that finished, and how much context it recovered.
|
||||||
///
|
///
|
||||||
@@ -321,11 +518,40 @@ pub enum Event {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
resets_at: Option<f64>,
|
resets_at: Option<f64>,
|
||||||
},
|
},
|
||||||
|
/// The provider refused the turn because its login can no longer be used.
|
||||||
|
///
|
||||||
|
/// Recognised by the driver for the same reason [`Event::LimitReached`] is:
|
||||||
|
/// only that layer knows the provider's dialect, and the phone needs a
|
||||||
|
/// state it can act on without matching error prose.
|
||||||
|
AuthenticationRequired {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The common presentation of a file change, whichever driver produced it.
|
||||||
|
pub(super) fn patch_start(id: String, diff: String) -> Event {
|
||||||
|
Event::ToolStart {
|
||||||
|
id,
|
||||||
|
tool: "Patch".to_string(),
|
||||||
|
input: serde_json::json!({"diff": diff}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn prefixed_lines(prefix: char, text: &str) -> String {
|
||||||
|
text.split_inclusive('\n')
|
||||||
|
.map(|line| {
|
||||||
|
if line.ends_with('\n') {
|
||||||
|
format!("{prefix}{line}")
|
||||||
|
} else {
|
||||||
|
format!("{prefix}{line}\n")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// How much the model was holding, from the three figures a turn reports:
|
/// How much the model was holding, from the three figures a turn reports:
|
||||||
/// the input side only, prompt plus both cache figures. A cached token is
|
/// the input side only, prompt plus both cache figures. A cached token is
|
||||||
/// cheaper but it is still one the model was given; output is what the turn
|
/// cheaper but it is still one the model was given; output is what the turn
|
||||||
@@ -338,6 +564,25 @@ pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 {
|
|||||||
input + cache_creation + cache_read
|
input + cache_creation + cache_read
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The context window after `event`, given what it was before.
|
||||||
|
///
|
||||||
|
/// Beside [`context_after`] because it has the same three readers and the same
|
||||||
|
/// hazard: a figure that outlives what made it true. A model change replaces
|
||||||
|
/// the process, so the window it reports replaces the old one -- and until the
|
||||||
|
/// new one says, there is no answer rather than the previous model's.
|
||||||
|
pub fn context_limit_after(current: Option<u64>, event: &Event) -> Option<u64> {
|
||||||
|
match event {
|
||||||
|
Event::ContextWindow { tokens } => Some(*tokens),
|
||||||
|
// The window belongs to the process, and a stopped one has none. Left
|
||||||
|
// standing, a restarted session on a different model would draw its
|
||||||
|
// occupancy against the previous model's window.
|
||||||
|
Event::Status {
|
||||||
|
state: SessionStatus::Exited,
|
||||||
|
} => None,
|
||||||
|
_ => current,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The context after `event`, given what it was before.
|
/// The context after `event`, given what it was before.
|
||||||
///
|
///
|
||||||
/// The whole rule in one place, because three readers need the same answer:
|
/// The whole rule in one place, because three readers need the same answer:
|
||||||
@@ -408,6 +653,48 @@ pub enum SessionStatus {
|
|||||||
Running,
|
Running,
|
||||||
AwaitingInput,
|
AwaitingInput,
|
||||||
Compacting,
|
Compacting,
|
||||||
|
/// The session's process is up but cannot be spoken to yet.
|
||||||
|
///
|
||||||
|
/// Its own state because the two it would otherwise borrow are both
|
||||||
|
/// wrong in ways somebody notices. `Running` means the session is
|
||||||
|
/// answering, so a model taking a minute to load looks like a model
|
||||||
|
/// thinking for a minute -- and there is no way to tell from the screen
|
||||||
|
/// that the first message will be refused. `Idle` invites that message
|
||||||
|
/// and then loses it.
|
||||||
|
///
|
||||||
|
/// It exists for `llama-server`, which reads a multi-gigabyte file off
|
||||||
|
/// disk before it answers anything, and it is general because the
|
||||||
|
/// condition is: a process that is started and not yet ready is a state
|
||||||
|
/// any driver may have to report. Nothing is queued *because* of this
|
||||||
|
/// state -- a driver that reports it is responsible for holding what it
|
||||||
|
/// is sent until it can deliver it -- but this is what says so on screen.
|
||||||
|
Loading,
|
||||||
|
/// The model is reading what it was given, and has not begun answering.
|
||||||
|
///
|
||||||
|
/// Its own state for the same reason [`SessionStatus::Loading`] is, one
|
||||||
|
/// level down: prompt processing is work the machine does before a turn
|
||||||
|
/// produces anything, and on a long conversation it is the part of the
|
||||||
|
/// wait somebody is looking at. Reported as `Running` it was
|
||||||
|
/// indistinguishable from a model thinking, which is what the reader is
|
||||||
|
/// actually waiting to see.
|
||||||
|
///
|
||||||
|
/// It is still a turn in progress -- nothing settles, nothing is invited
|
||||||
|
/// -- and it is not a measurement of the prompt: it says the request is
|
||||||
|
/// out and the model has said nothing yet, which for a server serving one
|
||||||
|
/// slot is what reading the prompt looks like.
|
||||||
|
Reading,
|
||||||
|
/// The session's own turn is over, but work it started is still going:
|
||||||
|
/// a backgrounded subagent, or a command left running.
|
||||||
|
///
|
||||||
|
/// Its own state rather than `Idle` because the two differ in kind and
|
||||||
|
/// only one of them is an invitation. `Idle` means the session is
|
||||||
|
/// waiting for a person; this means it is waiting for itself, and a
|
||||||
|
/// notification saying the work had finished would have been wrong. It
|
||||||
|
/// is also not `Running`: nothing is being written to the transcript,
|
||||||
|
/// the reply that ended the turn is finished, and a spinner on a session
|
||||||
|
/// that will not speak again until a task reports back is a promise
|
||||||
|
/// nobody can keep.
|
||||||
|
Waiting,
|
||||||
Exited,
|
Exited,
|
||||||
/// There is a process recorded for this session and the machine will not
|
/// There is a process recorded for this session and the machine will not
|
||||||
/// say whether it is still running.
|
/// say whether it is still running.
|
||||||
@@ -441,13 +728,54 @@ pub enum Unqueued {
|
|||||||
/// is the backpressure-free buffer of record.
|
/// is the backpressure-free buffer of record.
|
||||||
pub type EventSink = mpsc::UnboundedSender<Event>;
|
pub type EventSink = mpsc::UnboundedSender<Event>;
|
||||||
|
|
||||||
|
/// One piece of work a session has running while it is free to do something
|
||||||
|
/// else: a backgrounded command, a subagent, whatever else a provider can
|
||||||
|
/// leave going.
|
||||||
|
///
|
||||||
|
/// Runtime state, never written to a transcript: it is what the provider
|
||||||
|
/// says right now, so a session with no process has nothing to say. Served
|
||||||
|
/// by `GET /sessions/{id}/background`; the `BackgroundTasks` event carries
|
||||||
|
/// only the size, which is what the status row draws.
|
||||||
|
///
|
||||||
|
/// [`description`](Self::description) is `None` where the provider names a
|
||||||
|
/// task by something no reader would recognise -- a process id -- rather
|
||||||
|
/// than by a sentence worked out here; the phone says it does not know.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct BackgroundTask {
|
||||||
|
/// The provider's own id for it. Never shown; it is what makes two
|
||||||
|
/// snapshots comparable, and what keys the list on the phone.
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub kind: BackgroundTaskKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What kind of thing a [`BackgroundTask`] is, in the terms the app draws.
|
||||||
|
/// `Other` is deliberately a state of its own rather than a guess: a
|
||||||
|
/// provider word this build has not seen is not a command.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum BackgroundTaskKind {
|
||||||
|
Agent,
|
||||||
|
Command,
|
||||||
|
Workflow,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
/// The inbound half of a session. Deliberately small; see PLAN.md for the
|
/// The inbound half of a session. Deliberately small; see PLAN.md for the
|
||||||
/// per-driver mapping of each method onto its dialect.
|
/// per-driver mapping of each method onto its dialect.
|
||||||
///
|
///
|
||||||
/// `send_user_message` during a run is the point of the whole app: both
|
/// `send_user_message` during a run is the point of the whole app: a dialect
|
||||||
/// real dialects queue it for injection at the next tool boundary rather
|
/// with live input injects it at the next tool boundary, while a turn-at-a-time
|
||||||
/// than the end of the turn.
|
/// dialect queues it for the next child process.
|
||||||
pub trait Driver: Send + Sync {
|
pub trait Driver: Send + Sync {
|
||||||
|
/// The background work the provider says is alive now, in the order it
|
||||||
|
/// wants it read. `None` means it has not reported, not that there is
|
||||||
|
/// none -- see [`BackgroundTask`].
|
||||||
|
fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Takes a message, now or once the session is free for it.
|
/// Takes a message, now or once the session is free for it.
|
||||||
///
|
///
|
||||||
/// Every driver owes exactly one `MessageTaken` per message, at the moment
|
/// Every driver owes exactly one `MessageTaken` per message, at the moment
|
||||||
@@ -493,6 +821,16 @@ pub trait Driver: Send + Sync {
|
|||||||
/// `/rename` afterwards, which is what puts the same name in its own
|
/// `/rename` afterwards, which is what puts the same name in its own
|
||||||
/// session picker and in what other agents see.
|
/// session picker and in what other agents see.
|
||||||
fn set_title(&self, title: &str);
|
fn set_title(&self, title: &str);
|
||||||
|
/// Takes the session's provider settings, whole.
|
||||||
|
///
|
||||||
|
/// The whole map because it is a form's contents -- an absent key means
|
||||||
|
/// "unset", not "unchanged". A driver applies what it can apply now and
|
||||||
|
/// says so about the rest: the map is also on disk by the time this is
|
||||||
|
/// called, so a setting that only takes effect at the next start is not
|
||||||
|
/// lost, it is waiting. The default is right for a driver with no settings
|
||||||
|
/// of its own, which is every one but llama.cpp -- see
|
||||||
|
/// [`crate::config::DriverKind::params`].
|
||||||
|
fn set_params(&self, _params: &std::collections::BTreeMap<String, String>) {}
|
||||||
/// Runs a command this session's own dialect understands, verbatim --
|
/// Runs a command this session's own dialect understands, verbatim --
|
||||||
/// `/context`, `/usage`, anything a CLI adds next month. A driver with no
|
/// `/context`, `/usage`, anything a CLI adds next month. A driver with no
|
||||||
/// such vocabulary says so with an [`Event::Error`] rather than sending it
|
/// such vocabulary says so with an [`Event::Error`] rather than sending it
|
||||||
@@ -557,6 +895,23 @@ pub trait Driver: Send + Sync {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A steer says so to the model and nowhere else: the note goes only into
|
||||||
|
/// what the CLI is handed, while the transcript keeps what was typed.
|
||||||
|
#[test]
|
||||||
|
fn only_a_steer_carries_the_note() {
|
||||||
|
let files = [std::path::PathBuf::from("/tmp/x/trace.txt")];
|
||||||
|
let plain = message_body("do the thing", &files, false);
|
||||||
|
assert_eq!(plain, "do the thing\n\nAttached file: /tmp/x/trace.txt");
|
||||||
|
let steer = message_body("do the thing", &files, true);
|
||||||
|
assert_eq!(steer, format!("{STEERING_NOTE}\n\n{plain}"));
|
||||||
|
// Attachments with nothing typed still need the note, and must not
|
||||||
|
// arrive with a blank line where the words would have been.
|
||||||
|
assert_eq!(
|
||||||
|
message_body("", &files, true),
|
||||||
|
format!("{STEERING_NOTE}\n\nAttached file: /tmp/x/trace.txt")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A tripwire for the wire format, not for serde. The app reads these
|
/// A tripwire for the wire format, not for serde. The app reads these
|
||||||
/// names, and getting one wrong does not fail loudly: a field the app
|
/// names, and getting one wrong does not fail loudly: a field the app
|
||||||
/// cannot find reads as a field the server chose not to send, which
|
/// cannot find reads as a field the server chose not to send, which
|
||||||
@@ -594,6 +949,8 @@ mod tests {
|
|||||||
Event::UsageDelta {
|
Event::UsageDelta {
|
||||||
tokens: 12,
|
tokens: 12,
|
||||||
context: Some(30_100),
|
context: Some(30_100),
|
||||||
|
tokens_per_second: None,
|
||||||
|
prefill_ms: None,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
Some(30_100)
|
Some(30_100)
|
||||||
@@ -633,6 +990,8 @@ mod tests {
|
|||||||
Event::UsageDelta {
|
Event::UsageDelta {
|
||||||
tokens: 12,
|
tokens: 12,
|
||||||
context: None,
|
context: None,
|
||||||
|
tokens_per_second: None,
|
||||||
|
prefill_ms: None,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
Some(30_100)
|
Some(30_100)
|
||||||
|
|||||||
+236
-15
@@ -10,6 +10,8 @@
|
|||||||
//! - `/tool [input]` -- a full tool run, start through end.
|
//! - `/tool [input]` -- a full tool run, start through end.
|
||||||
//! - `/bash [command]` -- a Bash call carrying that command, for what the
|
//! - `/bash [command]` -- a Bash call carrying that command, for what the
|
||||||
//! phone's shell highlighting does to a particular line.
|
//! phone's shell highlighting does to a particular line.
|
||||||
|
//! - `/patch` -- one common patch call, for the diff presentation shared by
|
||||||
|
//! real Codex and Claude sessions.
|
||||||
//! - `/tools [n] [gap]` -- n calls back to back. `gap` is seconds between one
|
//! - `/tools [n] [gap]` -- n calls back to back. `gap` is seconds between one
|
||||||
//! call and the next, which is what makes a run *grow* while somebody is
|
//! call and the next, which is what makes a run *grow* while somebody is
|
||||||
//! looking at it -- the only way to reach the state where a call opened on
|
//! looking at it -- the only way to reach the state where a call opened on
|
||||||
@@ -52,6 +54,15 @@
|
|||||||
//! "helper k", its prompt recorded as its own first user message: a
|
//! "helper k", its prompt recorded as its own first user message: a
|
||||||
//! streamed reply, one Bash call, then it finishes about three seconds
|
//! streamed reply, one Bash call, then it finishes about three seconds
|
||||||
//! later, the same lifecycle a real Task call has -- see `SUBAGENTS.md`.
|
//! later, the same lifecycle a real Task call has -- see `SUBAGENTS.md`.
|
||||||
|
//! The parent's own turn ends in `waiting` rather than `idle` while they
|
||||||
|
//! run, each records its closing report in its own transcript, and the
|
||||||
|
//! parent then runs a turn answering it -- which is the whole of the shape a
|
||||||
|
//! real background Task produces, and the one where two replies used to be
|
||||||
|
//! drawn as one paragraph.
|
||||||
|
//! - `/background [seconds]` -- a backgrounded *command*: the same shape with
|
||||||
|
//! no subagent behind it, so its report has nowhere to go but the card that
|
||||||
|
//! launched it. Until then that card says the command is running, which is
|
||||||
|
//! the stale claim this exists to watch being corrected.
|
||||||
//!
|
//!
|
||||||
//! `/slow` earns its place: a queued message, a Stop button and a spinner are
|
//! `/slow` earns its place: a queued message, a Stop button and a spinner are
|
||||||
//! states that only exist mid-turn, and the obvious way to get one -- ask a
|
//! states that only exist mid-turn, and the obvious way to get one -- ask a
|
||||||
@@ -64,7 +75,8 @@ use std::sync::{Arc, Mutex};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::driver::{
|
use super::driver::{
|
||||||
AttachmentRef, Driver, Event, EventSink, QuestionOption, SessionStatus, Unqueued,
|
AttachmentRef, BackgroundTask, BackgroundTaskKind, Driver, Event, EventSink, QuestionOption,
|
||||||
|
SessionStatus, Unqueued, patch_start,
|
||||||
};
|
};
|
||||||
use super::subagent::Subagents;
|
use super::subagent::Subagents;
|
||||||
|
|
||||||
@@ -113,6 +125,9 @@ pub struct EchoDriver {
|
|||||||
/// says it recovered, and a clear leaves it unmeasured. What is real is
|
/// says it recovered, and a clear leaves it unmeasured. What is real is
|
||||||
/// which way the numbers move.
|
/// which way the numbers move.
|
||||||
context: Arc<AtomicU64>,
|
context: Arc<AtomicU64>,
|
||||||
|
/// Live background commands, for the same list a real provider reports.
|
||||||
|
/// This is the deterministic UI/session-lifecycle rig for that state.
|
||||||
|
background_tasks: Arc<Mutex<Vec<BackgroundTask>>>,
|
||||||
/// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the
|
/// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the
|
||||||
/// test rig for the same registry the claude driver routes real Task
|
/// test rig for the same registry the claude driver routes real Task
|
||||||
/// calls into.
|
/// calls into.
|
||||||
@@ -430,18 +445,120 @@ impl EchoDriver {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
subagents.start(&id, &title, Some(&prompt));
|
subagents.start(&id, &title, Some(&prompt));
|
||||||
helpers.push((id, sink.clone(), Arc::clone(&subagents)));
|
helpers.push((k, id, title, sink.clone(), Arc::clone(&subagents)));
|
||||||
}
|
}
|
||||||
for (id, sink, subagents) in helpers {
|
// How many are still to report, so the last one to finish
|
||||||
tokio::spawn(run_helper(id, sink, subagents));
|
// is the one that puts the session back to idle -- see
|
||||||
|
// `SessionStatus::Waiting`.
|
||||||
|
let outstanding = Arc::new(AtomicU64::new(helpers.len() as u64));
|
||||||
|
for (k, id, title, sink, subagents) in helpers {
|
||||||
|
tokio::spawn(run_helper(
|
||||||
|
k,
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
sink,
|
||||||
|
subagents,
|
||||||
|
Arc::clone(&outstanding),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
// Not idle: the session's turn is over but its helpers are
|
||||||
|
// still going, and it will speak again with nobody having
|
||||||
|
// typed anything.
|
||||||
let _ = sink.send(Event::Status {
|
let _ = sink.send(Event::Status {
|
||||||
state: SessionStatus::Idle,
|
state: SessionStatus::Waiting,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A backgrounded command: the task shape with no subagent behind it.
|
||||||
|
// Its own verb because it is the *other* half of what a task
|
||||||
|
// notification does -- a subagent's report goes into the subagent's
|
||||||
|
// transcript, and this one has nowhere to go but the card that
|
||||||
|
// launched it, which until then is still saying the command is
|
||||||
|
// running.
|
||||||
|
if let Some(rest) = text.strip_prefix("/background") {
|
||||||
|
let seconds = rest.trim().parse::<u64>().unwrap_or(4).clamp(1, 120);
|
||||||
|
if announce {
|
||||||
|
self.emit(Event::MessageTaken {
|
||||||
|
id: None,
|
||||||
|
text: text.clone(),
|
||||||
|
attachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.emit(Event::Status {
|
||||||
|
state: SessionStatus::Running,
|
||||||
|
});
|
||||||
|
let id = format!("echo-background-{}", super::random_hex());
|
||||||
|
let command = format!("sleep {seconds} && echo done");
|
||||||
|
self.emit(Event::ToolStart {
|
||||||
|
id: id.clone(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({
|
||||||
|
"command": command,
|
||||||
|
"description": "wait a moment",
|
||||||
|
"run_in_background": true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
self.emit(Event::ToolEnd {
|
||||||
|
id: id.clone(),
|
||||||
|
output: format!("Command running in background with ID: {id}"),
|
||||||
|
});
|
||||||
|
let background_tasks = Arc::clone(&self.background_tasks);
|
||||||
|
{
|
||||||
|
let mut tasks = background_tasks.lock().unwrap();
|
||||||
|
tasks.push(BackgroundTask {
|
||||||
|
id: id.clone(),
|
||||||
|
description: Some(command.clone()),
|
||||||
|
kind: BackgroundTaskKind::Command,
|
||||||
|
});
|
||||||
|
self.emit(Event::BackgroundTasks { count: tasks.len() });
|
||||||
|
}
|
||||||
|
for word in "Started it; I'll pick this up when it lands.".split_inclusive(' ') {
|
||||||
|
self.emit(Event::AssistantText {
|
||||||
|
delta: word.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Not idle: the command is still going, and the session will speak
|
||||||
|
// again with nobody having typed anything.
|
||||||
|
self.emit(Event::Status {
|
||||||
|
state: SessionStatus::Waiting,
|
||||||
|
});
|
||||||
|
let sink = self.sink.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_secs(seconds)).await;
|
||||||
|
{
|
||||||
|
let mut tasks = background_tasks.lock().unwrap();
|
||||||
|
tasks.retain(|task| task.id != id);
|
||||||
|
let _ = sink.send(Event::BackgroundTasks { count: tasks.len() });
|
||||||
|
}
|
||||||
|
let _ = sink.send(Event::ToolUpdate {
|
||||||
|
id,
|
||||||
|
output: format!(r#"Background command "{command}" completed (exit code 0)"#),
|
||||||
|
});
|
||||||
|
let _ = sink.send(Event::Status {
|
||||||
|
state: SessionStatus::Running,
|
||||||
|
});
|
||||||
|
for word in "Done -- it finished cleanly.".split_inclusive(' ') {
|
||||||
|
let _ = sink.send(Event::AssistantText {
|
||||||
|
delta: word.to_string(),
|
||||||
|
});
|
||||||
|
tokio::time::sleep(DELTA_DELAY).await;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let tasks = background_tasks.lock().unwrap();
|
||||||
|
let _ = sink.send(Event::Status {
|
||||||
|
state: if tasks.is_empty() {
|
||||||
|
SessionStatus::Idle
|
||||||
|
} else {
|
||||||
|
SessionStatus::Waiting
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// The same word the real CLI takes, so a phone drives both the same way.
|
// The same word the real CLI takes, so a phone drives both the same way.
|
||||||
// `Driver::compact` is what the manager's route calls; this is the typed
|
// `Driver::compact` is what the manager's route calls; this is the typed
|
||||||
// path onto it.
|
// path onto it.
|
||||||
@@ -510,10 +627,15 @@ impl EchoDriver {
|
|||||||
.and_then(|w| w.parse().ok())
|
.and_then(|w| w.parse().ok())
|
||||||
.unwrap_or(3usize)
|
.unwrap_or(3usize)
|
||||||
.clamp(2, 12);
|
.clamp(2, 12);
|
||||||
// How long to wait between calls, default none. A run that arrives
|
// How long each call spends running, default none. A run that
|
||||||
// all at once cannot exercise a run *growing*: the case worth
|
// arrives all at once cannot exercise a run *growing*: the case
|
||||||
// watching is a call somebody has opened and is reading when the
|
// worth watching is a call somebody has opened and is reading when
|
||||||
// next one turns it into a group.
|
// the next one turns it into a group. Spent between the call's start
|
||||||
|
// and its end rather than between one call and the next, because
|
||||||
|
// that is where a real session's time goes -- and a call is drawn
|
||||||
|
// outside its group while it runs, which is a state nothing could
|
||||||
|
// see while every call here ended a few milliseconds after it
|
||||||
|
// started.
|
||||||
let gap = Duration::from_secs(
|
let gap = Duration::from_secs(
|
||||||
words
|
words
|
||||||
.next()
|
.next()
|
||||||
@@ -532,6 +654,7 @@ impl EchoDriver {
|
|||||||
let run_bash = text
|
let run_bash = text
|
||||||
.strip_prefix("/bash")
|
.strip_prefix("/bash")
|
||||||
.map(|rest| rest.trim().to_string());
|
.map(|rest| rest.trim().to_string());
|
||||||
|
let run_patch = text == "/patch";
|
||||||
// Seconds to stay running before answering, default 30. Clamped rather
|
// Seconds to stay running before answering, default 30. Clamped rather
|
||||||
// than trusted: a session pinned running for an hour by a typo is a
|
// than trusted: a session pinned running for an hour by a typo is a
|
||||||
// worse outcome than a short wait.
|
// worse outcome than a short wait.
|
||||||
@@ -548,6 +671,12 @@ impl EchoDriver {
|
|||||||
let table = text
|
let table = text
|
||||||
.strip_prefix("/table")
|
.strip_prefix("/table")
|
||||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(6).clamp(1, 12));
|
.map(|rest| rest.trim().parse::<usize>().unwrap_or(6).clamp(1, 12));
|
||||||
|
// Seconds to spend thinking before the reply, default three. The rig
|
||||||
|
// for the thinking card: a block that runs long enough to watch the
|
||||||
|
// spinner, then ends with a duration to read.
|
||||||
|
let think = text
|
||||||
|
.strip_prefix("/think")
|
||||||
|
.map(|rest| Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(3).clamp(1, 600)));
|
||||||
let linger = text.strip_prefix("/slow").map(|rest| {
|
let linger = text.strip_prefix("/slow").map(|rest| {
|
||||||
Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600))
|
Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600))
|
||||||
});
|
});
|
||||||
@@ -608,9 +737,6 @@ impl EchoDriver {
|
|||||||
|
|
||||||
if let Some((count, gap)) = many_tools {
|
if let Some((count, gap)) = many_tools {
|
||||||
for i in 1..=count {
|
for i in 1..=count {
|
||||||
if i > 1 {
|
|
||||||
tokio::time::sleep(gap).await;
|
|
||||||
}
|
|
||||||
let id = format!("t-{}", super::random_hex());
|
let id = format!("t-{}", super::random_hex());
|
||||||
send(Event::ToolStart {
|
send(Event::ToolStart {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
@@ -641,7 +767,7 @@ impl EchoDriver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tokio::time::sleep(DELTA_DELAY).await;
|
tokio::time::sleep(DELTA_DELAY + gap).await;
|
||||||
send(Event::ToolEnd {
|
send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: format!("call {i} finished"),
|
output: format!("call {i} finished"),
|
||||||
@@ -701,6 +827,20 @@ impl EchoDriver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if run_patch {
|
||||||
|
let id = format!("p-{}", super::random_hex());
|
||||||
|
send(patch_start(
|
||||||
|
id.clone(),
|
||||||
|
"--- src/example.rs\n+++ src/example.rs\n@@ -1,3 +1,3 @@\n fn answer() -> u8 {\n- 41\n+ 42\n }\n\n--- /dev/null\n+++ notes.md\n+- added bullet\n+added prose\n\n--- old-notes.md\n+++ /dev/null\n-- removed bullet\n-removed prose\n"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
tokio::time::sleep(DELTA_DELAY).await;
|
||||||
|
send(Event::ToolEnd {
|
||||||
|
id,
|
||||||
|
output: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(input) = run_tool {
|
if let Some(input) = run_tool {
|
||||||
let id = format!("t-{}", super::random_hex());
|
let id = format!("t-{}", super::random_hex());
|
||||||
send(Event::ToolStart {
|
send(Event::ToolStart {
|
||||||
@@ -720,10 +860,31 @@ impl EchoDriver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(think) = think {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
for remaining in (1..=think.as_secs()).rev() {
|
||||||
|
send(Event::Thinking {
|
||||||
|
delta: format!(
|
||||||
|
"Considering what to echo back, {remaining}s of it left. \
|
||||||
|
The reply is the message, which took some working out.\n\n"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
// Measured here for the same reason a driver measures it: the
|
||||||
|
// phone can only see when an event arrived.
|
||||||
|
send(Event::ThinkingDone {
|
||||||
|
ms: started.elapsed().as_millis() as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let streaming = std::time::Instant::now();
|
||||||
|
let mut words = 0u64;
|
||||||
for word in format!("You said: {text}").split_inclusive(' ') {
|
for word in format!("You said: {text}").split_inclusive(' ') {
|
||||||
send(Event::AssistantText {
|
send(Event::AssistantText {
|
||||||
delta: word.to_string(),
|
delta: word.to_string(),
|
||||||
});
|
});
|
||||||
|
words += 1;
|
||||||
tokio::time::sleep(DELTA_DELAY).await;
|
tokio::time::sleep(DELTA_DELAY).await;
|
||||||
}
|
}
|
||||||
// A conversation gets bigger, so the pretend context does too:
|
// A conversation gets bigger, so the pretend context does too:
|
||||||
@@ -732,6 +893,14 @@ impl EchoDriver {
|
|||||||
send(Event::UsageDelta {
|
send(Event::UsageDelta {
|
||||||
tokens: spent,
|
tokens: spent,
|
||||||
context: Some(context.fetch_add(spent + 100, Ordering::SeqCst) + spent + 100),
|
context: Some(context.fetch_add(spent + 100, Ordering::SeqCst) + spent + 100),
|
||||||
|
// A real measurement of a pretend model: what this driver
|
||||||
|
// emitted, over how long it took. A rig owes the app a figure
|
||||||
|
// of the shape a real one has, not an invented value.
|
||||||
|
tokens_per_second: Some(streaming.elapsed().as_secs_f64())
|
||||||
|
.filter(|elapsed| *elapsed > 0.0)
|
||||||
|
.map(|elapsed| words as f64 / elapsed),
|
||||||
|
// Nothing to read: this driver has no prompt to process.
|
||||||
|
prefill_ms: None,
|
||||||
});
|
});
|
||||||
finish();
|
finish();
|
||||||
});
|
});
|
||||||
@@ -747,6 +916,7 @@ impl EchoDriver {
|
|||||||
sink,
|
sink,
|
||||||
pending_questions: Mutex::new(Vec::new()),
|
pending_questions: Mutex::new(Vec::new()),
|
||||||
context: Arc::new(AtomicU64::new(0)),
|
context: Arc::new(AtomicU64::new(0)),
|
||||||
|
background_tasks: Arc::new(Mutex::new(Vec::new())),
|
||||||
busy: Arc::new(AtomicBool::new(false)),
|
busy: Arc::new(AtomicBool::new(false)),
|
||||||
queued: Arc::new(Mutex::new(Vec::new())),
|
queued: Arc::new(Mutex::new(Vec::new())),
|
||||||
session_dir,
|
session_dir,
|
||||||
@@ -878,7 +1048,18 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
|||||||
/// its `Running` state can be seen on the phone before it finishes. The
|
/// its `Running` state can be seen on the phone before it finishes. The
|
||||||
/// parent's own Task call for it ends at the same moment, the same way a
|
/// parent's own Task call for it ends at the same moment, the same way a
|
||||||
/// real Task's `tool_result` ends it.
|
/// real Task's `tool_result` ends it.
|
||||||
async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
|
/// One echo subagent's whole life, ending in the report its parent wakes up
|
||||||
|
/// for. `outstanding` is how many helpers are still to report; the one that
|
||||||
|
/// takes it to zero is the one that says the session is idle again, and `k` is
|
||||||
|
/// which helper this is, which is what staggers them.
|
||||||
|
async fn run_helper(
|
||||||
|
k: usize,
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
sink: EventSink,
|
||||||
|
subagents: Arc<Subagents>,
|
||||||
|
outstanding: Arc<AtomicU64>,
|
||||||
|
) {
|
||||||
let start = tokio::time::Instant::now();
|
let start = tokio::time::Instant::now();
|
||||||
for word in "Working on it now.".split_inclusive(' ') {
|
for word in "Working on it now.".split_inclusive(' ') {
|
||||||
subagents.record(
|
subagents.record(
|
||||||
@@ -906,16 +1087,52 @@ async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
|
|||||||
output: "helper done".to_string(),
|
output: "helper done".to_string(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let target = Duration::from_secs(3);
|
// Staggered, one second apart: two helpers reporting at the same instant
|
||||||
|
// interleave the parent's replies word by word, which is a fixture
|
||||||
|
// artefact -- a real CLI runs one turn at a time -- and it hides the very
|
||||||
|
// thing this is a fixture for.
|
||||||
|
let target = Duration::from_secs(2 + k as u64);
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
if elapsed < target {
|
if elapsed < target {
|
||||||
tokio::time::sleep(target - elapsed).await;
|
tokio::time::sleep(target - elapsed).await;
|
||||||
}
|
}
|
||||||
|
// The subagent's closing report, in the subagent's own transcript, which
|
||||||
|
// is where a real one's goes and the only place it belongs -- recorded
|
||||||
|
// before the ending, so it is not below it.
|
||||||
|
let summary = format!("{title} finished and had nothing to report.");
|
||||||
|
subagents.record(
|
||||||
|
&id,
|
||||||
|
Event::AssistantText {
|
||||||
|
delta: summary.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
subagents.finish(&id);
|
subagents.finish(&id);
|
||||||
let _ = sink.send(Event::ToolEnd {
|
let _ = sink.send(Event::ToolEnd {
|
||||||
id,
|
id,
|
||||||
output: "subagent finished".to_string(),
|
output: "subagent finished".to_string(),
|
||||||
});
|
});
|
||||||
|
// And then the turn the session runs because the task reported back. The
|
||||||
|
// parent has to say something afterwards, since the defect this
|
||||||
|
// reproduces is two replies meeting with nothing between them -- and
|
||||||
|
// nothing at all about the helper goes into the *parent's* transcript,
|
||||||
|
// which is the shape being reproduced.
|
||||||
|
let _ = sink.send(Event::Status {
|
||||||
|
state: SessionStatus::Running,
|
||||||
|
});
|
||||||
|
for word in format!("Noted, {title} is done.").split_inclusive(' ') {
|
||||||
|
let _ = sink.send(Event::AssistantText {
|
||||||
|
delta: word.to_string(),
|
||||||
|
});
|
||||||
|
tokio::time::sleep(DELTA_DELAY).await;
|
||||||
|
}
|
||||||
|
let last = outstanding.fetch_sub(1, Ordering::SeqCst) <= 1;
|
||||||
|
let _ = sink.send(Event::Status {
|
||||||
|
state: if last {
|
||||||
|
SessionStatus::Idle
|
||||||
|
} else {
|
||||||
|
SessionStatus::Waiting
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A message written during a turn and waiting for it to end: the id of the
|
/// A message written during a turn and waiting for it to end: the id of the
|
||||||
@@ -1010,6 +1227,10 @@ fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Driver for EchoDriver {
|
impl Driver for EchoDriver {
|
||||||
|
fn background_tasks(&self) -> Option<Vec<BackgroundTask>> {
|
||||||
|
Some(self.background_tasks.lock().unwrap().clone())
|
||||||
|
}
|
||||||
|
|
||||||
fn between_turns(&self) -> bool {
|
fn between_turns(&self) -> bool {
|
||||||
!self.busy.load(Ordering::SeqCst)
|
!self.busy.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
//!
|
//!
|
||||||
//! **The phone never names a file.** It picks an id out of what this
|
//! **The phone never names a file.** It picks an id out of what this
|
||||||
//! module enumerated, and the path is looked up again on the server -- the
|
//! module enumerated, and the path is looked up again on the server -- the
|
||||||
//! same rule the setups model follows for providers, and for the same
|
//! same rule the machines model follows for providers, and for the same
|
||||||
//! reason: an enrolled token must not be able to turn into "read me this
|
//! reason: an enrolled token must not be able to turn into "read me this
|
||||||
//! arbitrary path".
|
//! arbitrary path".
|
||||||
|
|
||||||
|
|||||||
@@ -1,902 +0,0 @@
|
|||||||
//! The llama.cpp driver: a `llama-server` process per session, spoken to over
|
|
||||||
//! its OpenAI-compatible HTTP API and translated into the common event model.
|
|
||||||
//!
|
|
||||||
//! Two things make this shaped differently from the Claude driver.
|
|
||||||
//!
|
|
||||||
//! **It is spawned but not spoken to over stdio.** The process is started
|
|
||||||
//! through the same [`Transport`] as any other and then reached over HTTP on a
|
|
||||||
//! loopback port. That is the second half of what a transport is -- "run this"
|
|
||||||
//! plus "reach this port" -- and it is what lets a session run on another
|
|
||||||
//! machine: [`Transport::reserve_port`] hands back a port the server binds
|
|
||||||
//! *there* and one that reaches it *here*, and the ssh connection carrying the
|
|
||||||
//! command carries the tunnel between them. The far `llama-server` binds
|
|
||||||
//! loopback only, so a model is never served to that machine's network.
|
|
||||||
//!
|
|
||||||
//! **The model file is the far machine's, not this one's.** A remote setup
|
|
||||||
//! names its own models directory (`SshConfig::models_dir`, defaulting to where
|
|
||||||
//! this backend keeps its downloads), and the file is looked for *there* -- so
|
|
||||||
//! a session naming a model that machine does not have says so, instead of
|
|
||||||
//! starting a server that will never load one. Downloading to another machine
|
|
||||||
//! is not built; the model gets there however anything else does.
|
|
||||||
//!
|
|
||||||
//! **The server is stateless between requests**, so the whole conversation goes
|
|
||||||
//! with every one. It is rebuilt from the session's transcript rather than kept
|
|
||||||
//! in this struct, which is not tidiness: a copy in driver memory is invisible
|
|
||||||
//! to a second device and gone when this process restarts.
|
|
||||||
//!
|
|
||||||
//! That leaves the Claude driver as the odd one out rather than this one -- the
|
|
||||||
//! CLI's own memory of a conversation is a cache in front of the same
|
|
||||||
//! transcript. Resolve any inconsistency in this direction.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus};
|
|
||||||
use super::process;
|
|
||||||
use super::transport::{Launch, Streams, Transport};
|
|
||||||
use crate::config::{ProviderConfig, SessionConfig};
|
|
||||||
|
|
||||||
/// How long to wait for a model to load before giving up. Loading is mostly
|
|
||||||
/// disk, and a large quantised model on a cold cache is genuinely slow, so this
|
|
||||||
/// is generous -- the failure it exists for is a server that will never answer.
|
|
||||||
const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
|
||||||
|
|
||||||
/// One turn in the conversation this driver keeps on the server's behalf.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
struct Message {
|
|
||||||
role: String,
|
|
||||||
content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct LlamaDriver {
|
|
||||||
sink: EventSink,
|
|
||||||
/// Where this session's own llama-server answers.
|
|
||||||
endpoint: String,
|
|
||||||
/// Where the conversation is read back from, one line per event.
|
|
||||||
transcript: PathBuf,
|
|
||||||
/// Sampling settings chosen at spawn, sent with every request.
|
|
||||||
sampling: serde_json::Map<String, serde_json::Value>,
|
|
||||||
/// Set by [`Driver::interrupt`]; the streaming loop checks it between
|
|
||||||
/// chunks and stops, leaving what was generated in the transcript.
|
|
||||||
cancel: Arc<AtomicBool>,
|
|
||||||
/// Where this session's process record lives, so [`Driver::stop`] can find
|
|
||||||
/// the server it has to end.
|
|
||||||
session_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LlamaDriver {
|
|
||||||
/// Takes charge of this session's `llama-server`: the one already loaded if
|
|
||||||
/// there is one, otherwise a new one.
|
|
||||||
///
|
|
||||||
/// One entry point, for the reason `ClaudeDriver::launch` gives, expensive
|
|
||||||
/// in a different currency: two servers holding the same model is twice the
|
|
||||||
/// memory, and the second would bind a different port while the phone kept
|
|
||||||
/// talking to the first.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn launch(
|
|
||||||
meta: &SessionConfig,
|
|
||||||
provider: &ProviderConfig,
|
|
||||||
transport: &Transport,
|
|
||||||
models_dir: &Path,
|
|
||||||
transcript: &Path,
|
|
||||||
session_dir: &Path,
|
|
||||||
sink: EventSink,
|
|
||||||
// llama.cpp has no notion of a Task call, so this is accepted only
|
|
||||||
// to keep one shape across every driver's launch -- see
|
|
||||||
// `SUBAGENTS.md`'s "Server layout".
|
|
||||||
_subagents: Arc<super::subagent::Subagents>,
|
|
||||||
) -> Result<Self> {
|
|
||||||
let model = meta.model.as_deref().context(
|
|
||||||
"a llama.cpp session needs a model -- one of the downloaded ones, by its key",
|
|
||||||
)?;
|
|
||||||
let path = model_on(transport, models_dir, model)?;
|
|
||||||
|
|
||||||
// Already loaded and still running: keep talking to it. The health poll
|
|
||||||
// below confirms it is really answering, so adopting a pid whose server
|
|
||||||
// has wedged still reports as a failure rather than as a session that
|
|
||||||
// silently never replies.
|
|
||||||
if let Some(process::Record {
|
|
||||||
detail: process::Detail::Http { port },
|
|
||||||
pid,
|
|
||||||
..
|
|
||||||
}) = process::live(session_dir)
|
|
||||||
{
|
|
||||||
tracing::info!(
|
|
||||||
"session {} reattaching to the llama-server it left loaded (pid {pid}, port {port})",
|
|
||||||
meta.id
|
|
||||||
);
|
|
||||||
return Ok(Self::attached(
|
|
||||||
format!("http://127.0.0.1:{port}"),
|
|
||||||
meta,
|
|
||||||
model,
|
|
||||||
transcript,
|
|
||||||
session_dir,
|
|
||||||
sink,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Where it listens on its own machine, and where that is reached
|
|
||||||
// from here -- the same number when that machine is this one.
|
|
||||||
let forward = transport
|
|
||||||
.reserve_port()
|
|
||||||
.context("finding a port for llama-server")?;
|
|
||||||
let mut args: Vec<String> = vec![
|
|
||||||
"-m".into(),
|
|
||||||
path.clone(),
|
|
||||||
// Loopback there, whichever machine there is: what reaches it
|
|
||||||
// from outside that machine is the ssh tunnel and nothing
|
|
||||||
// else.
|
|
||||||
"--host".into(),
|
|
||||||
"127.0.0.1".into(),
|
|
||||||
"--port".into(),
|
|
||||||
forward.there.to_string(),
|
|
||||||
];
|
|
||||||
// Settings that belong to the server because they decide how the model
|
|
||||||
// is loaded; the sampling ones ride on each request instead, so changing
|
|
||||||
// them later needn't reload anything.
|
|
||||||
for (key, flag) in [
|
|
||||||
("contextSize", "-c"),
|
|
||||||
("gpuLayers", "-ngl"),
|
|
||||||
("threads", "-t"),
|
|
||||||
] {
|
|
||||||
if let Some(value) = meta.params.get(key) {
|
|
||||||
args.push(flag.to_string());
|
|
||||||
args.push(value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let program = provider.program();
|
|
||||||
let launch = Launch::new(program, args, meta.cwd.as_deref()).reaching(forward);
|
|
||||||
// Its output goes to files, not pipes. Not only so the process can
|
|
||||||
// outlive this server: nothing ever read those pipes, so a chatty
|
|
||||||
// llama-server filled the 64 KB buffer and blocked mid-load with no sign
|
|
||||||
// of why.
|
|
||||||
let child = transport.spawn(
|
|
||||||
&launch,
|
|
||||||
Streams::Detached {
|
|
||||||
stdin: std::process::Stdio::null(),
|
|
||||||
stdout: log_file(&session_dir.join(SERVER_LOG))?.into(),
|
|
||||||
stderr: log_file(&session_dir.join(SERVER_LOG))?.into(),
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
let pid = child
|
|
||||||
.id()
|
|
||||||
.context("llama-server exited before it could be recorded")?;
|
|
||||||
tracing::info!(
|
|
||||||
"session {} running {program} for {model} {} on 127.0.0.1:{} there, \
|
|
||||||
reached at 127.0.0.1:{} here, as pid {pid}",
|
|
||||||
meta.id,
|
|
||||||
transport.describe(),
|
|
||||||
forward.there,
|
|
||||||
forward.here,
|
|
||||||
);
|
|
||||||
// Reaped so it does not become a zombie while this server is still its
|
|
||||||
// parent; the health poll and the record are what say whether the
|
|
||||||
// session is alive, because after a restart there is no `Child` to ask.
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut child = child;
|
|
||||||
let _ = child.wait().await;
|
|
||||||
});
|
|
||||||
|
|
||||||
// The *near* port, because that is the one anything reaching this
|
|
||||||
// server has to dial -- including a later run of this backend,
|
|
||||||
// which adopts the record without knowing which machine the server
|
|
||||||
// is on. For a remote session the recorded pid is the ssh
|
|
||||||
// client's, which is the process this machine owns and which holds
|
|
||||||
// the tunnel open for exactly as long as the far server lives.
|
|
||||||
let record = process::Record::of(pid, process::Detail::Http { port: forward.here })
|
|
||||||
.context("llama-server was gone before its start time could be read")?;
|
|
||||||
process::write(session_dir, &record);
|
|
||||||
|
|
||||||
Ok(Self::attached(
|
|
||||||
format!("http://127.0.0.1:{}", forward.here),
|
|
||||||
meta,
|
|
||||||
model,
|
|
||||||
transcript,
|
|
||||||
session_dir,
|
|
||||||
sink,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The driver for a `llama-server` at `endpoint`, however it got there.
|
|
||||||
///
|
|
||||||
/// Shared by starting one and adopting one, because everything after "there
|
|
||||||
/// is a server at this address" is identical -- including waiting for it to
|
|
||||||
/// answer, which an adopted one still owes: a recorded pid says a process
|
|
||||||
/// exists, not that its model is loaded.
|
|
||||||
fn attached(
|
|
||||||
endpoint: String,
|
|
||||||
meta: &SessionConfig,
|
|
||||||
model: &str,
|
|
||||||
transcript: &Path,
|
|
||||||
session_dir: &Path,
|
|
||||||
sink: EventSink,
|
|
||||||
) -> Self {
|
|
||||||
// Loading is slow enough to be worth saying so: the session shows as
|
|
||||||
// running until the model is in memory, rather than looking ready and
|
|
||||||
// refusing the first message.
|
|
||||||
let _ = sink.send(Event::Status {
|
|
||||||
state: SessionStatus::Running,
|
|
||||||
});
|
|
||||||
{
|
|
||||||
let sink = sink.clone();
|
|
||||||
let endpoint = endpoint.clone();
|
|
||||||
let model = model.to_string();
|
|
||||||
let session_dir = session_dir.to_path_buf();
|
|
||||||
std::thread::spawn(move || match wait_until_ready(&endpoint, &session_dir) {
|
|
||||||
Ok(()) => {
|
|
||||||
tracing::info!("{model} loaded and answering at {endpoint}");
|
|
||||||
let _ = sink.send(Event::Status {
|
|
||||||
state: SessionStatus::Idle,
|
|
||||||
});
|
|
||||||
watch(session_dir, sink);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
let _ = sink.send(Event::Error {
|
|
||||||
message: format!("{model} never became ready: {err:#}"),
|
|
||||||
});
|
|
||||||
let _ = sink.send(Event::Status {
|
|
||||||
state: SessionStatus::Exited,
|
|
||||||
});
|
|
||||||
process::clear(&session_dir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut sampling = serde_json::Map::new();
|
|
||||||
for (key, field) in [
|
|
||||||
("temperature", "temperature"),
|
|
||||||
("topP", "top_p"),
|
|
||||||
("topK", "top_k"),
|
|
||||||
("maxTokens", "max_tokens"),
|
|
||||||
] {
|
|
||||||
if let Some(raw) = meta.params.get(key)
|
|
||||||
&& let Ok(number) = raw.parse::<f64>()
|
|
||||||
{
|
|
||||||
sampling.insert(field.to_string(), json!(number));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
|
||||||
sink,
|
|
||||||
endpoint,
|
|
||||||
transcript: transcript.to_path_buf(),
|
|
||||||
sampling,
|
|
||||||
cancel: Arc::new(AtomicBool::new(false)),
|
|
||||||
session_dir: session_dir.to_path_buf(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where llama-server's own output goes. One file for both streams: it is
|
|
||||||
/// diagnostics nobody parses, and interleaving them is how it reads in a
|
|
||||||
/// terminal anyway.
|
|
||||||
const SERVER_LOG: &str = "llama-server.log";
|
|
||||||
|
|
||||||
/// How often a loaded server is checked for still being there. Slower than the
|
|
||||||
/// Claude driver's stdout poll because nothing is waiting on it: this only has
|
|
||||||
/// to notice a server that has gone.
|
|
||||||
const WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
|
|
||||||
|
|
||||||
/// An owner-only log opened for appending, so the two streams pointed at
|
|
||||||
/// it do not overwrite each other and a reattach keeps what came before.
|
|
||||||
fn log_file(path: &Path) -> Result<std::fs::File> {
|
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
|
||||||
std::fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(path)
|
|
||||||
.with_context(|| format!("opening {}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reports the server going away, for as long as the session is there to report
|
|
||||||
/// it to.
|
|
||||||
///
|
|
||||||
/// Polled rather than waited on, for the reason the Claude driver gives: after a
|
|
||||||
/// restart this server is not the process's parent, so liveness has to be a
|
|
||||||
/// question asked of the record -- and asking it two different ways is how the
|
|
||||||
/// two answers come to disagree.
|
|
||||||
fn watch(session_dir: PathBuf, sink: EventSink) {
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
loop {
|
|
||||||
std::thread::sleep(WATCH_INTERVAL);
|
|
||||||
match process::recorded(&session_dir) {
|
|
||||||
Some((_, process::Liveness::Alive)) => {}
|
|
||||||
// Nothing recorded means the session was stopped or deleted
|
|
||||||
// deliberately, and whoever did that has already said so.
|
|
||||||
None => return,
|
|
||||||
Some((_, process::Liveness::Dead)) => {
|
|
||||||
let _ = sink.send(Event::Error {
|
|
||||||
message: "llama-server exited".to_string(),
|
|
||||||
});
|
|
||||||
let _ = sink.send(Event::Status {
|
|
||||||
state: SessionStatus::Exited,
|
|
||||||
});
|
|
||||||
process::clear(&session_dir);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Some((_, process::Liveness::Unknown)) => {
|
|
||||||
let _ = sink.send(Event::Status {
|
|
||||||
state: SessionStatus::Unknown,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sink.is_closed() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Driver for LlamaDriver {
|
|
||||||
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
|
|
||||||
if !attachments.is_empty() {
|
|
||||||
let _ = self.sink.send(Event::Error {
|
|
||||||
message: "this model can't be sent attachments or files".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let sink = self.sink.clone();
|
|
||||||
let endpoint = self.endpoint.clone();
|
|
||||||
let transcript = self.transcript.clone();
|
|
||||||
let sampling = self.sampling.clone();
|
|
||||||
let cancel = Arc::clone(&self.cancel);
|
|
||||||
cancel.store(false, Ordering::Relaxed);
|
|
||||||
|
|
||||||
// Its own thread: the request blocks for as long as the model takes to
|
|
||||||
// generate, which is the whole point of streaming it.
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
// Nothing is ever held back here -- there is no queue to wait in --
|
|
||||||
// so the message is taken the moment it arrives. Said anyway,
|
|
||||||
// because this is what records it: see `MessageTaken`.
|
|
||||||
let _ = sink.send(Event::MessageTaken {
|
|
||||||
id: None,
|
|
||||||
text: text.clone(),
|
|
||||||
// Never any: this driver refuses attachments above.
|
|
||||||
attachments: Vec::new(),
|
|
||||||
});
|
|
||||||
let _ = sink.send(Event::Status {
|
|
||||||
state: SessionStatus::Running,
|
|
||||||
});
|
|
||||||
// Everything before this message, plus this message. Read rather
|
|
||||||
// than remembered, and `text` is appended here rather than waited
|
|
||||||
// for, because the message's own transcript entry is still on its
|
|
||||||
// way when this runs.
|
|
||||||
let mut messages = conversation(&transcript);
|
|
||||||
messages.push(Message {
|
|
||||||
role: "user".into(),
|
|
||||||
content: text,
|
|
||||||
});
|
|
||||||
// The reply is not stored: the deltas below are the durable record,
|
|
||||||
// so the next turn reads back exactly what the phone was shown --
|
|
||||||
// including a partial one that was interrupted.
|
|
||||||
if let Err(err) = generate(&endpoint, &messages, &sampling, &cancel, &sink) {
|
|
||||||
let _ = sink.send(Event::Error {
|
|
||||||
message: format!("{err:#}"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let _ = sink.send(Event::Status {
|
|
||||||
state: SessionStatus::Idle,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn answer_question(&self, _id: &str, _answers: &[String]) {
|
|
||||||
// Nothing here asks questions: this driver has no tools.
|
|
||||||
}
|
|
||||||
|
|
||||||
fn interrupt(&self) {
|
|
||||||
self.cancel.store(true, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing to forward: this process has no notion of what the conversation
|
|
||||||
// is called, and the rename has already happened where the name lives.
|
|
||||||
fn set_title(&self, _title: &str) {}
|
|
||||||
|
|
||||||
fn set_permission_mode(&self, _mode: &str) {
|
|
||||||
let _ = self.sink.send(Event::Error {
|
|
||||||
message: "a llama.cpp session runs no tools, so there is nothing for a permission \
|
|
||||||
mode to govern."
|
|
||||||
.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_model(&self, _model: &str) {
|
|
||||||
let _ = self.sink.send(Event::Error {
|
|
||||||
message: "a llama.cpp session's model is fixed when it starts, because the server \
|
|
||||||
loads one model into memory. Spawn another session to use a different one."
|
|
||||||
.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_command(&self, text: &str) {
|
|
||||||
let _ = self.sink.send(Event::Error {
|
|
||||||
message: format!(
|
|
||||||
"a llama.cpp session has no commands of its own, so {text} means nothing to it."
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compact(&self) {
|
|
||||||
let _ = self.sink.send(Event::Error {
|
|
||||||
message: "llama.cpp has no compaction. Clear the session instead, which costs nothing."
|
|
||||||
.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear(&self) {
|
|
||||||
// All of it. `conversation` folds from the last of these, so recording
|
|
||||||
// the marker *is* the reset -- there is no driver state to keep in step
|
|
||||||
// with it, which is the same property that makes a second device see the
|
|
||||||
// same conversation this one does.
|
|
||||||
let _ = self.sink.send(Event::Cleared);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stops generating and leaves the server loaded.
|
|
||||||
///
|
|
||||||
/// Worth being deliberate about, because the cost points the other way from
|
|
||||||
/// the Claude driver's: a `llama-server` holds its whole model in memory, so
|
|
||||||
/// a leaked one is gigabytes nobody is using. It is left anyway, because the
|
|
||||||
/// alternative is unloading and reloading that model on every backend
|
|
||||||
/// restart -- minutes of disk, for a session somebody is in the middle of.
|
|
||||||
/// The record is what keeps it from being *nobody's*.
|
|
||||||
fn detach(&self) {
|
|
||||||
self.cancel.store(true, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stop(&self) {
|
|
||||||
self.cancel.store(true, Ordering::Relaxed);
|
|
||||||
if let Some(record) = process::live(&self.session_dir) {
|
|
||||||
process::stop(&record, process::STOP_GRACE);
|
|
||||||
}
|
|
||||||
process::clear(&self.session_dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The conversation so far, folded out of the transcript.
|
|
||||||
///
|
|
||||||
/// Consecutive `AssistantText` deltas are one assistant turn, closed by the next
|
|
||||||
/// user message -- which is also what makes an interrupted reply come back as
|
|
||||||
/// the partial text the phone actually saw.
|
|
||||||
///
|
|
||||||
/// This must stay a pure function of the transcript and must never re-render
|
|
||||||
/// earlier turns. llama.cpp caches the prompt prefix, so a growing conversation
|
|
||||||
/// reprocesses almost nothing -- but only while every turn is byte-identical to
|
|
||||||
/// last time. Changing how an old turn is rendered silently reprocesses the
|
|
||||||
/// whole history on every message.
|
|
||||||
fn conversation(path: &Path) -> Vec<Message> {
|
|
||||||
let Ok(events) = crate::session::transcript::read_after(path, 0) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let mut messages: Vec<Message> = Vec::new();
|
|
||||||
let mut pending = String::new();
|
|
||||||
// Everything before the last clear is still in the transcript and is
|
|
||||||
// deliberately not in the conversation. Folding from zero would put it back,
|
|
||||||
// which is the whole of what clearing had to undo.
|
|
||||||
let events = match events.iter().rposition(|e| e.event == Event::Cleared) {
|
|
||||||
Some(at) => &events[at + 1..],
|
|
||||||
None => &events[..],
|
|
||||||
};
|
|
||||||
for event in events.iter().cloned() {
|
|
||||||
match event.event {
|
|
||||||
Event::UserMessage { text, .. } => {
|
|
||||||
if !pending.is_empty() {
|
|
||||||
messages.push(Message {
|
|
||||||
role: "assistant".into(),
|
|
||||||
content: std::mem::take(&mut pending),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
messages.push(Message {
|
|
||||||
role: "user".into(),
|
|
||||||
content: text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Event::AssistantText { delta } => pending.push_str(&delta),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !pending.is_empty() {
|
|
||||||
messages.push(Message {
|
|
||||||
role: "assistant".into(),
|
|
||||||
content: pending,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
messages
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where a model key resolves to on disk, refusing anything that climbs
|
|
||||||
/// out of the models directory -- the key arrives from a phone.
|
|
||||||
fn model_path(models_dir: &Path, key: &str) -> Result<PathBuf> {
|
|
||||||
let mut path = models_dir.to_path_buf();
|
|
||||||
for part in key.split('/') {
|
|
||||||
if part.is_empty() || part == "." || part == ".." {
|
|
||||||
bail!("\"{key}\" is not a model key this can resolve");
|
|
||||||
}
|
|
||||||
path.push(part);
|
|
||||||
}
|
|
||||||
if !path.is_file() {
|
|
||||||
bail!("no downloaded model called \"{key}\" -- download it first");
|
|
||||||
}
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The model file's path **on the machine that will serve it**, confirmed to be
|
|
||||||
/// there.
|
|
||||||
///
|
|
||||||
/// One function rather than a local check and hope for the other case: the same
|
|
||||||
/// question has to be asked of two filesystems. The remote answer is measured
|
|
||||||
/// for the reason the local one is -- a missing file otherwise becomes a
|
|
||||||
/// `llama-server` that starts, fails to load, and reports as a session that
|
|
||||||
/// never became ready, which reads as the machine being slow.
|
|
||||||
///
|
|
||||||
/// One blocking round trip on a remote spawn, which is what the spawn is
|
|
||||||
/// already paying to start ssh. The alternative is a path built here from a `~`
|
|
||||||
/// this machine cannot expand.
|
|
||||||
fn model_on(transport: &Transport, models_dir: &Path, key: &str) -> Result<String> {
|
|
||||||
let Transport::Ssh { name, .. } = transport else {
|
|
||||||
return Ok(model_path(models_dir, key)?.to_string_lossy().into_owned());
|
|
||||||
};
|
|
||||||
// The same directory the spawn screen listed for this machine, and one
|
|
||||||
// function for the same reason: a list from one place and a load from
|
|
||||||
// another is a model that appears and then fails.
|
|
||||||
let dir = crate::models::dir_on(transport, models_dir);
|
|
||||||
// Checked here rather than in the script: `..` in a key would walk out of
|
|
||||||
// the models directory on a machine this server can start processes on,
|
|
||||||
// and the phone is where the key comes from.
|
|
||||||
for part in key.split('/') {
|
|
||||||
if part.is_empty() || part == "." || part == ".." {
|
|
||||||
bail!("\"{key}\" is not a model key this can resolve");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let path = format!("{}/{key}", dir.trim_end_matches('/'));
|
|
||||||
// `$HOME` on the far side, which is the only machine that knows what it is,
|
|
||||||
// and the resolved path printed back so the launch hands `llama-server`
|
|
||||||
// something absolute. "Not there" is answered rather than failed, because a
|
|
||||||
// machine that could not be asked at all has to say so in its own words --
|
|
||||||
// it would otherwise arrive as this same sentence about a missing model.
|
|
||||||
let script = "p=$1; case $p in \"~\") p=$HOME;; \"~/\"*) p=$HOME/${p#\"~/\"};; esac; \
|
|
||||||
[ -f \"$p\" ] && printf 'at\\t%s\\n' \"$p\" || printf 'missing\\n'"
|
|
||||||
.to_string();
|
|
||||||
let launch = Launch::new(
|
|
||||||
"sh",
|
|
||||||
vec!["-c".to_string(), script, "sh".to_string(), path.clone()],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let answer = transport
|
|
||||||
.capture_blocking(&launch)
|
|
||||||
.with_context(|| format!("couldn't ask {name} where its models are"))?;
|
|
||||||
match answer.trim().split_once('\t') {
|
|
||||||
Some(("at", resolved)) => Ok(resolved.to_string()),
|
|
||||||
_ => bail!(
|
|
||||||
"{name} has no model at {path}. A llama.cpp session serves the file from the \
|
|
||||||
machine it runs on, so the model has to be on {name} -- what this backend has \
|
|
||||||
downloaded is somewhere else."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Polls until the server says it is ready, or gives up.
|
|
||||||
///
|
|
||||||
/// Watches the process as well as the port, because the two failures need
|
|
||||||
/// different words and one of them is common: a model that will not load,
|
|
||||||
/// a port already taken on the far machine, a `llama-server` too old for
|
|
||||||
/// a flag. All of those exit within a second and none of them will ever
|
|
||||||
/// answer `/health`, so waiting out the timeout turns a server that said
|
|
||||||
/// exactly what was wrong into "gave up after 300s".
|
|
||||||
fn wait_until_ready(endpoint: &str, session_dir: &Path) -> Result<()> {
|
|
||||||
let deadline = std::time::Instant::now() + READY_TIMEOUT;
|
|
||||||
let url = format!("{endpoint}/health");
|
|
||||||
loop {
|
|
||||||
if let Ok(response) = ureq::get(&url).call()
|
|
||||||
&& response.status() == 200
|
|
||||||
{
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// `None` is the session having been stopped or deleted while this
|
|
||||||
// waited, which is nobody's fault and still not worth waiting on.
|
|
||||||
match process::recorded(session_dir) {
|
|
||||||
Some((_, process::Liveness::Alive | process::Liveness::Unknown)) => {}
|
|
||||||
Some((_, process::Liveness::Dead)) | None => {
|
|
||||||
bail!("it exited before it answered.{}", log_tail(session_dir));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if std::time::Instant::now() > deadline {
|
|
||||||
bail!(
|
|
||||||
"gave up after {}s.{}",
|
|
||||||
READY_TIMEOUT.as_secs(),
|
|
||||||
log_tail(session_dir)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The end of `llama-server`'s own log, for a failure message.
|
|
||||||
///
|
|
||||||
/// Its account of what went wrong is the useful half -- "failed to load
|
|
||||||
/// model", "bind: Address already in use" -- and on a remote session it
|
|
||||||
/// is the only half, since nobody reading the phone can open a file on
|
|
||||||
/// that machine. Bounded, because this ends up in an event a phone draws.
|
|
||||||
fn log_tail(session_dir: &Path) -> String {
|
|
||||||
let Ok(text) = std::fs::read_to_string(session_dir.join(SERVER_LOG)) else {
|
|
||||||
return String::new();
|
|
||||||
};
|
|
||||||
let tail: Vec<&str> = text.lines().rev().take(LOG_TAIL_LINES).collect();
|
|
||||||
if tail.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
format!(
|
|
||||||
" It last said: {}",
|
|
||||||
tail.into_iter().rev().collect::<Vec<_>>().join(" / ")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How much of that log to carry into a message somebody reads on a phone.
|
|
||||||
const LOG_TAIL_LINES: usize = 6;
|
|
||||||
|
|
||||||
/// One streamed completion: posts the conversation, emits each delta as it
|
|
||||||
/// arrives. Emits rather than returns, because the transcript those events land
|
|
||||||
/// in is what the next turn reads back.
|
|
||||||
fn generate(
|
|
||||||
endpoint: &str,
|
|
||||||
messages: &[Message],
|
|
||||||
sampling: &serde_json::Map<String, serde_json::Value>,
|
|
||||||
cancel: &AtomicBool,
|
|
||||||
sink: &EventSink,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut body = json!({
|
|
||||||
"messages": messages,
|
|
||||||
"stream": true,
|
|
||||||
"stream_options": {"include_usage": true},
|
|
||||||
});
|
|
||||||
let map = body.as_object_mut().expect("built as an object");
|
|
||||||
for (key, value) in sampling {
|
|
||||||
map.insert(key.clone(), value.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut response = ureq::post(format!("{endpoint}/v1/chat/completions"))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.send_json(&body)
|
|
||||||
.context("asking llama-server to generate")?;
|
|
||||||
|
|
||||||
let reader = std::io::BufReader::new(response.body_mut().as_reader());
|
|
||||||
let mut tokens = 0u64;
|
|
||||||
// The prompt side only, which is what the model is holding -- the same
|
|
||||||
// definition the other dialects report, so one word on the phone means one
|
|
||||||
// thing whichever kind of session it is.
|
|
||||||
let mut context = None;
|
|
||||||
for line in std::io::BufRead::lines(reader) {
|
|
||||||
if cancel.load(Ordering::Relaxed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let line = line.context("reading the generation stream")?;
|
|
||||||
// Server-sent events: the payload lines are the ones that matter.
|
|
||||||
let Some(payload) = line.strip_prefix("data: ") else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if payload.trim() == "[DONE]" {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let Ok(chunk) = serde_json::from_str::<serde_json::Value>(payload) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Some(usage) = chunk.get("usage") {
|
|
||||||
if let Some(total) = usage
|
|
||||||
.get("total_tokens")
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
{
|
|
||||||
tokens = total;
|
|
||||||
}
|
|
||||||
if let Some(prompt) = usage
|
|
||||||
.get("prompt_tokens")
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
{
|
|
||||||
context = Some(prompt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let delta = chunk
|
|
||||||
.get("choices")
|
|
||||||
.and_then(|c| c.get(0))
|
|
||||||
.and_then(|c| c.get("delta"))
|
|
||||||
.and_then(|d| d.get("content"))
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !delta.is_empty() {
|
|
||||||
let _ = sink.send(Event::AssistantText {
|
|
||||||
delta: delta.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tokens > 0 {
|
|
||||||
let _ = sink.send(Event::UsageDelta { tokens, context });
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::session::transcript::Transcript;
|
|
||||||
|
|
||||||
/// Writes a transcript the way the pump does, so the fold is tested against
|
|
||||||
/// the real file format rather than a hand-built vector.
|
|
||||||
fn transcript_with(events: &[Event]) -> (tempfile::TempDir, PathBuf) {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("transcript.jsonl");
|
|
||||||
let mut transcript = Transcript::open(&path).expect("open");
|
|
||||||
for event in events {
|
|
||||||
transcript.append(event.clone(), 0.0).expect("append");
|
|
||||||
}
|
|
||||||
(dir, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deltas_between_user_messages_are_one_assistant_turn() {
|
|
||||||
let (_dir, path) = transcript_with(&[
|
|
||||||
Event::UserMessage {
|
|
||||||
id: None,
|
|
||||||
text: "hello".into(),
|
|
||||||
attachments: Vec::new(),
|
|
||||||
},
|
|
||||||
Event::AssistantText {
|
|
||||||
delta: "hi ".into(),
|
|
||||||
},
|
|
||||||
Event::AssistantText {
|
|
||||||
delta: "there".into(),
|
|
||||||
},
|
|
||||||
Event::Status {
|
|
||||||
state: SessionStatus::Idle,
|
|
||||||
},
|
|
||||||
Event::UserMessage {
|
|
||||||
id: None,
|
|
||||||
text: "again".into(),
|
|
||||||
attachments: Vec::new(),
|
|
||||||
},
|
|
||||||
Event::AssistantText {
|
|
||||||
delta: "yes".into(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
let messages = conversation(&path);
|
|
||||||
assert_eq!(
|
|
||||||
messages
|
|
||||||
.iter()
|
|
||||||
.map(|m| (m.role.as_str(), m.content.as_str()))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
[
|
|
||||||
("user", "hello"),
|
|
||||||
("assistant", "hi there"),
|
|
||||||
("user", "again"),
|
|
||||||
("assistant", "yes")
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
/// The interrupted case, which decides what a resumed conversation is built
|
|
||||||
/// from: whatever the phone was shown. The deltas that arrived before the
|
|
||||||
/// stop are in the transcript, so they are in the prompt -- the model is
|
|
||||||
/// never told it said something the user did not see.
|
|
||||||
fn an_interrupted_reply_stays_in_the_conversation() {
|
|
||||||
let (_dir, path) = transcript_with(&[
|
|
||||||
Event::UserMessage {
|
|
||||||
id: None,
|
|
||||||
text: "count".into(),
|
|
||||||
attachments: Vec::new(),
|
|
||||||
},
|
|
||||||
Event::AssistantText {
|
|
||||||
delta: "one two".into(),
|
|
||||||
},
|
|
||||||
Event::Status {
|
|
||||||
state: SessionStatus::Idle,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
let messages = conversation(&path);
|
|
||||||
assert_eq!(messages.len(), 2);
|
|
||||||
assert_eq!(messages[1].content, "one two");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
/// Events this driver does not produce must not disturb the fold: a
|
|
||||||
/// transcript can carry errors and status changes from a session that
|
|
||||||
/// was, say, relaunched.
|
|
||||||
fn other_events_are_not_part_of_the_conversation() {
|
|
||||||
let (_dir, path) = transcript_with(&[
|
|
||||||
Event::Status {
|
|
||||||
state: SessionStatus::Running,
|
|
||||||
},
|
|
||||||
Event::UserMessage {
|
|
||||||
id: None,
|
|
||||||
text: "hello".into(),
|
|
||||||
attachments: Vec::new(),
|
|
||||||
},
|
|
||||||
Event::Error {
|
|
||||||
message: "something went wrong".into(),
|
|
||||||
},
|
|
||||||
Event::AssistantText {
|
|
||||||
delta: "still here".into(),
|
|
||||||
},
|
|
||||||
Event::UsageDelta {
|
|
||||||
tokens: 12,
|
|
||||||
context: Some(12),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
let messages = conversation(&path);
|
|
||||||
assert_eq!(messages.len(), 2);
|
|
||||||
assert_eq!(messages[0].content, "hello");
|
|
||||||
assert_eq!(messages[1].content, "still here");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
/// Clearing decides what the *model* is given, not just what the phone
|
|
||||||
/// draws. Everything above the marker stays in the transcript and none of it
|
|
||||||
/// is sent.
|
|
||||||
fn the_conversation_starts_after_the_last_clear() {
|
|
||||||
let (_dir, path) = transcript_with(&[
|
|
||||||
Event::UserMessage {
|
|
||||||
id: None,
|
|
||||||
text: "the long expensive conversation".into(),
|
|
||||||
attachments: Vec::new(),
|
|
||||||
},
|
|
||||||
Event::AssistantText {
|
|
||||||
delta: "at length".into(),
|
|
||||||
},
|
|
||||||
Event::Cleared,
|
|
||||||
Event::UserMessage {
|
|
||||||
id: None,
|
|
||||||
text: "a fresh start".into(),
|
|
||||||
attachments: Vec::new(),
|
|
||||||
},
|
|
||||||
Event::AssistantText {
|
|
||||||
delta: "cheaply".into(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
let messages = conversation(&path);
|
|
||||||
assert_eq!(messages.len(), 2);
|
|
||||||
assert_eq!(messages[0].content, "a fresh start");
|
|
||||||
assert_eq!(messages[1].content, "cheaply");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
/// The *last* one, so clearing twice does not resurrect what the
|
|
||||||
/// first clear dropped.
|
|
||||||
fn only_the_newest_clear_counts() {
|
|
||||||
let (_dir, path) = transcript_with(&[
|
|
||||||
Event::UserMessage {
|
|
||||||
id: None,
|
|
||||||
text: "one".into(),
|
|
||||||
attachments: Vec::new(),
|
|
||||||
},
|
|
||||||
Event::Cleared,
|
|
||||||
Event::UserMessage {
|
|
||||||
id: None,
|
|
||||||
text: "two".into(),
|
|
||||||
attachments: Vec::new(),
|
|
||||||
},
|
|
||||||
Event::Cleared,
|
|
||||||
Event::UserMessage {
|
|
||||||
id: None,
|
|
||||||
text: "three".into(),
|
|
||||||
attachments: Vec::new(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
let messages = conversation(&path);
|
|
||||||
assert_eq!(messages.len(), 1);
|
|
||||||
assert_eq!(messages[0].content, "three");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_model_key_cannot_climb_out_of_the_models_directory() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
for attempt in ["../../etc/passwd", "unsloth/../../escape.gguf", ""] {
|
|
||||||
assert!(
|
|
||||||
model_path(dir.path(), attempt).is_err(),
|
|
||||||
"{attempt:?} should have been refused",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
//! An MCP client, for the tools a llama session has that `llama-server` does
|
||||||
|
//! not provide itself.
|
||||||
|
//!
|
||||||
|
//! **Why this is here and not a flag on `llama-server`.** That server can host
|
||||||
|
//! MCP servers (`--mcp-servers-json`), but only ones it can *spawn*: its
|
||||||
|
//! configuration is Cursor's, and an entry without a `command` is skipped with
|
||||||
|
//! "MCP server 'exa' has no command". Exa's is a remote HTTP endpoint with
|
||||||
|
//! nothing to spawn, so reaching it that way means a local process bridging
|
||||||
|
//! stdio to HTTP -- a Node install on the machine serving the model, and a
|
||||||
|
//! package to keep current, for what is three JSON-RPC calls.
|
||||||
|
//!
|
||||||
|
//! llama.cpp's own web UI does not do that either. It ships Exa in a
|
||||||
|
//! "recommended servers" list and connects to `https://mcp.exa.ai/mcp`
|
||||||
|
//! *itself*, from the browser. This is the same arrangement with this server
|
||||||
|
//! in the browser's place, and it is the right one for a second reason: it
|
||||||
|
//! puts the search on the machine running the backend rather than on whichever
|
||||||
|
//! machine happens to be serving the model, which may have no route out at
|
||||||
|
//! all.
|
||||||
|
//!
|
||||||
|
//! **Only the three calls a tool needs.** `initialize`, `tools/list`,
|
||||||
|
//! `tools/call`. Nothing here implements resources, prompts, sampling or the
|
||||||
|
//! server-to-client stream, because nothing here uses them; a session's tools
|
||||||
|
//! are a list fetched once and a call made on demand. That is why this is a
|
||||||
|
//! file rather than a dependency on a protocol crate -- there is no spec
|
||||||
|
//! surface to get subtly wrong, only a request and its reply.
|
||||||
|
//!
|
||||||
|
//! Transport is "streamable HTTP": every message is a POST, and the reply is
|
||||||
|
//! either JSON or a one-event SSE stream carrying the same JSON. Both are
|
||||||
|
//! accepted because which one arrives is the server's choice, not ours.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// Identifies this client to an MCP server.
|
||||||
|
///
|
||||||
|
/// Not politeness: Exa's endpoint is behind Cloudflare, which answers **403**
|
||||||
|
/// to a request with no `User-Agent` at all (measured 2026-09-19 -- the same
|
||||||
|
/// request with one succeeds). A client that omitted it would look exactly
|
||||||
|
/// like a server that was refusing us.
|
||||||
|
const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
/// The protocol version this speaks. Sent at `initialize`; a server that
|
||||||
|
/// prefers another says so in its answer and this goes along with whatever it
|
||||||
|
/// then sends, since none of the three calls here has changed between
|
||||||
|
/// versions.
|
||||||
|
const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||||
|
|
||||||
|
/// How long any one call may take.
|
||||||
|
///
|
||||||
|
/// Generous because a web search is a search: Exa fetches and cleans pages
|
||||||
|
/// before answering. Bounded at all because this blocks a turn, and a tool
|
||||||
|
/// that never returns is a session that never speaks again.
|
||||||
|
const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// A connected MCP server, and the tools it offered.
|
||||||
|
pub struct McpServer {
|
||||||
|
/// The name this server is configured under. It prefixes every tool, so
|
||||||
|
/// two servers offering `search` are two different tools.
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
/// What the server called this conversation, when it named one. Sent back
|
||||||
|
/// on every later request; a server that keeps no session sends no header
|
||||||
|
/// and this stays `None`.
|
||||||
|
session: Option<String>,
|
||||||
|
/// The tool names this server answers to, without the prefix, keyed by the
|
||||||
|
/// prefixed name the model is given.
|
||||||
|
tools: Vec<McpTool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One tool an MCP server offers, in both the names it has.
|
||||||
|
pub struct McpTool {
|
||||||
|
/// `{server}_{tool}` -- what the model calls it, and what comes back in a
|
||||||
|
/// tool call. Prefixed the way `llama-server` prefixes the MCP tools it
|
||||||
|
/// hosts itself, so a reader sees one naming convention whichever side a
|
||||||
|
/// tool came from.
|
||||||
|
pub qualified: String,
|
||||||
|
/// What the server calls it.
|
||||||
|
bare: String,
|
||||||
|
/// The OpenAI-shaped function definition sent to the model.
|
||||||
|
pub definition: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpServer {
|
||||||
|
/// Connects, handshakes, and asks what it can do.
|
||||||
|
///
|
||||||
|
/// All three steps or none: a server that answered `initialize` and then
|
||||||
|
/// failed to list its tools is not a server with no tools, and returning
|
||||||
|
/// an empty list for it would put a session on screen that silently
|
||||||
|
/// cannot search.
|
||||||
|
pub fn connect(name: &str, url: &str) -> Result<Self> {
|
||||||
|
let mut server = Self {
|
||||||
|
name: name.to_string(),
|
||||||
|
url: url.to_string(),
|
||||||
|
session: None,
|
||||||
|
tools: Vec::new(),
|
||||||
|
};
|
||||||
|
server
|
||||||
|
.request(
|
||||||
|
1,
|
||||||
|
"initialize",
|
||||||
|
json!({
|
||||||
|
"protocolVersion": PROTOCOL_VERSION,
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "ai-server", "title": "AI Sessions", "version": env!("CARGO_PKG_VERSION")},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.with_context(|| format!("handshaking with the {name} MCP server at {url}"))?;
|
||||||
|
// A notification: no id, and the server answers with no body. Sent
|
||||||
|
// because the specification requires it before any other call, and
|
||||||
|
// Exa's server does enforce it.
|
||||||
|
server.notify("notifications/initialized")?;
|
||||||
|
let listed = server
|
||||||
|
.request(2, "tools/list", json!({}))
|
||||||
|
.with_context(|| format!("asking the {name} MCP server what it offers"))?;
|
||||||
|
server.tools = listed
|
||||||
|
.get("tools")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|tools| {
|
||||||
|
tools
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tool| server.describe(tool))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns one entry of `tools/list` into the function definition a model is
|
||||||
|
/// given, or `None` for one this cannot name or call.
|
||||||
|
fn describe(&self, tool: &Value) -> Option<McpTool> {
|
||||||
|
let bare = tool.get("name").and_then(Value::as_str)?.to_string();
|
||||||
|
let qualified = format!("{}_{bare}", self.name);
|
||||||
|
let mut function = serde_json::Map::new();
|
||||||
|
function.insert("name".into(), json!(qualified));
|
||||||
|
if let Some(description) = tool.get("description").and_then(Value::as_str) {
|
||||||
|
function.insert("description".into(), json!(description));
|
||||||
|
}
|
||||||
|
// `inputSchema` in MCP, `parameters` in the OpenAI shape: the same
|
||||||
|
// JSON Schema under two names. A tool that declares none takes no
|
||||||
|
// arguments, which is an empty object rather than an absent key --
|
||||||
|
// some templates render the key unconditionally.
|
||||||
|
function.insert(
|
||||||
|
"parameters".into(),
|
||||||
|
tool.get("inputSchema")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!({"type": "object", "properties": {}})),
|
||||||
|
);
|
||||||
|
Some(McpTool {
|
||||||
|
qualified,
|
||||||
|
bare,
|
||||||
|
definition: json!({"type": "function", "function": function}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tools(&self) -> &[McpTool] {
|
||||||
|
&self.tools
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one of this server's tools, named as the model named it.
|
||||||
|
///
|
||||||
|
/// The result is the text a model is shown. A tool the server reports as
|
||||||
|
/// failing is **not** an error here: `isError` means the tool ran and went
|
||||||
|
/// wrong -- a search that found nothing, a page that would not fetch --
|
||||||
|
/// and the model is the one that has to know, so it comes back as its own
|
||||||
|
/// message. An error is reserved for not having reached the server at all.
|
||||||
|
pub fn call(&mut self, qualified: &str, arguments: &Value) -> Result<String> {
|
||||||
|
// The bare name is taken before the call, because the call needs the
|
||||||
|
// whole of `self` and the tool list is part of it.
|
||||||
|
let bare = self
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.find(|tool| tool.qualified == qualified)
|
||||||
|
.map(|tool| tool.bare.clone())
|
||||||
|
.with_context(|| format!("{} does not offer {qualified}", self.name))?;
|
||||||
|
let result = self.request(
|
||||||
|
3,
|
||||||
|
"tools/call",
|
||||||
|
json!({"name": bare, "arguments": arguments}),
|
||||||
|
)?;
|
||||||
|
Ok(rendered(&result))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One request, and its result.
|
||||||
|
///
|
||||||
|
/// `&self` rather than `&mut self` everywhere but the handshake would be
|
||||||
|
/// tidier and is wrong: the session header is assigned by the server on
|
||||||
|
/// the first reply and has to be kept.
|
||||||
|
fn request(&mut self, id: u64, method: &str, params: Value) -> Result<Value> {
|
||||||
|
let body = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||||
|
let answer = self.post(&body)?.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"the {} MCP server answered {method} with nothing",
|
||||||
|
self.name
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if let Some(message) = answer.pointer("/error/message").and_then(Value::as_str) {
|
||||||
|
bail!("{} refused {method}: {message}", self.name);
|
||||||
|
}
|
||||||
|
answer
|
||||||
|
.get("result")
|
||||||
|
.cloned()
|
||||||
|
.with_context(|| format!("the {} MCP server's {method} carried no result", self.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message with no id, which is answered with no body.
|
||||||
|
fn notify(&mut self, method: &str) -> Result<()> {
|
||||||
|
self.post(&json!({"jsonrpc": "2.0", "method": method}))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posts one JSON-RPC message and returns whatever came back, which for a
|
||||||
|
/// notification is nothing.
|
||||||
|
fn post(&mut self, body: &Value) -> Result<Option<Value>> {
|
||||||
|
let mut request = ureq::post(&self.url)
|
||||||
|
.config()
|
||||||
|
.timeout_global(Some(CALL_TIMEOUT))
|
||||||
|
.build()
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
// Both, because which one a server replies with is its choice.
|
||||||
|
.header("accept", "application/json, text/event-stream")
|
||||||
|
.header("user-agent", USER_AGENT);
|
||||||
|
if let Some(session) = &self.session {
|
||||||
|
request = request.header("mcp-session-id", session);
|
||||||
|
}
|
||||||
|
let mut response = request
|
||||||
|
.send_json(body)
|
||||||
|
.with_context(|| format!("reaching the {} MCP server at {}", self.name, self.url))?;
|
||||||
|
if let Some(session) = response
|
||||||
|
.headers()
|
||||||
|
.get("mcp-session-id")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
{
|
||||||
|
self.session = Some(session.to_string());
|
||||||
|
}
|
||||||
|
let streamed = response
|
||||||
|
.headers()
|
||||||
|
.get("content-type")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| value.contains("text/event-stream"));
|
||||||
|
let text = response
|
||||||
|
.body_mut()
|
||||||
|
.read_to_string()
|
||||||
|
.with_context(|| format!("reading the {} MCP server's answer", self.name))?;
|
||||||
|
Ok(first_message(&text, streamed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first JSON-RPC message in a reply body.
|
||||||
|
///
|
||||||
|
/// One, not all: every call here carries a single id and the server answers it
|
||||||
|
/// once. Server-sent events are unwrapped to their payload lines; a plain JSON
|
||||||
|
/// body is itself.
|
||||||
|
fn first_message(text: &str, streamed: bool) -> Option<Value> {
|
||||||
|
if streamed {
|
||||||
|
return text
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.strip_prefix("data: "))
|
||||||
|
.find_map(|payload| serde_json::from_str(payload).ok());
|
||||||
|
}
|
||||||
|
serde_json::from_str(text.trim()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `tools/call` result as the text a model is given.
|
||||||
|
///
|
||||||
|
/// MCP answers with a list of content blocks; the text ones are joined and the
|
||||||
|
/// rest are named rather than dropped, because a model told nothing came back
|
||||||
|
/// will try again. `structuredContent` is used when there is no text at all,
|
||||||
|
/// which is how some servers answer entirely.
|
||||||
|
fn rendered(result: &Value) -> String {
|
||||||
|
let blocks = result.get("content").and_then(Value::as_array);
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
for block in blocks.into_iter().flatten() {
|
||||||
|
match block.get("type").and_then(Value::as_str) {
|
||||||
|
Some("text") => parts.push(
|
||||||
|
block
|
||||||
|
.get("text")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
Some(kind) => parts.push(format!("[{kind} content, which this session cannot show]")),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts.iter().all(|part| part.trim().is_empty())
|
||||||
|
&& let Some(structured) = result.get("structuredContent")
|
||||||
|
{
|
||||||
|
return structured.to_string();
|
||||||
|
}
|
||||||
|
parts.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_event_stream_body_is_unwrapped_to_its_payload() {
|
||||||
|
let body =
|
||||||
|
"event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n";
|
||||||
|
assert_eq!(
|
||||||
|
first_message(body, true),
|
||||||
|
Some(json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_json_body_is_the_message() {
|
||||||
|
let body = " {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n";
|
||||||
|
assert_eq!(
|
||||||
|
first_message(body, false),
|
||||||
|
Some(json!({"jsonrpc": "2.0", "id": 1, "result": {}})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// A notification's reply, which is nothing at all.
|
||||||
|
fn an_empty_body_is_no_message() {
|
||||||
|
assert_eq!(first_message("", false), None);
|
||||||
|
assert_eq!(first_message("event: ping\n", true), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_blocks_are_joined_and_other_kinds_are_named() {
|
||||||
|
let result = json!({"content": [
|
||||||
|
{"type": "text", "text": "first"},
|
||||||
|
{"type": "image", "data": "…"},
|
||||||
|
{"type": "text", "text": "second"},
|
||||||
|
]});
|
||||||
|
assert_eq!(
|
||||||
|
rendered(&result),
|
||||||
|
"first\n[image content, which this session cannot show]\nsecond",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// A server that answers only in structured form. Rendering "" for it
|
||||||
|
/// would tell the model the search came back empty, which is a different
|
||||||
|
/// fact from the one that is true.
|
||||||
|
fn a_result_with_no_text_falls_back_to_its_structured_form() {
|
||||||
|
let result = json!({"content": [], "structuredContent": {"hits": 2}});
|
||||||
|
assert_eq!(rendered(&result), "{\"hits\":2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// The real endpoint, which is the only thing that can confirm the
|
||||||
|
/// handshake, the session header and the SSE unwrapping all agree with a
|
||||||
|
/// server nobody here wrote. Skipped without network rather than failed:
|
||||||
|
/// `./run-tests.sh` has to pass on a machine with no route out.
|
||||||
|
fn exa_answers_a_search_over_the_real_protocol() {
|
||||||
|
let Ok(mut server) = McpServer::connect("exa", super::super::EXA_MCP_URL) else {
|
||||||
|
eprintln!("skipping: could not reach Exa");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
server
|
||||||
|
.tools()
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool.qualified == "exa_web_search_exa"),
|
||||||
|
"Exa offered {:?}",
|
||||||
|
server
|
||||||
|
.tools()
|
||||||
|
.iter()
|
||||||
|
.map(|tool| &tool.qualified)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
let answer = server
|
||||||
|
.call(
|
||||||
|
"exa_web_search_exa",
|
||||||
|
&json!({"query": "llama.cpp server", "numResults": 1}),
|
||||||
|
)
|
||||||
|
.expect("search");
|
||||||
|
assert!(!answer.trim().is_empty(), "a search returned nothing");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,857 @@
|
|||||||
|
//! One `llama-server` per machine, in **router mode**: the front door to every
|
||||||
|
//! model that machine serves, shared by every session on it.
|
||||||
|
//!
|
||||||
|
//! A router holds no weights itself. It reads a preset file naming models and
|
||||||
|
//! their flags, and starts a child `llama-server` per model that is asked for
|
||||||
|
//! -- so "one server per model, with that model's own settings" is what a
|
||||||
|
//! machine ends up running, and one process, one port and one record is what
|
||||||
|
//! this backend has to keep track of. That is the whole reason it is here:
|
||||||
|
//! before 2026-09-19 each session started its own `llama-server`, so two
|
||||||
|
//! sessions on one model held two copies of it in memory and a model change
|
||||||
|
//! cost a load that only that session benefited from.
|
||||||
|
//!
|
||||||
|
//! **A router outlives this backend, and nothing here stops it on its own.**
|
||||||
|
//! It is recorded the way a session's process is ([`process`]), adopted again
|
||||||
|
//! on the way back up, and ended only when somebody asks for that in the
|
||||||
|
//! machine's provider settings. A loaded model is minutes of disk and
|
||||||
|
//! gigabytes of memory; letting the last session to be closed throw that away
|
||||||
|
//! would make the shared server pointless.
|
||||||
|
//!
|
||||||
|
//! **The preset file is the configuration, and it lives on the serving
|
||||||
|
//! machine.** Flags that decide how a model is loaded -- context size, layers
|
||||||
|
//! on the GPU, slots, the draft head -- are per model rather than per session,
|
||||||
|
//! because one loaded model is what several sessions are now talking to. They
|
||||||
|
//! are written into a section named by the model's key, which is also the name
|
||||||
|
//! a request routes by, so nothing has to translate between the two.
|
||||||
|
//!
|
||||||
|
//! What is deliberately *not* here: which tools a session offers, how hard it
|
||||||
|
//! thinks, and the sampling settings. Those ride on each request, so they stay
|
||||||
|
//! the session's own and need no reload -- see `super`'s module comment.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use super::Model;
|
||||||
|
use crate::config::{MachineConfig, ProviderConfig};
|
||||||
|
use crate::session::process;
|
||||||
|
use crate::session::transport::{Launch, Streams, Transport};
|
||||||
|
|
||||||
|
/// Where a router's own output goes, both streams into one file. For a remote
|
||||||
|
/// machine this is the local end of the ssh connection, so it carries what the
|
||||||
|
/// far `llama-server` said -- including what a child model instance said,
|
||||||
|
/// which is the only account of a model that would not load.
|
||||||
|
const LOG: &str = "llama-router.log";
|
||||||
|
|
||||||
|
/// The preset file's name in the router's own directory, for a router on this
|
||||||
|
/// machine. One on another machine keeps it over there instead, at
|
||||||
|
/// [`REMOTE_PRESET`], since that is the only side that can read it.
|
||||||
|
const PRESET: &str = "models.ini";
|
||||||
|
|
||||||
|
/// The preset file's first line, which `llama-server` refuses a file without.
|
||||||
|
const VERSION: &str = "version = 1\n";
|
||||||
|
|
||||||
|
/// How long to wait for a model to load before giving up. Loading is mostly
|
||||||
|
/// disk, and a large quantised model on a cold cache is genuinely slow, so
|
||||||
|
/// this is generous -- the failure it exists for is a model that will never
|
||||||
|
/// answer rather than one that is slow.
|
||||||
|
const LOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// How long to wait for the router process itself. Short: it loads nothing,
|
||||||
|
/// so anything beyond a second or two is a port it cannot bind or a program
|
||||||
|
/// too old for one of these flags.
|
||||||
|
const START_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// How often either of those is checked.
|
||||||
|
const POLL: std::time::Duration = std::time::Duration::from_millis(250);
|
||||||
|
|
||||||
|
/// How much of the router's log to carry into a message somebody reads on a
|
||||||
|
/// phone.
|
||||||
|
const LOG_TAIL_LINES: usize = 6;
|
||||||
|
|
||||||
|
/// How many models a router keeps loaded at once before evicting the least
|
||||||
|
/// recently used. One by default because the machine serving models usually
|
||||||
|
/// has one GPU: a second model loaded beside the first is the case where
|
||||||
|
/// neither fits.
|
||||||
|
const DEFAULT_MAX_LOADED: u32 = 1;
|
||||||
|
|
||||||
|
/// Every machine's router, so that two sessions on one machine reach one
|
||||||
|
/// process rather than starting two.
|
||||||
|
///
|
||||||
|
/// A registry rather than a field on each session: the sharing *is* the
|
||||||
|
/// point, and a router that two drivers could each own is one that both would
|
||||||
|
/// start.
|
||||||
|
pub struct Routers {
|
||||||
|
dir: PathBuf,
|
||||||
|
/// The runtime a router is started on and reaped into.
|
||||||
|
///
|
||||||
|
/// Captured here because everything that starts one runs on a *blocking*
|
||||||
|
/// thread -- loading a model is minutes of disk, so it cannot be on the
|
||||||
|
/// runtime -- and tokio's `Command::spawn` registers the child with the
|
||||||
|
/// reactor, so calling it outside a runtime context panics. That panic is
|
||||||
|
/// silent: it kills the loading thread and leaves the session saying
|
||||||
|
/// "loading" for ever, with nothing in the log, which is exactly how it
|
||||||
|
/// was found.
|
||||||
|
runtime: Option<tokio::runtime::Handle>,
|
||||||
|
inner: Mutex<HashMap<String, Arc<Router>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Routers {
|
||||||
|
/// `dir` is where each router's record, log and (for this machine) preset
|
||||||
|
/// file live -- beside the session directories, since a router is shared
|
||||||
|
/// by sessions and belongs to none of them.
|
||||||
|
///
|
||||||
|
/// Made on the runtime that will outlive it; `None` is a test with no
|
||||||
|
/// runtime at all, where there is nothing to reap into either.
|
||||||
|
pub fn new(dir: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
runtime: tokio::runtime::Handle::try_current().ok(),
|
||||||
|
inner: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This machine-and-provider's router, made if this is the first ask.
|
||||||
|
///
|
||||||
|
/// The machine and provider are re-read every time rather than captured,
|
||||||
|
/// because both are editable while sessions are running: a renamed
|
||||||
|
/// machine, a re-probed program, a changed `maxLoaded`. What a *running*
|
||||||
|
/// router was started with is whatever it was started with; the new value
|
||||||
|
/// reaches the next start, which is the same rule every other launch flag
|
||||||
|
/// follows.
|
||||||
|
pub fn of(&self, machine: &MachineConfig, provider: &ProviderConfig) -> Arc<Router> {
|
||||||
|
let key = format!("{}/{}", machine.id, provider.name);
|
||||||
|
let spec = Spec {
|
||||||
|
transport: Transport::for_machine(machine),
|
||||||
|
program: provider.program().to_string(),
|
||||||
|
max_loaded: provider.max_loaded.unwrap_or(DEFAULT_MAX_LOADED),
|
||||||
|
};
|
||||||
|
let mut routers = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let router = routers.entry(key.clone()).or_insert_with(|| {
|
||||||
|
Arc::new(Router {
|
||||||
|
dir: self.dir.join(key.replace('/', "-")),
|
||||||
|
spec: Mutex::new(spec.clone()),
|
||||||
|
runtime: self.runtime.clone(),
|
||||||
|
gate: Mutex::new(()),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
*router.spec.lock().unwrap_or_else(|e| e.into_inner()) = spec;
|
||||||
|
Arc::clone(router)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What it takes to start a router, as its machine currently describes it.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Spec {
|
||||||
|
transport: Transport,
|
||||||
|
program: String,
|
||||||
|
max_loaded: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Router {
|
||||||
|
/// Its record and log, on this machine whichever machine it serves from.
|
||||||
|
dir: PathBuf,
|
||||||
|
spec: Mutex<Spec>,
|
||||||
|
/// See [`Routers::runtime`].
|
||||||
|
runtime: Option<tokio::runtime::Handle>,
|
||||||
|
/// Held while a router is started and while the preset file is edited --
|
||||||
|
/// the two things that go wrong when two sessions do them at once. Never
|
||||||
|
/// held across a model load, which takes minutes.
|
||||||
|
gate: Mutex<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Router {
|
||||||
|
/// Where its record and log are, for a session that wants to watch the
|
||||||
|
/// process it is talking through or read what it last said.
|
||||||
|
pub fn dir(&self) -> &Path {
|
||||||
|
&self.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Its process record, if one is running -- which is also how a session
|
||||||
|
/// records the process it reaches its model through.
|
||||||
|
pub fn record(&self) -> Option<process::Record> {
|
||||||
|
process::live(&self.dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same process, recorded as one a session reaches but does not own.
|
||||||
|
///
|
||||||
|
/// The one place that conversion happens, so that a session directory can
|
||||||
|
/// never come to hold a record saying it owns the machine's router -- see
|
||||||
|
/// [`process::Detail::Shared`].
|
||||||
|
pub fn shared_record(&self) -> Option<process::Record> {
|
||||||
|
let record = self.record()?;
|
||||||
|
match record.detail {
|
||||||
|
process::Detail::Http { port } => Some(process::Record {
|
||||||
|
detail: process::Detail::Shared { port },
|
||||||
|
..record
|
||||||
|
}),
|
||||||
|
process::Detail::Stdio { .. } | process::Detail::Shared { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where to reach it, or `None` when nothing is running.
|
||||||
|
pub fn endpoint(&self) -> Option<String> {
|
||||||
|
match self.record()?.detail {
|
||||||
|
process::Detail::Http { port } => Some(format!("http://127.0.0.1:{port}")),
|
||||||
|
process::Detail::Stdio { .. } | process::Detail::Shared { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts `model` in memory and says where to talk to it, starting the
|
||||||
|
/// router first if it is not up.
|
||||||
|
///
|
||||||
|
/// Blocking, and slow on purpose: a model that has to come off disk takes
|
||||||
|
/// as long as it takes. The caller is the driver's loading thread, which
|
||||||
|
/// is what [`super::Serving::Loading`] exists to describe.
|
||||||
|
///
|
||||||
|
/// Nothing is held across that wait. Two sessions load through one router,
|
||||||
|
/// and the second one wanting a model already in memory must not queue
|
||||||
|
/// behind the first one's cold load of a different model -- which is most
|
||||||
|
/// of what sharing a server was for.
|
||||||
|
pub fn load(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
found: &Model,
|
||||||
|
settings: &BTreeMap<String, String>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let endpoint = self.start_if_down()?;
|
||||||
|
self.describe_model(key, found, settings)?;
|
||||||
|
// Nothing to ask for where it is already in memory, which is the case
|
||||||
|
// this whole module exists to produce: a second session naming a model
|
||||||
|
// somebody else loaded is a round trip rather than a load. Asking
|
||||||
|
// anyway is not harmless -- `POST /models/load` answers **400** for a
|
||||||
|
// model that is already loaded, which arrived as a session that
|
||||||
|
// refused to start next to one happily using that same model.
|
||||||
|
if !self.is_ready(key) {
|
||||||
|
let asked = self
|
||||||
|
.post("/models/load", json!({ "model": key }))
|
||||||
|
.with_context(|| format!("asking llama-server to load {key}"));
|
||||||
|
match (asked, self.wait_loaded(key)) {
|
||||||
|
// Loaded, whatever the request said: something else may have
|
||||||
|
// asked for it in the meantime, and what is in memory is the
|
||||||
|
// answer rather than what one request made of being told to
|
||||||
|
// put it there.
|
||||||
|
(_, Ok(())) => {}
|
||||||
|
// It did not load, and a refusal of the request itself says
|
||||||
|
// more about why than "it never appeared" does.
|
||||||
|
(Err(refused), Err(_)) => return Err(refused),
|
||||||
|
(Ok(_), Err(never)) => return Err(never),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes what this model is and how to load it into the preset file, and
|
||||||
|
/// has the router re-read it.
|
||||||
|
///
|
||||||
|
/// Also the path a settings change takes, which is why it is separate from
|
||||||
|
/// [`load`](Self::load): a model whose entry has changed is **unloaded**
|
||||||
|
/// by the re-read, and that is the change taking effect rather than a
|
||||||
|
/// side effect -- the sessions using it load it again, with the new
|
||||||
|
/// settings, on their next message. What must not happen is the same
|
||||||
|
/// thing to an unrelated model, which is why the file is written and the
|
||||||
|
/// re-read asked for only when the text actually differs.
|
||||||
|
pub fn describe_model(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
found: &Model,
|
||||||
|
settings: &BTreeMap<String, String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Read, edit, write: under the gate because two of those at once lose
|
||||||
|
// one of the two sections.
|
||||||
|
let _one_at_a_time = self.gate.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let existing = self.preset()?;
|
||||||
|
let updated = upsert(&existing, key, §ion(found, settings));
|
||||||
|
if updated == existing {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.write_preset(&updated)?;
|
||||||
|
// Only meaningful against a running router; one that is down reads the
|
||||||
|
// file when it starts.
|
||||||
|
if self.endpoint().is_some() {
|
||||||
|
self.get("/models?reload=1")
|
||||||
|
.context("asking llama-server to re-read its models")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this model is in memory now.
|
||||||
|
fn is_ready(&self, key: &str) -> bool {
|
||||||
|
self.loaded()
|
||||||
|
.into_iter()
|
||||||
|
.any(|model| model.model == key && model.ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every model this router knows about and what each is doing, or an empty
|
||||||
|
/// list when it is not running.
|
||||||
|
pub fn loaded(&self) -> Vec<RouterModel> {
|
||||||
|
let Ok(answer) = self.get("/models") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
answer
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|models| models.iter().filter_map(RouterModel::read).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes one model out of memory, leaving the router and every other model
|
||||||
|
/// alone.
|
||||||
|
pub fn unload(&self, key: &str) -> Result<()> {
|
||||||
|
self.post("/models/unload", json!({ "model": key }))
|
||||||
|
.with_context(|| format!("asking llama-server to unload {key}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the router and every model it is holding.
|
||||||
|
///
|
||||||
|
/// The only thing that does: a session closing, being deleted, or this
|
||||||
|
/// backend shutting down all leave it running. Sessions using it will see
|
||||||
|
/// their process go and report `exited`, which is true -- the model they
|
||||||
|
/// were talking to is no longer in memory.
|
||||||
|
///
|
||||||
|
/// Neither the record nor the mark is cleared here, and that is what tells
|
||||||
|
/// those sessions this was asked for rather than a crash. A session's
|
||||||
|
/// watcher looks a couple of seconds later, so anything removed now is
|
||||||
|
/// removed before the only reader of it has looked -- which is how the
|
||||||
|
/// first version of this put "llama-server exited" in three transcripts
|
||||||
|
/// belonging to somebody who had just pressed Stop. The record describes a
|
||||||
|
/// dead process, which every reader already handles, and starting a new
|
||||||
|
/// router is what clears both.
|
||||||
|
pub fn stop(&self) -> Result<()> {
|
||||||
|
let Some(record) = self.record() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
process::mark_stopping(&self.dir)?;
|
||||||
|
process::stop(&record, process::STOP_GRACE);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end of the router's log, for a failure message.
|
||||||
|
///
|
||||||
|
/// Its account of what went wrong is the useful half -- "failed to load
|
||||||
|
/// model", "bind: Address already in use" -- and on a remote machine it is
|
||||||
|
/// the only half, since nobody reading the phone can open a file over
|
||||||
|
/// there. Bounded, because this ends up in an event a phone draws.
|
||||||
|
pub fn log_tail(&self) -> String {
|
||||||
|
let Ok(text) = std::fs::read_to_string(self.dir.join(LOG)) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let tail: Vec<&str> = text.lines().rev().take(LOG_TAIL_LINES).collect();
|
||||||
|
if tail.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
" It last said: {}",
|
||||||
|
tail.into_iter().rev().collect::<Vec<_>>().join(" / ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adopts the running router or starts one, and waits for it to answer.
|
||||||
|
///
|
||||||
|
/// Under the gate, and asked again inside it: two sessions starting at once
|
||||||
|
/// would otherwise both find no record, both start a router, and bind two
|
||||||
|
/// ports to the same models.
|
||||||
|
fn start_if_down(&self) -> Result<String> {
|
||||||
|
if let Some(endpoint) = self.endpoint() {
|
||||||
|
return Ok(endpoint);
|
||||||
|
}
|
||||||
|
let _one_at_a_time = self.gate.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(endpoint) = self.endpoint() {
|
||||||
|
return Ok(endpoint);
|
||||||
|
}
|
||||||
|
let spec = self.spec.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||||
|
wg_app_link::private::create_dir(&self.dir)?;
|
||||||
|
// Whatever the last router left behind, including the mark saying its
|
||||||
|
// end was asked for -- see [`stop`](Self::stop). From here on, a
|
||||||
|
// process that goes away is news.
|
||||||
|
process::clear(&self.dir);
|
||||||
|
// Written before the router starts, because it is read at startup and
|
||||||
|
// a router with no preset file at all lists nothing.
|
||||||
|
let preset = self.write_preset(&self.preset()?)?;
|
||||||
|
let forward = spec
|
||||||
|
.transport
|
||||||
|
.reserve_port()
|
||||||
|
.context("finding a port for llama-server")?;
|
||||||
|
let args = vec![
|
||||||
|
// Loopback there, whichever machine there is: what reaches it from
|
||||||
|
// outside that machine is the ssh tunnel and nothing else.
|
||||||
|
"--host".to_string(),
|
||||||
|
"127.0.0.1".to_string(),
|
||||||
|
"--port".to_string(),
|
||||||
|
forward.there.to_string(),
|
||||||
|
// No `-m`: a `llama-server` given no model is a router.
|
||||||
|
"--models-preset".to_string(),
|
||||||
|
preset,
|
||||||
|
"--models-max".to_string(),
|
||||||
|
spec.max_loaded.to_string(),
|
||||||
|
// The built-in agent tools -- read, search, edit, shell. Hosted by
|
||||||
|
// the router itself, which is what makes them one set for the
|
||||||
|
// machine rather than one per model. Which of them a *session*
|
||||||
|
// offers its model is decided here in the backend, per request, so
|
||||||
|
// there is nothing per-session to pass through: see `super::tools`.
|
||||||
|
"--tools".to_string(),
|
||||||
|
"all".to_string(),
|
||||||
|
];
|
||||||
|
let launch = Launch::new(&spec.program, args, None).reaching(forward);
|
||||||
|
// Starting and reaping both happen inside the runtime, though this is
|
||||||
|
// a blocking thread: tokio's `Command::spawn` registers the child with
|
||||||
|
// the reactor, so calling it outside a runtime context panics -- and
|
||||||
|
// that panic kills only this thread, leaving a session that says
|
||||||
|
// "loading" for ever with nothing in the log. See [`Routers::runtime`].
|
||||||
|
let _inside = self.runtime.as_ref().map(tokio::runtime::Handle::enter);
|
||||||
|
// Its output goes to a file, not a pipe. Not only so the process can
|
||||||
|
// outlive this server: nothing ever read those pipes, so a chatty
|
||||||
|
// llama-server filled the 64 KB buffer and blocked with no sign of why.
|
||||||
|
let child = spec.transport.spawn(
|
||||||
|
&launch,
|
||||||
|
Streams::Detached {
|
||||||
|
stdin: std::process::Stdio::null(),
|
||||||
|
stdout: log_file(&self.dir.join(LOG))?.into(),
|
||||||
|
stderr: log_file(&self.dir.join(LOG))?.into(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let pid = child
|
||||||
|
.id()
|
||||||
|
.context("llama-server exited before it could be recorded")?;
|
||||||
|
tracing::info!(
|
||||||
|
"running {} in router mode {} on 127.0.0.1:{} there, reached at 127.0.0.1:{} here, \
|
||||||
|
as pid {pid}",
|
||||||
|
spec.program,
|
||||||
|
spec.transport.describe(),
|
||||||
|
forward.there,
|
||||||
|
forward.here,
|
||||||
|
);
|
||||||
|
// Reaped so it does not become a zombie while this server is still
|
||||||
|
// its parent; the record and the health poll are what say whether it
|
||||||
|
// is alive, because after a restart there is no `Child` to ask.
|
||||||
|
if let Some(runtime) = &self.runtime {
|
||||||
|
runtime.spawn(async move {
|
||||||
|
let mut child = child;
|
||||||
|
let _ = child.wait().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The *near* port, because that is the one anything reaching this
|
||||||
|
// router has to dial -- including a later run of this backend, which
|
||||||
|
// adopts the record without knowing which machine it is on. For a
|
||||||
|
// remote machine the recorded pid is the ssh client's, which is the
|
||||||
|
// process this machine owns and which holds the tunnel open for
|
||||||
|
// exactly as long as the far router lives.
|
||||||
|
let record = process::Record::of(pid, process::Detail::Http { port: forward.here })
|
||||||
|
.context("llama-server was gone before its start time could be read")?;
|
||||||
|
process::write(&self.dir, &record);
|
||||||
|
let endpoint = format!("http://127.0.0.1:{}", forward.here);
|
||||||
|
self.wait_answering(&endpoint)?;
|
||||||
|
Ok(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls until the router answers, watching the process as well as the
|
||||||
|
/// port: a port already taken, or a `llama-server` too old for one of
|
||||||
|
/// these flags, exits within a second and would otherwise be waited out.
|
||||||
|
fn wait_answering(&self, endpoint: &str) -> Result<()> {
|
||||||
|
let deadline = std::time::Instant::now() + START_TIMEOUT;
|
||||||
|
let url = format!("{endpoint}/health");
|
||||||
|
loop {
|
||||||
|
if let Ok(response) = ureq::get(&url).call()
|
||||||
|
&& response.status() == 200
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match process::recorded(&self.dir) {
|
||||||
|
Some((_, process::Liveness::Alive | process::Liveness::Unknown)) => {}
|
||||||
|
Some((_, process::Liveness::Dead)) | None => {
|
||||||
|
process::clear(&self.dir);
|
||||||
|
bail!("llama-server exited before it answered.{}", self.log_tail());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() > deadline {
|
||||||
|
process::clear(&self.dir);
|
||||||
|
bail!(
|
||||||
|
"llama-server did not answer within {}s.{}",
|
||||||
|
START_TIMEOUT.as_secs(),
|
||||||
|
self.log_tail()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::thread::sleep(POLL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls until the named model is in memory, or says why it will never be.
|
||||||
|
///
|
||||||
|
/// A model that will not load is the common failure and it is fast: the
|
||||||
|
/// child exits, the router reports it unloaded with an exit code, and this
|
||||||
|
/// says so rather than waiting out the timeout -- which is what turned "it
|
||||||
|
/// said the file was corrupt" into "gave up after 300s".
|
||||||
|
fn wait_loaded(&self, key: &str) -> Result<()> {
|
||||||
|
let deadline = std::time::Instant::now() + LOAD_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
match self.loaded().into_iter().find(|model| model.model == key) {
|
||||||
|
Some(model) if model.ready => return Ok(()),
|
||||||
|
Some(model) if model.failed => {
|
||||||
|
bail!("{key} would not load.{}", self.log_tail())
|
||||||
|
}
|
||||||
|
// Still loading, or not listed yet after a reload.
|
||||||
|
Some(_) | None => {}
|
||||||
|
}
|
||||||
|
if process::live(&self.dir).is_none() {
|
||||||
|
bail!(
|
||||||
|
"llama-server went away while loading {key}.{}",
|
||||||
|
self.log_tail()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() > deadline {
|
||||||
|
bail!(
|
||||||
|
"{key} was still loading after {}s.{}",
|
||||||
|
LOAD_TIMEOUT.as_secs(),
|
||||||
|
self.log_tail()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::thread::sleep(POLL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One request to the router's management endpoints. The generation ones
|
||||||
|
/// are the driver's and stream, so they are not these.
|
||||||
|
fn get(&self, path: &str) -> Result<Value> {
|
||||||
|
let url = format!("{}{path}", self.answering()?);
|
||||||
|
Self::read(
|
||||||
|
ureq::get(&url)
|
||||||
|
.call()
|
||||||
|
.with_context(|| format!("GET {path}"))?,
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(&self, path: &str, body: Value) -> Result<Value> {
|
||||||
|
let url = format!("{}{path}", self.answering()?);
|
||||||
|
Self::read(
|
||||||
|
ureq::post(&url)
|
||||||
|
.send_json(body)
|
||||||
|
.with_context(|| format!("POST {path}"))?,
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where to reach a router that is running, or the failure saying it is
|
||||||
|
/// not -- which is what every one of these requests needs first.
|
||||||
|
fn answering(&self) -> Result<String> {
|
||||||
|
self.endpoint().context("no llama-server is running")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(mut response: ureq::http::Response<ureq::Body>, path: &str) -> Result<Value> {
|
||||||
|
response
|
||||||
|
.body_mut()
|
||||||
|
.read_json()
|
||||||
|
.with_context(|| format!("reading what {path} answered"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preset file as it stands on the machine that serves the models, or
|
||||||
|
/// empty where there is none yet.
|
||||||
|
///
|
||||||
|
/// Read back rather than remembered, for one reason that matters after a
|
||||||
|
/// restart: a router adopted from a previous run is already serving models
|
||||||
|
/// whose sections this process has never seen, and rewriting the file
|
||||||
|
/// without them would unload them at the next reload.
|
||||||
|
fn preset(&self) -> Result<String> {
|
||||||
|
let spec = self.spec.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||||
|
let text = match &spec.transport {
|
||||||
|
Transport::Here => {
|
||||||
|
Ok(std::fs::read_to_string(self.dir.join(PRESET)).unwrap_or_default())
|
||||||
|
}
|
||||||
|
Transport::Ssh { name, .. } => {
|
||||||
|
let script = format!("p={REMOTE_PRESET}; cat \"$p\" 2>/dev/null || true");
|
||||||
|
let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
|
||||||
|
spec.transport
|
||||||
|
.capture_blocking(&launch)
|
||||||
|
.with_context(|| format!("reading the model settings on {name}"))
|
||||||
|
}
|
||||||
|
}?;
|
||||||
|
// A file that is not there yet reads as a new one rather than as
|
||||||
|
// nothing: `llama-server` refuses a preset with no version line, so
|
||||||
|
// "empty" is not a state this can hand back or write.
|
||||||
|
Ok(if text.trim().is_empty() {
|
||||||
|
VERSION.to_string()
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the preset file where the router will read it, and says where
|
||||||
|
/// that is -- which is the path the router is given, so the two cannot
|
||||||
|
/// disagree.
|
||||||
|
fn write_preset(&self, text: &str) -> Result<String> {
|
||||||
|
let spec = self.spec.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||||
|
match &spec.transport {
|
||||||
|
Transport::Here => {
|
||||||
|
let path = self.dir.join(PRESET);
|
||||||
|
wg_app_link::private::write_file(&path, text.as_bytes())?;
|
||||||
|
Ok(path.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
Transport::Ssh { name, .. } => {
|
||||||
|
use base64::Engine as _;
|
||||||
|
// Base64 rather than a heredoc: the text goes through a shell
|
||||||
|
// on the far side, and an INI value is not something to trust
|
||||||
|
// to quoting rules twice over.
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
|
||||||
|
let script = format!(
|
||||||
|
"p={REMOTE_PRESET}; mkdir -p \"$(dirname \"$p\")\" && \
|
||||||
|
printf %s \"$1\" | base64 -d > \"$p\" && printf '%s\\n' \"$p\""
|
||||||
|
);
|
||||||
|
let launch = Launch::new(
|
||||||
|
"sh",
|
||||||
|
vec!["-c".to_string(), script, "sh".to_string(), encoded],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let answer = spec
|
||||||
|
.transport
|
||||||
|
.capture_blocking(&launch)
|
||||||
|
.with_context(|| format!("writing the model settings on {name}"))?;
|
||||||
|
match answer.trim() {
|
||||||
|
"" => bail!("{name} did not say where it wrote the model settings"),
|
||||||
|
path => Ok(path.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the preset file goes on a machine that is not this one, as a shell
|
||||||
|
/// word the far side expands: under that machine's state directory, beside
|
||||||
|
/// whatever else belongs to this app there.
|
||||||
|
///
|
||||||
|
/// `$HOME` is resolved over there because only that machine knows what it is.
|
||||||
|
const REMOTE_PRESET: &str = "\"${XDG_STATE_HOME:-$HOME/.local/state}/ai-app/llama-models.ini\"";
|
||||||
|
|
||||||
|
/// One model the router knows about, as the provider view and the load poll
|
||||||
|
/// both read it.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RouterModel {
|
||||||
|
pub model: String,
|
||||||
|
/// The router's own word: `unloaded`, `loading`, `loaded`, `sleeping`.
|
||||||
|
/// Carried through rather than reduced to a boolean, because the phone
|
||||||
|
/// draws it and llama.cpp is the authority on what states there are.
|
||||||
|
pub status: String,
|
||||||
|
pub ready: bool,
|
||||||
|
/// Unloaded *and* something went wrong, which is not the same as unloaded.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub failed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RouterModel {
|
||||||
|
fn read(entry: &Value) -> Option<Self> {
|
||||||
|
let model = entry.get("id")?.as_str()?.to_string();
|
||||||
|
let status = entry
|
||||||
|
.pointer("/status/value")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let exited = entry
|
||||||
|
.get("exit_code")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.unwrap_or_default();
|
||||||
|
Some(Self {
|
||||||
|
ready: status == "loaded" || status == "sleeping",
|
||||||
|
failed: status == "unloaded" && exited != 0,
|
||||||
|
model,
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a model's section says, from the file it is and the settings it has.
|
||||||
|
///
|
||||||
|
/// The keys are `llama-server`'s own argument names without their dashes,
|
||||||
|
/// which is what a preset section is: `--n-gpu-layers 20` is
|
||||||
|
/// `n-gpu-layers = 20`. So adding a setting is a row here and a row in
|
||||||
|
/// [`crate::config::LLAMA_MODEL_PARAMS`], and nothing in between.
|
||||||
|
fn section(found: &Model, settings: &BTreeMap<String, String>) -> String {
|
||||||
|
let mut lines = vec![format!("model = {}", found.path)];
|
||||||
|
for (key, flag) in [
|
||||||
|
("contextSize", "ctx-size"),
|
||||||
|
("gpuLayers", "n-gpu-layers"),
|
||||||
|
("threads", "threads"),
|
||||||
|
// How far ahead the draft head guesses. Not defaulted: 2 measured 7%
|
||||||
|
// faster than llama.cpp's 3 on this machine's GPU, once, which is a
|
||||||
|
// reason to make the knob reachable and not a reason to move it for
|
||||||
|
// everybody.
|
||||||
|
("specDraftNMax", "spec-draft-n-max"),
|
||||||
|
] {
|
||||||
|
if let Some(value) = settings.get(key).map(|value| value.trim())
|
||||||
|
&& !value.is_empty()
|
||||||
|
{
|
||||||
|
lines.push(format!("{flag} = {value}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One slot unless this model is told otherwise. A session is one
|
||||||
|
// conversation making one request at a time, and a second session's turn
|
||||||
|
// waits rather than splitting the cache: measured 2026-09-19 on the 27B
|
||||||
|
// here, 41.5 tok/s plain at any slot count, **61.4** with the draft head
|
||||||
|
// at one slot, and **28** with the head at four. Speculating against a
|
||||||
|
// split KV cache is slower than not speculating at all.
|
||||||
|
let slots = settings
|
||||||
|
.get("slots")
|
||||||
|
.map(|value| value.trim())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("1");
|
||||||
|
lines.push(format!("parallel = {slots}"));
|
||||||
|
// A model carrying a multi-token-prediction head drafts with it, which is
|
||||||
|
// most of a 50% speed-up for free -- the tensors are in the file whether
|
||||||
|
// or not they are used. Conditional because it cannot be otherwise: asked
|
||||||
|
// for on a model without one, `llama-server` **exits** ("context type MTP
|
||||||
|
// requested but model doesn't contain MTP layers"). See `Model::mtp`.
|
||||||
|
if found.mtp && settings.get("speculative").map(String::as_str) != Some("off") {
|
||||||
|
lines.push("spec-type = draft-mtp".to_string());
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preset file with `name`'s section replaced by `body`, added at the end
|
||||||
|
/// if it was not there.
|
||||||
|
///
|
||||||
|
/// Text in and text out, rather than a parsed model, because the file belongs
|
||||||
|
/// to `llama-server` rather than to this: anything in it that this does not
|
||||||
|
/// understand -- a `[*]` section, a key added by a later version, a comment
|
||||||
|
/// somebody wrote -- has to survive being edited.
|
||||||
|
fn upsert(existing: &str, name: &str, body: &str) -> String {
|
||||||
|
let header = format!("[{name}]");
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut skipping = false;
|
||||||
|
let mut replaced = false;
|
||||||
|
for line in existing.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||||
|
skipping = trimmed == header;
|
||||||
|
if skipping {
|
||||||
|
replaced = true;
|
||||||
|
push_section(&mut out, &header, body);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !skipping {
|
||||||
|
out.push_str(line);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.is_empty() {
|
||||||
|
out.push_str(VERSION);
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
push_section(&mut out, &header, body);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_section(out: &mut String, header: &str, body: &str) {
|
||||||
|
if !out.ends_with("\n\n") && !out.is_empty() {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(header);
|
||||||
|
out.push('\n');
|
||||||
|
out.push_str(body.trim_end());
|
||||||
|
out.push_str("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An owner-only log opened for appending, so the two streams pointed at it do
|
||||||
|
/// not overwrite each other and an adopted router keeps what came before.
|
||||||
|
fn log_file(path: &Path) -> Result<std::fs::File> {
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(path)
|
||||||
|
.with_context(|| format!("opening {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn settings(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_section_names_the_file_and_the_flags_that_were_set() {
|
||||||
|
let found = Model {
|
||||||
|
path: "/models/a.gguf".to_string(),
|
||||||
|
mtp: true,
|
||||||
|
};
|
||||||
|
let text = section(
|
||||||
|
&found,
|
||||||
|
&settings(&[("contextSize", "8192"), ("threads", " 6 ")]),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
text,
|
||||||
|
"model = /models/a.gguf\nctx-size = 8192\nthreads = 6\nparallel = 1\n\
|
||||||
|
spec-type = draft-mtp"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A blank is not a value: it is the setting being unset, and passing
|
||||||
|
// it on is a child that exits on an empty argument.
|
||||||
|
let text = section(&found, &settings(&[("contextSize", " ")]));
|
||||||
|
assert!(!text.contains("ctx-size"), "{text}");
|
||||||
|
|
||||||
|
// The draft head is asked for only where the file has one, and can be
|
||||||
|
// turned off for a machine where it does not pay.
|
||||||
|
let plain = Model {
|
||||||
|
mtp: false,
|
||||||
|
..found.clone()
|
||||||
|
};
|
||||||
|
assert!(!section(&plain, &settings(&[])).contains("spec-type"));
|
||||||
|
assert!(!section(&found, &settings(&[("speculative", "off")])).contains("spec-type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_section_replaces_its_own_and_leaves_every_other_line_alone() {
|
||||||
|
let first = upsert("", "repo/a.gguf", "model = /models/a.gguf");
|
||||||
|
assert!(first.starts_with("version = 1\n"), "{first}");
|
||||||
|
assert!(
|
||||||
|
first.contains("[repo/a.gguf]\nmodel = /models/a.gguf\n"),
|
||||||
|
"{first}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A second model is added rather than replacing the first, because a
|
||||||
|
// reload of a file that lost a section unloads that model -- which
|
||||||
|
// would be one session taking another's model out of memory.
|
||||||
|
let both = upsert(&first, "repo/b.gguf", "model = /models/b.gguf");
|
||||||
|
assert!(both.contains("[repo/a.gguf]"), "{both}");
|
||||||
|
assert!(both.contains("[repo/b.gguf]"), "{both}");
|
||||||
|
|
||||||
|
// Editing one rewrites only its own keys, and keeps what llama.cpp's
|
||||||
|
// own file has that this does not know about.
|
||||||
|
let with_global = format!(
|
||||||
|
"version = 1\n\n[*]\njinja = true\n\n{}",
|
||||||
|
both.trim_start_matches("version = 1\n")
|
||||||
|
);
|
||||||
|
let edited = upsert(
|
||||||
|
&with_global,
|
||||||
|
"repo/a.gguf",
|
||||||
|
"model = /models/a.gguf\nctx-size = 4096",
|
||||||
|
);
|
||||||
|
assert!(edited.contains("[*]\njinja = true"), "{edited}");
|
||||||
|
assert!(edited.contains("ctx-size = 4096"), "{edited}");
|
||||||
|
assert_eq!(edited.matches("[repo/a.gguf]").count(), 1, "{edited}");
|
||||||
|
assert!(
|
||||||
|
edited.contains("[repo/b.gguf]\nmodel = /models/b.gguf"),
|
||||||
|
"{edited}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writing_the_same_settings_twice_changes_nothing() {
|
||||||
|
// What keeps an unrelated session's model in memory: the file is only
|
||||||
|
// written, and the router only told to re-read it, when the text
|
||||||
|
// actually differs.
|
||||||
|
let once = upsert("", "repo/a.gguf", "model = /models/a.gguf");
|
||||||
|
assert_eq!(upsert(&once, "repo/a.gguf", "model = /models/a.gguf"), once);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
//! What a llama session can do besides talk, and who runs it.
|
||||||
|
//!
|
||||||
|
//! Two sources, one list. The machine's `llama-server` runs a set of its own
|
||||||
|
//! -- reading, searching, editing, a shell -- and publishes them at
|
||||||
|
//! `GET /tools` in the shape a model is given, with `POST /tools` to run one.
|
||||||
|
//! That set belongs to the server rather than to any one session, so **which
|
||||||
|
//! of them a session offers is a [`Chosen`] applied at each request**: a
|
||||||
|
//! filter rather than a flag, which is what keeps one session's choice off
|
||||||
|
//! every other session sharing that server -- and what makes changing it take
|
||||||
|
//! effect on the next message rather than on a reload.
|
||||||
|
//! Anything else comes from an MCP server this backend is connected to (see
|
||||||
|
//! [`super::mcp`]). Both arrive here as a definition to offer and a way to
|
||||||
|
//! call, and nothing downstream of [`Tools::execute`] knows which a tool was.
|
||||||
|
//!
|
||||||
|
//! **The built-in tools run where the model does, and that is the point.** A
|
||||||
|
//! session on another machine edits that machine's files, because that is the
|
||||||
|
//! machine `llama-server` is on -- the same rule the model file already
|
||||||
|
//! follows. MCP tools run here instead, which is right for the opposite
|
||||||
|
//! reason: a web search wants the machine with a route out, not the one with
|
||||||
|
//! the GPU.
|
||||||
|
//!
|
||||||
|
//! **A tool's failure is a result, not an error.** A missing file, a command
|
||||||
|
//! that exited non-zero, a search that found nothing: all of those are things
|
||||||
|
//! the model has to read and act on, so they come back as the tool's output.
|
||||||
|
//! [`Tools::execute`] returns `Err` only when the tool could not be reached at
|
||||||
|
//! all, which is a fact about this server rather than about the work.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use super::mcp::McpServer;
|
||||||
|
|
||||||
|
/// How long one tool call may take.
|
||||||
|
///
|
||||||
|
/// This is the shell tool's budget as much as anything: a build, a test run,
|
||||||
|
/// a `find` over a large tree. Bounded because it blocks the turn, and a
|
||||||
|
/// session stuck behind a command that will never finish cannot even be told
|
||||||
|
/// to stop.
|
||||||
|
const EXECUTE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// The tools one session has, and how to run each of them.
|
||||||
|
pub struct Tools {
|
||||||
|
/// The `llama-server` these belong to. Replaced when the session's model
|
||||||
|
/// changes, because that is a different server on a different port.
|
||||||
|
endpoint: String,
|
||||||
|
/// What the model is given, in the order it is offered: the server's own
|
||||||
|
/// tools first, then each MCP server's. Named as well, because which of
|
||||||
|
/// them a session offers is decided per request -- see [`offered`](Self::offered).
|
||||||
|
definitions: Vec<(String, Value)>,
|
||||||
|
/// The server's tools, and whether each is run relative to a working
|
||||||
|
/// directory. Only the ones that say so are sent one -- a tool that
|
||||||
|
/// ignores it would still have its cache keyed on it.
|
||||||
|
server: HashMap<String, bool>,
|
||||||
|
/// Connected MCP servers, each of which knows its own tools by the
|
||||||
|
/// prefixed names they were offered under.
|
||||||
|
mcp: Vec<Arc<Mutex<McpServer>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tools {
|
||||||
|
/// Asks a ready `llama-server` what it offers and adds what the MCP
|
||||||
|
/// servers offered. Everything it has, rather than what one session wants:
|
||||||
|
/// the choosing is [`Chosen`]'s, per request.
|
||||||
|
///
|
||||||
|
/// A session with no built-in tools -- or none at all -- is a perfectly
|
||||||
|
/// good session, so nothing here treats "no tools" as a failure. What *is*
|
||||||
|
/// a failure is not being able to ask at all, because that is the same
|
||||||
|
/// server the conversation is about to go to.
|
||||||
|
pub fn discover(endpoint: &str, mcp: Vec<Arc<Mutex<McpServer>>>) -> Result<Self> {
|
||||||
|
let mut response = ureq::get(format!("{endpoint}/tools"))
|
||||||
|
// A server started with no `--tools` answers **403** here, not an
|
||||||
|
// empty list -- the route is off rather than empty. Read as a
|
||||||
|
// failure that was a session which never started, for the one
|
||||||
|
// setting whose whole purpose is to have no tools.
|
||||||
|
.config()
|
||||||
|
.http_status_as_error(false)
|
||||||
|
.build()
|
||||||
|
.call()
|
||||||
|
.context("asking llama-server which tools it has")?;
|
||||||
|
let catalog: Vec<Value> = if response.status().is_success() {
|
||||||
|
response
|
||||||
|
.body_mut()
|
||||||
|
.read_json()
|
||||||
|
.context("reading llama-server's tool list")?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let mut definitions = Vec::new();
|
||||||
|
let mut server = HashMap::new();
|
||||||
|
for entry in &catalog {
|
||||||
|
let Some(name) = entry
|
||||||
|
.pointer("/definition/function/name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(definition) = entry.get("definition") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
server.insert(
|
||||||
|
name.to_string(),
|
||||||
|
entry
|
||||||
|
.get("uses_cwd")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
);
|
||||||
|
definitions.push((name.to_string(), definition.clone()));
|
||||||
|
}
|
||||||
|
for connected in &mcp {
|
||||||
|
for tool in connected.lock().unwrap().tools() {
|
||||||
|
definitions.push((tool.qualified.clone(), tool.definition.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
endpoint: endpoint.to_string(),
|
||||||
|
definitions,
|
||||||
|
server,
|
||||||
|
mcp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What goes in the request's `tools`, or `None` when there is nothing to
|
||||||
|
/// offer.
|
||||||
|
///
|
||||||
|
/// Absent rather than empty for a reason that shows on screen: a chat
|
||||||
|
/// template branches on whether tools were given, and an empty list
|
||||||
|
/// renders the whole "you may call one or more functions" preamble with no
|
||||||
|
/// functions under it.
|
||||||
|
///
|
||||||
|
/// `chosen` names which of the *server's* tools this session offers; an
|
||||||
|
/// MCP server's are the session's own to begin with, since they were
|
||||||
|
/// configured against this provider rather than found on the machine.
|
||||||
|
pub fn offered(&self, chosen: &Chosen) -> Option<Vec<&Value>> {
|
||||||
|
let offered: Vec<&Value> = self
|
||||||
|
.definitions
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, _)| !self.server.contains_key(name) || chosen.takes(name))
|
||||||
|
.map(|(_, definition)| definition)
|
||||||
|
.collect();
|
||||||
|
(!offered.is_empty()).then_some(offered)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this is a tool this session has, which decides what to do
|
||||||
|
/// about a call naming something else.
|
||||||
|
///
|
||||||
|
/// `chosen` for the same reason [`offered`](Self::offered) takes it: a
|
||||||
|
/// tool the session did not offer is one the model invented, and being
|
||||||
|
/// told that is better than being asked for permission to run something
|
||||||
|
/// that would then be refused. [`execute`](Self::execute) checks it as
|
||||||
|
/// well, because that is where running it is actually prevented.
|
||||||
|
pub fn knows(&self, name: &str, chosen: &Chosen) -> bool {
|
||||||
|
(self.server.contains_key(name) && chosen.takes(name)) || self.mcp_for(name).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The MCP server that offered `name`, if one did.
|
||||||
|
fn mcp_for(&self, name: &str) -> Option<&Arc<Mutex<McpServer>>> {
|
||||||
|
self.mcp.iter().find(|server| {
|
||||||
|
server
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.tools()
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool.qualified == name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one call and returns what the model should read.
|
||||||
|
///
|
||||||
|
/// `cwd` is the session's working directory, sent only to the tools that
|
||||||
|
/// say they use one. A session with no working directory sends none, and
|
||||||
|
/// `llama-server` falls back to its own -- which is the honest outcome:
|
||||||
|
/// this server has no better answer for where "here" is.
|
||||||
|
pub fn execute(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
arguments: &Value,
|
||||||
|
cwd: Option<&str>,
|
||||||
|
chosen: &Chosen,
|
||||||
|
) -> Result<String> {
|
||||||
|
if let Some(server) = self.mcp_for(name) {
|
||||||
|
return server.lock().unwrap().call(name, arguments);
|
||||||
|
}
|
||||||
|
// A tool this session did not offer is not one it may run, even where
|
||||||
|
// the server has it. A model that names one anyway is guessing, and a
|
||||||
|
// session whose whole setting was to have no tools must not get a
|
||||||
|
// shell out of a guess.
|
||||||
|
let uses_cwd = *self
|
||||||
|
.server
|
||||||
|
.get(name)
|
||||||
|
.filter(|_| chosen.takes(name))
|
||||||
|
.with_context(|| format!("no tool called {name}"))?;
|
||||||
|
let mut request = ureq::post(format!("{}/tools", self.endpoint))
|
||||||
|
.config()
|
||||||
|
.timeout_global(Some(EXECUTE_TIMEOUT))
|
||||||
|
// The refusal is a sentence the model can act on, so it is read as
|
||||||
|
// one rather than discarded in favour of its status code -- the
|
||||||
|
// same reason `super::refusal` exists for generation.
|
||||||
|
.http_status_as_error(false)
|
||||||
|
.build()
|
||||||
|
.header("content-type", "application/json");
|
||||||
|
if let (true, Some(cwd)) = (uses_cwd, cwd) {
|
||||||
|
request = request.header("x-tool-cwd", cwd);
|
||||||
|
}
|
||||||
|
let mut response = request
|
||||||
|
.send_json(json!({"tool": name, "params": arguments}))
|
||||||
|
.with_context(|| format!("asking llama-server to run {name}"))?;
|
||||||
|
let body = response
|
||||||
|
.body_mut()
|
||||||
|
.read_to_string()
|
||||||
|
.with_context(|| format!("reading what {name} produced"))?;
|
||||||
|
Ok(match serde_json::from_str::<Value>(&body) {
|
||||||
|
Ok(answer) => result_text(&answer),
|
||||||
|
// Not JSON at all: hand over what was said rather than a parse
|
||||||
|
// error about it, since the model is what has to carry on.
|
||||||
|
Err(_) => body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of the server's own tools a session offers its model.
|
||||||
|
///
|
||||||
|
/// Held by the session rather than settled at discovery, so that changing it
|
||||||
|
/// takes effect on the next message: one shared server has one set of tools,
|
||||||
|
/// and which of them go into a request is this.
|
||||||
|
///
|
||||||
|
/// Three cases rather than a list of names, because two of them are what
|
||||||
|
/// people actually write: everything, nothing, or these. "Nothing" is the one
|
||||||
|
/// that has to be sayable at all -- an empty list would be indistinguishable
|
||||||
|
/// from the setting being unset, which is what `all` means.
|
||||||
|
pub enum Chosen {
|
||||||
|
All,
|
||||||
|
None,
|
||||||
|
Named(std::collections::HashSet<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Chosen {
|
||||||
|
pub fn from(wanted: Option<&str>) -> Self {
|
||||||
|
match wanted.map(str::trim) {
|
||||||
|
None | Some("") | Some("all") => Self::All,
|
||||||
|
Some("none") => Self::None,
|
||||||
|
Some(list) => Self::Named(
|
||||||
|
list.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn takes(&self, name: &str) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::All => true,
|
||||||
|
Self::None => false,
|
||||||
|
Self::Named(names) => names.contains(name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /tools`'s answer as the text a model is given.
|
||||||
|
///
|
||||||
|
/// The server answers `plain_text_response` for a tool that ran and `error`
|
||||||
|
/// for one that did not, and both are the model's business -- see this
|
||||||
|
/// module's note on failures being results. Anything else is handed over as
|
||||||
|
/// itself rather than discarded, since a tool this build has not seen before
|
||||||
|
/// is exactly the case where guessing is worst.
|
||||||
|
fn result_text(answer: &Value) -> String {
|
||||||
|
if let Some(text) = answer.get("plain_text_response").and_then(Value::as_str) {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
if let Some(message) = answer.get("error").and_then(Value::as_str) {
|
||||||
|
return message.to_string();
|
||||||
|
}
|
||||||
|
answer.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much a session asks before it acts.
|
||||||
|
///
|
||||||
|
/// Two, because two is what the mechanism underneath actually has. The web UI
|
||||||
|
/// that ships with `llama-server` asks before every call and remembers the
|
||||||
|
/// tools you said "always" to, and that pair -- a prompt and a growing set of
|
||||||
|
/// exceptions -- is the whole of its permission model. A third mode sitting
|
||||||
|
/// between them would have to invent a rule about which tools are "edits",
|
||||||
|
/// and the rule would be this app's opinion rather than anything the tools
|
||||||
|
/// declare.
|
||||||
|
pub const MODES: &[&str] = &["manual", "bypassPermissions"];
|
||||||
|
|
||||||
|
/// What a new llama session asks by default.
|
||||||
|
///
|
||||||
|
/// The cautious one, matching the web UI: a model with a shell on somebody's
|
||||||
|
/// own machine is the case to be wrong about in this direction, and one tap
|
||||||
|
/// on "always allow" is what makes it bearable afterwards.
|
||||||
|
pub const DEFAULT_MODE: &str = "manual";
|
||||||
|
|
||||||
|
/// The answer that makes an allowance permanent for the session. The tool's
|
||||||
|
/// name follows it, which is what makes the transcript alone enough to
|
||||||
|
/// rebuild the set -- see `super::allowed`.
|
||||||
|
pub const ALWAYS_PREFIX: &str = "Always allow ";
|
||||||
|
pub const ALLOW_ONCE: &str = "Allow once";
|
||||||
|
pub const REFUSE: &str = "Don't allow";
|
||||||
|
|
||||||
|
/// What the model is told when a call was refused.
|
||||||
|
///
|
||||||
|
/// Addressed to the model, not to the reader: it has to understand that the
|
||||||
|
/// work did not happen and that trying the same call again is not the way
|
||||||
|
/// round it, or it retries in a loop.
|
||||||
|
pub const REFUSED: &str = "The person using this session did not allow this call, so it was not run. Do not try it \
|
||||||
|
again -- say what you were going to do and why it needed that, and let them decide.";
|
||||||
|
|
||||||
|
/// What stands in for a call that never finished, when the transcript is read
|
||||||
|
/// back into a conversation.
|
||||||
|
///
|
||||||
|
/// Every tool call in the history owes a result, because that is the shape a
|
||||||
|
/// chat template renders; a turn stopped between the call and its result
|
||||||
|
/// leaves one that has none. Saying so is better than inventing an outcome,
|
||||||
|
/// and better than dropping the call -- which would tell the model it never
|
||||||
|
/// asked.
|
||||||
|
pub const UNFINISHED: &str = "This call was interrupted before it produced anything.";
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tool_that_ran_reads_as_its_output() {
|
||||||
|
assert_eq!(
|
||||||
|
result_text(&json!({"plain_text_response": "hello\n"})),
|
||||||
|
"hello\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// The model is told what went wrong, because the model is what has to do
|
||||||
|
/// something about it -- read a different path, fix the command.
|
||||||
|
fn a_tool_that_failed_reads_as_its_message() {
|
||||||
|
assert_eq!(
|
||||||
|
result_text(&json!({"error": "cannot stat file: /tmp/nope"})),
|
||||||
|
"cannot stat file: /tmp/nope",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anything_else_is_handed_over_as_itself() {
|
||||||
|
assert_eq!(result_text(&json!({"rows": 2})), "{\"rows\":2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The vocabulary is `llama-server`'s own, and "none" has to mean it:
|
||||||
|
/// before the router, that word was a flag the server refused and so a
|
||||||
|
/// session that never started.
|
||||||
|
#[test]
|
||||||
|
fn which_tools_a_session_offers_reads_the_servers_own_words() {
|
||||||
|
for unset in [None, Some(""), Some(" all ")] {
|
||||||
|
assert!(Chosen::from(unset).takes("read_file"), "{unset:?}");
|
||||||
|
}
|
||||||
|
assert!(!Chosen::from(Some("none")).takes("read_file"));
|
||||||
|
|
||||||
|
let two = Chosen::from(Some("read_file, grep_search"));
|
||||||
|
assert!(two.takes("read_file"));
|
||||||
|
assert!(two.takes("grep_search"));
|
||||||
|
assert!(!two.takes("exec_shell_command"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filter is over the *machine's* tools. An MCP server's belong to the
|
||||||
|
/// session already -- they were configured against this provider rather
|
||||||
|
/// than found on the machine -- so "none" is a session that still searches
|
||||||
|
/// the web, and a request with nothing left in it offers no `tools` key at
|
||||||
|
/// all rather than an empty one.
|
||||||
|
#[test]
|
||||||
|
fn a_filtered_catalog_keeps_the_mcp_tools_and_vanishes_when_empty() {
|
||||||
|
let tools = Tools {
|
||||||
|
endpoint: "http://127.0.0.1:1".to_string(),
|
||||||
|
definitions: vec![
|
||||||
|
("read_file".to_string(), json!({"name": "read_file"})),
|
||||||
|
("exec_shell_command".to_string(), json!({"name": "shell"})),
|
||||||
|
("exa_web_search_exa".to_string(), json!({"name": "search"})),
|
||||||
|
],
|
||||||
|
server: [
|
||||||
|
("read_file".to_string(), true),
|
||||||
|
("exec_shell_command".to_string(), false),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
mcp: Vec::new(),
|
||||||
|
};
|
||||||
|
let offered = |wanted| {
|
||||||
|
tools
|
||||||
|
.offered(&Chosen::from(wanted))
|
||||||
|
.map(|offered| offered.len())
|
||||||
|
};
|
||||||
|
assert_eq!(offered(None), Some(3));
|
||||||
|
assert_eq!(offered(Some("read_file")), Some(2));
|
||||||
|
assert_eq!(offered(Some("none")), Some(1));
|
||||||
|
|
||||||
|
let nothing = Tools {
|
||||||
|
definitions: Vec::new(),
|
||||||
|
..tools
|
||||||
|
};
|
||||||
|
assert_eq!(nothing.offered(&Chosen::All), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
+547
-186
File diff suppressed because it is too large.
Load diff
@@ -48,16 +48,16 @@ impl Operation {
|
|||||||
#[serde(rename_all = "camelCase", tag = "state")]
|
#[serde(rename_all = "camelCase", tag = "state")]
|
||||||
pub enum Change {
|
pub enum Change {
|
||||||
Started {
|
Started {
|
||||||
setup: String,
|
machine: String,
|
||||||
session: String,
|
session: String,
|
||||||
operation: Operation,
|
operation: Operation,
|
||||||
},
|
},
|
||||||
Finished {
|
Finished {
|
||||||
setup: String,
|
machine: String,
|
||||||
session: String,
|
session: String,
|
||||||
},
|
},
|
||||||
Failed {
|
Failed {
|
||||||
setup: String,
|
machine: String,
|
||||||
session: String,
|
session: String,
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
@@ -66,11 +66,11 @@ pub enum Change {
|
|||||||
impl Change {
|
impl Change {
|
||||||
/// Which machine this is about, so a stream scoped to one can drop the rest.
|
/// Which machine this is about, so a stream scoped to one can drop the rest.
|
||||||
/// Every variant carries it; matching here keeps that fact in one place.
|
/// Every variant carries it; matching here keeps that fact in one place.
|
||||||
pub fn setup(&self) -> &str {
|
pub fn machine(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::Started { setup, .. }
|
Self::Started { machine, .. }
|
||||||
| Self::Finished { setup, .. }
|
| Self::Finished { machine, .. }
|
||||||
| Self::Failed { setup, .. } => setup,
|
| Self::Failed { machine, .. } => machine,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,12 +106,12 @@ impl Registry {
|
|||||||
/// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it reports
|
/// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it reports
|
||||||
/// a failure. Dropping without settling means the task was cancelled or
|
/// a failure. Dropping without settling means the task was cancelled or
|
||||||
/// panicked, and a row stuck on "importing" for ever is a worse answer.
|
/// panicked, and a row stuck on "importing" for ever is a worse answer.
|
||||||
pub fn begin(self: &Arc<Self>, setup: &str, session: &str, operation: Operation) -> InFlight {
|
pub fn begin(self: &Arc<Self>, machine: &str, session: &str, operation: Operation) -> InFlight {
|
||||||
let key = (setup.to_string(), session.to_string());
|
let key = (machine.to_string(), session.to_string());
|
||||||
self.running.lock().unwrap().insert(key.clone(), operation);
|
self.running.lock().unwrap().insert(key.clone(), operation);
|
||||||
self.failures.lock().unwrap().remove(&key);
|
self.failures.lock().unwrap().remove(&key);
|
||||||
let _ = self.changes.send(Change::Started {
|
let _ = self.changes.send(Change::Started {
|
||||||
setup: key.0.clone(),
|
machine: key.0.clone(),
|
||||||
session: key.1.clone(),
|
session: key.1.clone(),
|
||||||
operation,
|
operation,
|
||||||
});
|
});
|
||||||
@@ -123,25 +123,25 @@ impl Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What is happening to this session, if anything is.
|
/// What is happening to this session, if anything is.
|
||||||
pub fn running(&self, setup: &str, session: &str) -> Option<Operation> {
|
pub fn running(&self, machine: &str, session: &str) -> Option<Operation> {
|
||||||
let key = (setup.to_string(), session.to_string());
|
let key = (machine.to_string(), session.to_string());
|
||||||
self.running.lock().unwrap().get(&key).copied()
|
self.running.lock().unwrap().get(&key).copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How the last operation on this session failed, if it did.
|
/// How the last operation on this session failed, if it did.
|
||||||
pub fn failure(&self, setup: &str, session: &str) -> Option<String> {
|
pub fn failure(&self, machine: &str, session: &str) -> Option<String> {
|
||||||
let key = (setup.to_string(), session.to_string());
|
let key = (machine.to_string(), session.to_string());
|
||||||
self.failures.lock().unwrap().get(&key).cloned()
|
self.failures.lock().unwrap().get(&key).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forgets failures against sessions the machine no longer has. Called from
|
/// Forgets failures against sessions the machine no longer has. Called from
|
||||||
/// the listing, which is the only place that knows what is still there.
|
/// the listing, which is the only place that knows what is still there.
|
||||||
pub fn prune(&self, setup: &str, present: &[String]) {
|
pub fn prune(&self, machine: &str, present: &[String]) {
|
||||||
self.failures
|
self.failures
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.retain(|(kept_setup, session), _| {
|
.retain(|(kept_machine, session), _| {
|
||||||
kept_setup != setup || present.iter().any(|id| id == session)
|
kept_machine != machine || present.iter().any(|id| id == session)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ impl InFlight {
|
|||||||
}
|
}
|
||||||
self.settled = true;
|
self.settled = true;
|
||||||
self.registry.running.lock().unwrap().remove(&self.key);
|
self.registry.running.lock().unwrap().remove(&self.key);
|
||||||
let (setup, session) = (self.key.0.clone(), self.key.1.clone());
|
let (machine, session) = (self.key.0.clone(), self.key.1.clone());
|
||||||
let change = match failure {
|
let change = match failure {
|
||||||
Some(message) => {
|
Some(message) => {
|
||||||
self.registry
|
self.registry
|
||||||
@@ -183,12 +183,12 @@ impl InFlight {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.insert(self.key.clone(), message.clone());
|
.insert(self.key.clone(), message.clone());
|
||||||
Change::Failed {
|
Change::Failed {
|
||||||
setup,
|
machine,
|
||||||
session,
|
session,
|
||||||
message,
|
message,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => Change::Finished { setup, session },
|
None => Change::Finished { machine, session },
|
||||||
};
|
};
|
||||||
let _ = self.registry.changes.send(change);
|
let _ = self.registry.changes.send(change);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use anyhow::{Context, Result};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const RECORD_FILE: &str = "process.json";
|
const RECORD_FILE: &str = "process.json";
|
||||||
|
const STOP_REQUEST_FILE: &str = "stop-requested";
|
||||||
|
|
||||||
/// A process this server started and expects to outlive it.
|
/// A process this server started and expects to outlive it.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -55,6 +56,16 @@ pub enum Detail {
|
|||||||
/// Spoken to over HTTP on a loopback port, which is all it takes to find
|
/// Spoken to over HTTP on a loopback port, which is all it takes to find
|
||||||
/// it again -- there is no stream to be partway through.
|
/// it again -- there is no stream to be partway through.
|
||||||
Http { port: u16 },
|
Http { port: u16 },
|
||||||
|
/// The same, for a process this session reaches but does not own: the
|
||||||
|
/// llama.cpp router serving every session on its machine.
|
||||||
|
///
|
||||||
|
/// A variant rather than a flag because of what it forbids. Liveness is
|
||||||
|
/// the identical question -- a session whose router has gone has no model
|
||||||
|
/// -- but ending it is not this session's to ask, and [`signal`] is where
|
||||||
|
/// that is enforced: stopping, deleting or cleaning up after a session
|
||||||
|
/// must not take a model out of memory for every other session on that
|
||||||
|
/// machine.
|
||||||
|
Shared { port: u16 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a recorded process is still there.
|
/// Whether a recorded process is still there.
|
||||||
@@ -81,6 +92,15 @@ impl Record {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this server may end that process.
|
||||||
|
///
|
||||||
|
/// False for the one it shares -- see [`Detail::Shared`]. Liveness is the
|
||||||
|
/// identical question for both, which is why this is separate from it:
|
||||||
|
/// "is it there?" and "is it mine to end?" are asked in different places.
|
||||||
|
pub fn ours(&self) -> bool {
|
||||||
|
!matches!(self.detail, Detail::Shared { .. })
|
||||||
|
}
|
||||||
|
|
||||||
pub fn liveness(&self) -> Liveness {
|
pub fn liveness(&self) -> Liveness {
|
||||||
match stat_of(self.pid) {
|
match stat_of(self.pid) {
|
||||||
// A different start time is a reused pid, so definitely not ours.
|
// A different start time is a reused pid, so definitely not ours.
|
||||||
@@ -101,6 +121,28 @@ fn path(session_dir: &Path) -> PathBuf {
|
|||||||
session_dir.join(RECORD_FILE)
|
session_dir.join(RECORD_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stop_request_path(session_dir: &Path) -> PathBuf {
|
||||||
|
session_dir.join(STOP_REQUEST_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the process as one the server deliberately asked to end, so its watcher reports an exit
|
||||||
|
/// without presenting ordinary stderr from the process's lifetime as the cause.
|
||||||
|
pub fn mark_stopping(session_dir: &Path) -> Result<()> {
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(stop_request_path(session_dir))
|
||||||
|
.with_context(|| format!("marking {} as stopping", session_dir.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this process's exit was deliberately requested.
|
||||||
|
pub fn stopping(session_dir: &Path) -> bool {
|
||||||
|
stop_request_path(session_dir).is_file()
|
||||||
|
}
|
||||||
|
|
||||||
/// The recorded process and whether it is still there, or `None` when nothing
|
/// The recorded process and whether it is still there, or `None` when nothing
|
||||||
/// usable is recorded. A record that does not parse reads as no record: the
|
/// usable is recorded. A record that does not parse reads as no record: the
|
||||||
/// only way to get one is a crash partway through writing it, and the safe
|
/// only way to get one is a crash partway through writing it, and the safe
|
||||||
@@ -179,13 +221,47 @@ pub fn size_of(path: &Path) -> u64 {
|
|||||||
/// Forgets the recorded process -- for one confirmed dead, or a session
|
/// Forgets the recorded process -- for one confirmed dead, or a session
|
||||||
/// being deleted. The path out for [`write`].
|
/// being deleted. The path out for [`write`].
|
||||||
pub fn clear(session_dir: &Path) {
|
pub fn clear(session_dir: &Path) {
|
||||||
let path = path(session_dir);
|
for path in [path(session_dir), stop_request_path(session_dir)] {
|
||||||
if let Err(err) = std::fs::remove_file(&path)
|
if let Err(err) = std::fs::remove_file(&path)
|
||||||
&& err.kind() != std::io::ErrorKind::NotFound
|
&& err.kind() != std::io::ErrorKind::NotFound
|
||||||
{
|
{
|
||||||
tracing::warn!("couldn't remove {}: {err}", path.display());
|
tracing::warn!("couldn't remove {}: {err}", path.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a session stdin fifo and opens it read-write for the child.
|
||||||
|
///
|
||||||
|
/// The child holding the write end is what keeps a detached JSON server from
|
||||||
|
/// reading EOF when ai-server restarts and temporarily closes its own writer.
|
||||||
|
pub fn make_fifo(path: &Path) -> Result<std::fs::File> {
|
||||||
|
if !path.exists() {
|
||||||
|
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
|
||||||
|
.with_context(|| format!("{} is not a usable path", path.display()))?;
|
||||||
|
// SAFETY: `c_path` is nul-terminated and this call only reads it.
|
||||||
|
let made = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
|
||||||
|
if made != 0 {
|
||||||
|
return Err(std::io::Error::last_os_error())
|
||||||
|
.with_context(|| format!("creating the fifo {}", path.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(path)
|
||||||
|
.with_context(|| format!("opening the fifo {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh owner-only log for a detached session process.
|
||||||
|
pub fn create_log(path: &Path) -> Result<std::fs::File> {
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(path)
|
||||||
|
.with_context(|| format!("creating {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Grace period between asking a session's process to stop and killing it.
|
/// Grace period between asking a session's process to stop and killing it.
|
||||||
/// Here rather than beside each caller: two drivers plus the manager had
|
/// Here rather than beside each caller: two drivers plus the manager had
|
||||||
@@ -199,10 +275,10 @@ pub const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
|
|||||||
/// a SIGKILL would cost whatever it had not flushed; SIGKILL after the grace
|
/// a SIGKILL would cost whatever it had not flushed; SIGKILL after the grace
|
||||||
/// period because a session the phone has deleted must not still be running.
|
/// period because a session the phone has deleted must not still be running.
|
||||||
pub fn stop(record: &Record, grace: std::time::Duration) {
|
pub fn stop(record: &Record, grace: std::time::Duration) {
|
||||||
if record.liveness() != Liveness::Alive {
|
if !record.ours() || record.liveness() != Liveness::Alive {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
signal(record.pid, libc::SIGTERM);
|
signal(record, libc::SIGTERM);
|
||||||
let record = record.clone();
|
let record = record.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
tokio::time::sleep(grace).await;
|
tokio::time::sleep(grace).await;
|
||||||
@@ -228,6 +304,10 @@ pub fn wait_gone(records: &[Record], grace: std::time::Duration) {
|
|||||||
|
|
||||||
let deadline = std::time::Instant::now() + grace;
|
let deadline = std::time::Instant::now() + grace;
|
||||||
for record in records {
|
for record in records {
|
||||||
|
// Never asked to stop, so there is nothing to wait out.
|
||||||
|
if !record.ours() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
while record.liveness() == Liveness::Alive && std::time::Instant::now() < deadline {
|
while record.liveness() == Liveness::Alive && std::time::Instant::now() < deadline {
|
||||||
std::thread::sleep(LOOK);
|
std::thread::sleep(LOOK);
|
||||||
}
|
}
|
||||||
@@ -246,17 +326,25 @@ fn kill_if_still_there(record: &Record, grace: std::time::Duration) {
|
|||||||
record.pid,
|
record.pid,
|
||||||
grace
|
grace
|
||||||
);
|
);
|
||||||
signal(record.pid, libc::SIGKILL);
|
signal(record, libc::SIGKILL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn signal(pid: u32, signal: libc::c_int) {
|
/// The one place a session's process is signalled, which is why the refusal to
|
||||||
|
/// signal a shared one lives here rather than at each caller: every path out of
|
||||||
|
/// a session -- stopped, deleted, cleaned up on the way down -- ends in this
|
||||||
|
/// function, and the one that forgot would be a model unloaded under somebody
|
||||||
|
/// else's turn.
|
||||||
|
fn signal(record: &Record, signal: libc::c_int) {
|
||||||
|
if !record.ours() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// SAFETY: `kill` with a positive pid touches only that process, and the pid
|
// SAFETY: `kill` with a positive pid touches only that process, and the pid
|
||||||
// came from a record whose start time was just confirmed to match -- so it
|
// came from a record whose start time was just confirmed to match -- so it
|
||||||
// is still the process this server started, not a reused number. A failure
|
// is still the process this server started, not a reused number. A failure
|
||||||
// (already gone) is nothing to act on.
|
// (already gone) is nothing to act on.
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::kill(pid as libc::pid_t, signal);
|
libc::kill(record.pid as libc::pid_t, signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,13 +455,18 @@ mod tests {
|
|||||||
write(dir.path(), &record);
|
write(dir.path(), &record);
|
||||||
assert_eq!(live(dir.path()), Some(record.clone()));
|
assert_eq!(live(dir.path()), Some(record.clone()));
|
||||||
|
|
||||||
// And the other shape round trips through the same file.
|
// And the other shapes round trip through the same file.
|
||||||
record.detail = Detail::Http { port: 8080 };
|
for detail in [Detail::Http { port: 8080 }, Detail::Shared { port: 8080 }] {
|
||||||
|
record.detail = detail;
|
||||||
write(dir.path(), &record);
|
write(dir.path(), &record);
|
||||||
assert_eq!(live(dir.path()), Some(record));
|
assert_eq!(live(dir.path()), Some(record.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
mark_stopping(dir.path()).expect("mark stopping");
|
||||||
|
assert!(stopping(dir.path()));
|
||||||
clear(dir.path());
|
clear(dir.path());
|
||||||
assert_eq!(live(dir.path()), None);
|
assert_eq!(live(dir.path()), None);
|
||||||
|
assert!(!stopping(dir.path()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -403,6 +496,29 @@ mod tests {
|
|||||||
assert!(stray.is_empty(), "left behind {stray:?}");
|
assert!(stray.is_empty(), "left behind {stray:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The whole of what [`Detail::Shared`] is for: a session ending must not
|
||||||
|
/// take the machine's llama.cpp router with it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_shared_process_is_not_stopped_with_the_session_that_reached_it() {
|
||||||
|
let mut child = std::process::Command::new("sleep")
|
||||||
|
.arg("30")
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn sleep");
|
||||||
|
let shared = Record::of(child.id(), Detail::Shared { port: 1 }).expect("start time");
|
||||||
|
stop(&shared, std::time::Duration::from_millis(50));
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
assert_eq!(shared.liveness(), Liveness::Alive, "the router was killed");
|
||||||
|
|
||||||
|
// The same process, recorded as one this session owns, does stop.
|
||||||
|
let owned = Record {
|
||||||
|
detail: Detail::Http { port: 1 },
|
||||||
|
..shared
|
||||||
|
};
|
||||||
|
stop(&owned, std::time::Duration::from_millis(50));
|
||||||
|
let _ = child.wait();
|
||||||
|
assert_eq!(owned.liveness(), Liveness::Dead);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_dead_or_unreadable_record_is_not_live() {
|
fn a_dead_or_unreadable_record_is_not_live() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
|||||||
+237
-23
@@ -7,9 +7,10 @@
|
|||||||
//! work on a subagent's file unchanged.
|
//! work on a subagent's file unchanged.
|
||||||
//!
|
//!
|
||||||
//! Storage is `<session dir>/subagents/<id>/{meta.json,transcript.jsonl}`,
|
//! Storage is `<session dir>/subagents/<id>/{meta.json,transcript.jsonl}`,
|
||||||
//! where `<id>` is the Task tool_use id that started it -- unique, stable
|
//! where `<id>` is Claude's Task tool_use id or Codex's child thread id --
|
||||||
//! across a backend restart, and already the key the parent side uses. Only
|
//! unique, stable across a backend restart, and already the key the parent
|
||||||
//! ids matching [`is_subagent_id`] are ever turned into a path.
|
//! side uses. Only ids matching [`is_subagent_id`] are ever turned into a
|
||||||
|
//! path.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -67,6 +68,9 @@ pub struct SubagentInfo {
|
|||||||
/// session's but with no driver behind it.
|
/// session's but with no driver behind it.
|
||||||
pub struct Subagent {
|
pub struct Subagent {
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
|
/// Its own name, from `meta.json`, so an open subagent can be listed
|
||||||
|
/// without reading every subagent's directory back off disk.
|
||||||
|
title: String,
|
||||||
transcript: Mutex<Transcript>,
|
transcript: Mutex<Transcript>,
|
||||||
events: broadcast::Sender<SeqEvent>,
|
events: broadcast::Sender<SeqEvent>,
|
||||||
/// Mirrors the transcript's last `Status` event, kept live rather than
|
/// Mirrors the transcript's last `Status` event, kept live rather than
|
||||||
@@ -80,6 +84,10 @@ pub struct Subagent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Subagent {
|
impl Subagent {
|
||||||
|
fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
pub fn transcript_path(&self) -> PathBuf {
|
pub fn transcript_path(&self) -> PathBuf {
|
||||||
self.dir.join("transcript.jsonl")
|
self.dir.join("transcript.jsonl")
|
||||||
}
|
}
|
||||||
@@ -96,7 +104,7 @@ impl Subagent {
|
|||||||
*self.status.lock().unwrap() != SessionStatus::Exited
|
*self.status.lock().unwrap() != SessionStatus::Exited
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append(&self, event: Event) {
|
fn append(&self, event: Event) -> bool {
|
||||||
let mut transcript = self.transcript.lock().unwrap();
|
let mut transcript = self.transcript.lock().unwrap();
|
||||||
match transcript.append(event, super::now()) {
|
match transcript.append(event, super::now()) {
|
||||||
Ok(entry) => {
|
Ok(entry) => {
|
||||||
@@ -106,14 +114,18 @@ impl Subagent {
|
|||||||
// No subscribers is fine; the transcript already has it,
|
// No subscribers is fine; the transcript already has it,
|
||||||
// same as a session's pump.
|
// same as a session's pump.
|
||||||
let _ = self.events.send(entry);
|
let _ = self.events.send(entry);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("subagent transcript append failed: {err:#}");
|
||||||
|
false
|
||||||
}
|
}
|
||||||
Err(err) => tracing::error!("subagent transcript append failed: {err:#}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every subagent one session has started, keyed by the Task tool_use id
|
/// Every subagent one session has started, keyed by the provider's stable
|
||||||
/// that names it.
|
/// parent-side id for it.
|
||||||
///
|
///
|
||||||
/// Lives beside a session's driver rather than inside it: a claude driver
|
/// Lives beside a session's driver rather than inside it: a claude driver
|
||||||
/// holds an `Arc` to this and routes child lines into it; echo uses it for
|
/// holds an `Arc` to this and routes child lines into it; echo uses it for
|
||||||
@@ -124,14 +136,28 @@ pub struct Subagents {
|
|||||||
/// The session's own directory; subagents live under `<dir>/subagents`.
|
/// The session's own directory; subagents live under `<dir>/subagents`.
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
live: Mutex<HashMap<String, Arc<Subagent>>>,
|
live: Mutex<HashMap<String, Arc<Subagent>>>,
|
||||||
|
/// Open subagents by id, with the title each is known by, seeded from disk so an adopted
|
||||||
|
/// session starts with the measured set rather than waiting to see lifecycle edges which are
|
||||||
|
/// already behind its stdout cursor. The title is held here so that listing what is open
|
||||||
|
/// costs no directory read -- `GET /sessions/{id}/background` asks often.
|
||||||
|
open: Mutex<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Subagents {
|
impl Subagents {
|
||||||
pub fn new(session_dir: PathBuf) -> Self {
|
pub fn new(session_dir: PathBuf) -> Self {
|
||||||
Self {
|
let subagents = Self {
|
||||||
dir: session_dir,
|
dir: session_dir,
|
||||||
live: Mutex::new(HashMap::new()),
|
live: Mutex::new(HashMap::new()),
|
||||||
}
|
open: Mutex::new(HashMap::new()),
|
||||||
|
};
|
||||||
|
subagents.open.lock().unwrap().extend(
|
||||||
|
subagents
|
||||||
|
.list(true)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|info| info.status == SessionStatus::Running)
|
||||||
|
.map(|info| (info.id, info.title)),
|
||||||
|
);
|
||||||
|
subagents
|
||||||
}
|
}
|
||||||
|
|
||||||
fn subagents_dir(&self) -> PathBuf {
|
fn subagents_dir(&self) -> PathBuf {
|
||||||
@@ -200,6 +226,7 @@ impl Subagents {
|
|||||||
let (events, _) = broadcast::channel(EVENT_BUFFER);
|
let (events, _) = broadcast::channel(EVENT_BUFFER);
|
||||||
Ok(Arc::new(Subagent {
|
Ok(Arc::new(Subagent {
|
||||||
dir,
|
dir,
|
||||||
|
title: meta.title,
|
||||||
transcript: Mutex::new(transcript),
|
transcript: Mutex::new(transcript),
|
||||||
events,
|
events,
|
||||||
status: Mutex::new(status),
|
status: Mutex::new(status),
|
||||||
@@ -221,6 +248,12 @@ impl Subagents {
|
|||||||
}
|
}
|
||||||
match self.open_or_create(id, title, prompt) {
|
match self.open_or_create(id, title, prompt) {
|
||||||
Ok(subagent) => {
|
Ok(subagent) => {
|
||||||
|
if subagent.is_open() {
|
||||||
|
self.open
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(id.to_string(), subagent.title().to_string());
|
||||||
|
}
|
||||||
live.insert(id.to_string(), subagent);
|
live.insert(id.to_string(), subagent);
|
||||||
}
|
}
|
||||||
Err(err) => tracing::error!("couldn't start subagent {id}: {err:#}"),
|
Err(err) => tracing::error!("couldn't start subagent {id}: {err:#}"),
|
||||||
@@ -258,13 +291,54 @@ impl Subagents {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the subagent named `id` exists and has not finished. `false`
|
||||||
|
/// for an id that is not a subagent's at all -- a backgrounded command's
|
||||||
|
/// tool call reaches here with the same shape.
|
||||||
|
pub fn is_open(&self, id: &str) -> bool {
|
||||||
|
self.get(id).is_some_and(|subagent| subagent.is_open())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this session has any subagent still working.
|
||||||
|
///
|
||||||
|
/// That is the whole point of it. A backend restart adopts a session's
|
||||||
|
/// process and picks its stdout back up from a recorded offset, so the
|
||||||
|
/// `task_started` lines for subagents launched before the restart are
|
||||||
|
/// already behind that offset and the translator never sees them -- it
|
||||||
|
/// starts with an empty set and reports the session `Idle` at the end of
|
||||||
|
/// a turn it should have called [`SessionStatus::Waiting`].
|
||||||
|
pub fn any_open(&self, session_running: bool) -> bool {
|
||||||
|
session_running && self.open_count() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Latest measured number of live subagents, including ones found on disk at construction.
|
||||||
|
pub fn open_count(&self) -> usize {
|
||||||
|
self.open.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live subagents, each with the title it is known by. Ordered by
|
||||||
|
/// id, which says nothing about when they started but does mean two
|
||||||
|
/// readings agree; read from memory, so a caller may ask often.
|
||||||
|
pub fn open_list(&self) -> Vec<(String, String)> {
|
||||||
|
let mut open: Vec<(String, String)> = self
|
||||||
|
.open
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(id, title)| (id.clone(), title.clone()))
|
||||||
|
.collect();
|
||||||
|
open.sort();
|
||||||
|
open
|
||||||
|
}
|
||||||
|
|
||||||
/// Appends one event to a subagent's own transcript. A no-op, with a
|
/// Appends one event to a subagent's own transcript. A no-op, with a
|
||||||
/// debug log, for an id nothing was started under -- a child line for a
|
/// debug log, for an id nothing was started under -- a child line for a
|
||||||
/// subagent this registry never opened is dropped rather than guessed
|
/// subagent this registry never opened is dropped rather than guessed
|
||||||
/// at.
|
/// at.
|
||||||
pub fn record(&self, id: &str, event: Event) {
|
pub fn record(&self, id: &str, event: Event) {
|
||||||
match self.live.lock().unwrap().get(id).cloned() {
|
match self.live.lock().unwrap().get(id).cloned() {
|
||||||
Some(subagent) => subagent.append(event),
|
Some(subagent) => {
|
||||||
|
subagent.append(event);
|
||||||
|
}
|
||||||
None => tracing::debug!("dropping an event for unknown subagent {id}"),
|
None => tracing::debug!("dropping an event for unknown subagent {id}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,10 +352,11 @@ impl Subagents {
|
|||||||
pub fn finish(&self, id: &str) {
|
pub fn finish(&self, id: &str) {
|
||||||
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
|
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
|
||||||
&& subagent.is_open()
|
&& subagent.is_open()
|
||||||
{
|
&& subagent.append(Event::Status {
|
||||||
subagent.append(Event::Status {
|
|
||||||
state: SessionStatus::Exited,
|
state: SessionStatus::Exited,
|
||||||
});
|
})
|
||||||
|
{
|
||||||
|
self.open.lock().unwrap().remove(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,24 +368,84 @@ impl Subagents {
|
|||||||
pub fn reopen(&self, id: &str) {
|
pub fn reopen(&self, id: &str) {
|
||||||
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
|
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
|
||||||
&& !subagent.is_open()
|
&& !subagent.is_open()
|
||||||
{
|
&& subagent.append(Event::Status {
|
||||||
subagent.append(Event::Status {
|
|
||||||
state: SessionStatus::Running,
|
state: SessionStatus::Running,
|
||||||
});
|
})
|
||||||
|
{
|
||||||
|
self.open
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(id.to_string(), subagent.title().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The parent session's process is gone, so nothing still open here has
|
/// The parent session's process is gone, so nothing still open here has
|
||||||
/// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #4.
|
/// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #5.
|
||||||
|
///
|
||||||
|
/// Read from the directory rather than from `live`, because a subagent
|
||||||
|
/// left `Running` by a *previous* run of this server is exactly the one
|
||||||
|
/// that needs closing and is the one `live` does not have: nothing in
|
||||||
|
/// this process ever touched it, so it would keep reading `running`
|
||||||
|
/// every time its session was started again, with nothing able to
|
||||||
|
/// correct it.
|
||||||
pub fn finish_all(&self) {
|
pub fn finish_all(&self) {
|
||||||
let subagents: Vec<Arc<Subagent>> = self.live.lock().unwrap().values().cloned().collect();
|
// `list(true)` reports each one's own last status rather than
|
||||||
for subagent in subagents {
|
// rewriting a running one as unknown -- what is wanted here is which
|
||||||
if subagent.is_open() {
|
// are open on disk, and this call is the very thing that decides the
|
||||||
subagent.append(Event::Status {
|
// session is not running.
|
||||||
state: SessionStatus::Exited,
|
for info in self.list(true) {
|
||||||
});
|
if info.status != SessionStatus::Running {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let _ = self.get(&info.id);
|
||||||
|
self.finish(&info.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes subagents, transcripts and all -- `POST
|
||||||
|
/// /sessions/{id}/subagents/delete`, and the path out for [`start`]
|
||||||
|
/// short of deleting the whole session.
|
||||||
|
///
|
||||||
|
/// **All or nothing, and only for one that has finished.** Every id is
|
||||||
|
/// checked before anything is removed, so a batch naming one that is
|
||||||
|
/// still running leaves the others exactly as they were rather than
|
||||||
|
/// deleting up to the offender -- the reader picked a set, and a set
|
||||||
|
/// half-deleted is indistinguishable, on the list, from rows they never
|
||||||
|
/// picked. Refusing a running one is not withholding the capability:
|
||||||
|
/// its transcript is still being written to, and its process is the
|
||||||
|
/// session's to stop.
|
||||||
|
///
|
||||||
|
/// `session_running` decides what "running" means here, exactly as it
|
||||||
|
/// does in [`Subagents::list`].
|
||||||
|
///
|
||||||
|
/// [`start`]: Subagents::start
|
||||||
|
pub fn delete(&self, ids: &[String], session_running: bool) -> Result<()> {
|
||||||
|
let dirs: Vec<PathBuf> = ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| {
|
||||||
|
anyhow::ensure!(is_subagent_id(id), "{id} is not a subagent id");
|
||||||
|
Ok(self.subagents_dir().join(id))
|
||||||
|
})
|
||||||
|
.collect::<Result<_>>()?;
|
||||||
|
for (id, dir) in ids.iter().zip(&dirs) {
|
||||||
|
let info = info_of(dir, session_running)
|
||||||
|
.with_context(|| format!("there is no subagent {id} here"))?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
info.status != SessionStatus::Running,
|
||||||
|
"\"{}\" is still running -- it can be deleted once it has finished",
|
||||||
|
info.title
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut live = self.live.lock().unwrap();
|
||||||
|
for (id, dir) in ids.iter().zip(&dirs) {
|
||||||
|
fs::remove_dir_all(dir).with_context(|| format!("delete {}", dir.display()))?;
|
||||||
|
// Out of the registry as well as off the disk, so a later child
|
||||||
|
// line for this id starts a new subagent rather than appending
|
||||||
|
// to an unlinked file nothing can read.
|
||||||
|
live.remove(id);
|
||||||
|
self.open.lock().unwrap().remove(id);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every subagent under this session's directory, oldest first --
|
/// Every subagent under this session's directory, oldest first --
|
||||||
@@ -411,6 +546,7 @@ mod tests {
|
|||||||
.count(),
|
.count(),
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
assert_eq!(subagents.open_count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -428,6 +564,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
// A fresh registry, the way a backend restart builds one.
|
// A fresh registry, the way a backend restart builds one.
|
||||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||||
|
assert_eq!(subagents.open_count(), 1);
|
||||||
let subagent = subagents.get("toolu_2").expect("reopened");
|
let subagent = subagents.get("toolu_2").expect("reopened");
|
||||||
assert!(subagent.is_open());
|
assert!(subagent.is_open());
|
||||||
subagents.record(
|
subagents.record(
|
||||||
@@ -449,6 +586,7 @@ mod tests {
|
|||||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||||
subagents.start("toolu_3", "helper", None);
|
subagents.start("toolu_3", "helper", None);
|
||||||
subagents.finish("toolu_3");
|
subagents.finish("toolu_3");
|
||||||
|
assert_eq!(subagents.open_count(), 0);
|
||||||
let subagent = subagents.get("toolu_3").unwrap();
|
let subagent = subagents.get("toolu_3").unwrap();
|
||||||
assert!(!subagent.is_open());
|
assert!(!subagent.is_open());
|
||||||
// On disk too, not only in the live cache `is_open` reads.
|
// On disk too, not only in the live cache `is_open` reads.
|
||||||
@@ -461,6 +599,82 @@ mod tests {
|
|||||||
|
|
||||||
// Finishing an id that was never a subagent is a no-op, not a panic.
|
// Finishing an id that was never a subagent is a no-op, not a panic.
|
||||||
subagents.finish("never-started");
|
subagents.finish("never-started");
|
||||||
|
|
||||||
|
subagents.reopen("toolu_3");
|
||||||
|
assert_eq!(subagents.open_count(), 1);
|
||||||
|
subagents.finish("toolu_3");
|
||||||
|
assert_eq!(subagents.open_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finish_all_closes_one_left_running_by_an_earlier_run() {
|
||||||
|
// The state a backend restart leaves behind: the subagent is on disk
|
||||||
|
// reading `Running` and nothing in this process has touched it, so a
|
||||||
|
// registry that only knew its own `live` map left it running for
|
||||||
|
// ever -- and the phone showed it as running every time the session
|
||||||
|
// was started again.
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
{
|
||||||
|
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||||
|
subagents.start("toolu_stale", "helper", None);
|
||||||
|
}
|
||||||
|
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||||
|
subagents.finish_all();
|
||||||
|
let rows = subagents.list(true);
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].status, SessionStatus::Exited);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_finished_subagent_takes_its_directory_with_it() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||||
|
subagents.start("toolu_done", "helper", None);
|
||||||
|
subagents.finish("toolu_done");
|
||||||
|
|
||||||
|
subagents
|
||||||
|
.delete(&["toolu_done".to_string()], true)
|
||||||
|
.expect("delete");
|
||||||
|
|
||||||
|
assert!(subagents.list(true).is_empty());
|
||||||
|
assert!(!dir.path().join("subagents").join("toolu_done").exists());
|
||||||
|
// Out of the live registry too, so a later line starts a new one rather than appending to
|
||||||
|
// a file nothing can read.
|
||||||
|
assert!(subagents.get("toolu_done").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_batch_with_a_running_one_in_it_deletes_none_of_it() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||||
|
subagents.start("toolu_done", "finished helper", None);
|
||||||
|
subagents.finish("toolu_done");
|
||||||
|
subagents.start("toolu_busy", "busy helper", None);
|
||||||
|
|
||||||
|
let err = subagents
|
||||||
|
.delete(&["toolu_done".to_string(), "toolu_busy".to_string()], true)
|
||||||
|
.expect_err("refused");
|
||||||
|
assert!(err.to_string().contains("busy helper"), "{err:#}");
|
||||||
|
assert_eq!(subagents.list(true).len(), 2);
|
||||||
|
|
||||||
|
// The same batch once the session behind it has no process: nothing there is running, so
|
||||||
|
// both go.
|
||||||
|
subagents
|
||||||
|
.delete(&["toolu_done".to_string(), "toolu_busy".to_string()], false)
|
||||||
|
.expect("delete");
|
||||||
|
assert!(subagents.list(false).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_an_id_that_is_not_there_is_refused_rather_than_ignored() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||||
|
assert!(
|
||||||
|
subagents
|
||||||
|
.delete(&["toolu_ghost".to_string()], true)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(subagents.delete(&["../../etc".to_string()], true).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use std::path::Path;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::driver::{Event, SessionStatus, context_after};
|
use super::driver::{Event, SessionStatus, context_after, context_limit_after};
|
||||||
|
|
||||||
/// One transcript line: an [`Event`] plus its position and time. The event
|
/// One transcript line: an [`Event`] plus its position and time. The event
|
||||||
/// is flattened so the wire shape stays one flat object.
|
/// is flattened so the wire shape stays one flat object.
|
||||||
@@ -34,6 +34,7 @@ pub struct Transcript {
|
|||||||
last_status: Option<SessionStatus>,
|
last_status: Option<SessionStatus>,
|
||||||
last_activity: Option<f64>,
|
last_activity: Option<f64>,
|
||||||
context_tokens: Option<u64>,
|
context_tokens: Option<u64>,
|
||||||
|
context_limit: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Transcript {
|
impl Transcript {
|
||||||
@@ -68,6 +69,12 @@ impl Transcript {
|
|||||||
context_tokens: existing
|
context_tokens: existing
|
||||||
.iter()
|
.iter()
|
||||||
.fold(None, |current, entry| context_after(current, &entry.event)),
|
.fold(None, |current, entry| context_after(current, &entry.event)),
|
||||||
|
// The same fold for the same reason: a session whose process has
|
||||||
|
// since exited has no window, and the newest `ContextWindow` line
|
||||||
|
// alone would not know that.
|
||||||
|
context_limit: existing.iter().fold(None, |current, entry| {
|
||||||
|
context_limit_after(current, &entry.event)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +114,12 @@ impl Transcript {
|
|||||||
self.context_tokens
|
self.context_tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What that figure is out of, as of opening, and `None` where this
|
||||||
|
/// session's provider does not say.
|
||||||
|
pub fn context_limit(&self) -> Option<u64> {
|
||||||
|
self.context_limit
|
||||||
|
}
|
||||||
|
|
||||||
/// Appends `event`, assigning it the next sequence number. Flushed per
|
/// Appends `event`, assigning it the next sequence number. Flushed per
|
||||||
/// event: each line is tiny, and the transcript is the source of truth a
|
/// event: each line is tiny, and the transcript is the source of truth a
|
||||||
/// crash must not lose the tail of.
|
/// crash must not lose the tail of.
|
||||||
@@ -309,22 +322,78 @@ impl<'a> Indexed<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse(&self, range: Range<usize>) -> Result<Vec<SeqEvent>> {
|
fn parse(&self, range: Range<usize>) -> Result<Vec<SeqEvent>> {
|
||||||
self.lines[range]
|
self.lines[range.clone()]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|at| {
|
.enumerate()
|
||||||
serde_json::from_str(&self.text[at.clone()])
|
.map(|(offset, at)| self.parse_at(range.start + offset, at.clone()))
|
||||||
.with_context(|| format!("bad transcript line in {}", self.path.display()))
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_one(&self, index: usize) -> Result<SeqEvent> {
|
fn parse_one(&self, index: usize) -> Result<SeqEvent> {
|
||||||
serde_json::from_str(&self.text[self.lines[index].clone()])
|
self.parse_at(index, self.lines[index].clone())
|
||||||
.with_context(|| format!("bad transcript line in {}", self.path.display()))
|
}
|
||||||
|
|
||||||
|
/// One line, degrading to [`Event::Unreadable`] rather than failing when
|
||||||
|
/// this build cannot make sense of the event on it.
|
||||||
|
///
|
||||||
|
/// **A transcript is append-only and permanent, so the set of kinds that
|
||||||
|
/// can appear in one only ever grows.** What this build writes is not what
|
||||||
|
/// it may have to read: a line may come from a newer server, or from an
|
||||||
|
/// older one that wrote a kind since dropped. Refusing the whole file for
|
||||||
|
/// one such line is what happened on 2026-09-06 -- a kind was removed after
|
||||||
|
/// transcripts had recorded it, every read of those files failed, and the
|
||||||
|
/// sessions in them could not be opened, listed, paged or sent to. One
|
||||||
|
/// unfamiliar word took down every conversation it appeared in.
|
||||||
|
///
|
||||||
|
/// So the line survives as a line. It keeps its seq, which is the part
|
||||||
|
/// everything downstream is addressed by, and says what it was rather than
|
||||||
|
/// pretending to be something -- there is a row for that on the phone
|
||||||
|
/// already.
|
||||||
|
///
|
||||||
|
/// The seq itself is still required, and this still fails without one: a
|
||||||
|
/// line that cannot say where it sits in the sequence is not a line this
|
||||||
|
/// file can hold, and quietly dropping it would hand out a seq the file
|
||||||
|
/// already contains.
|
||||||
|
fn parse_at(&self, index: usize, at: Range<usize>) -> Result<SeqEvent> {
|
||||||
|
let line = &self.text[at];
|
||||||
|
match serde_json::from_str(line) {
|
||||||
|
Ok(entry) => Ok(entry),
|
||||||
|
Err(err) => {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct JustPlace {
|
||||||
|
seq: u64,
|
||||||
|
ts: f64,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: Option<String>,
|
||||||
|
}
|
||||||
|
let place: JustPlace = serde_json::from_str(line)
|
||||||
|
.with_context(|| format!("bad transcript line in {}", self.path.display()))?;
|
||||||
|
// Debug rather than a warning: a transcript written against a
|
||||||
|
// newer build has one of these per line it wrote, and the row
|
||||||
|
// on the phone is where this is actually reported.
|
||||||
|
tracing::debug!(
|
||||||
|
"transcript line {index} of {} (seq {}) is not one this build can read: {err}",
|
||||||
|
self.path.display(),
|
||||||
|
place.seq,
|
||||||
|
);
|
||||||
|
Ok(SeqEvent {
|
||||||
|
seq: place.seq,
|
||||||
|
ts: place.ts,
|
||||||
|
event: Event::Unreadable {
|
||||||
|
kind: place.kind.unwrap_or_else(|| "no kind".to_string()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The newest `limit` *rows* ending at line `end`, with each run of
|
/// The newest `limit` *rows* ending at line `end`, with each run of
|
||||||
/// consecutive [`Event::AssistantText`] deltas concatenated into one.
|
/// consecutive deltas of one streamed kind concatenated into one.
|
||||||
|
///
|
||||||
|
/// Two kinds stream a token at a time -- [`Event::AssistantText`] and
|
||||||
|
/// [`Event::Thinking`] -- and a run is of one of them, never of both: they
|
||||||
|
/// are two rows on screen, and welding them would put a model's working
|
||||||
|
/// inside what it said.
|
||||||
///
|
///
|
||||||
/// A reply is stored a token at a time, so a window counted in events is a
|
/// A reply is stored a token at a time, so a window counted in events is a
|
||||||
/// fraction of a row for a reply and a whole row for a tool call, and the
|
/// fraction of a row for a reply and a whole row for a tool call, and the
|
||||||
@@ -340,17 +409,22 @@ impl<'a> Indexed<'a> {
|
|||||||
fn parse_coalesced(&self, start: usize, end: usize, limit: usize) -> Result<Vec<SeqEvent>> {
|
fn parse_coalesced(&self, start: usize, end: usize, limit: usize) -> Result<Vec<SeqEvent>> {
|
||||||
// Newest first while walking back, reversed to transcript order at the end.
|
// Newest first while walking back, reversed to transcript order at the end.
|
||||||
let mut out: Vec<SeqEvent> = Vec::new();
|
let mut out: Vec<SeqEvent> = Vec::new();
|
||||||
// The run currently being gathered: its oldest seq/ts so far, and its deltas newest-first.
|
// The run currently being gathered: which kind it is, its oldest
|
||||||
let mut run: Option<(u64, f64, Vec<String>)> = None;
|
// seq/ts so far, and its deltas newest-first.
|
||||||
let flush = |run: &mut Option<(u64, f64, Vec<String>)>, out: &mut Vec<SeqEvent>| {
|
let mut run: Option<Run> = None;
|
||||||
if let Some((seq, ts, mut deltas)) = run.take() {
|
let flush = |run: &mut Option<Run>, out: &mut Vec<SeqEvent>| {
|
||||||
|
if let Some(Run {
|
||||||
|
kind,
|
||||||
|
seq,
|
||||||
|
ts,
|
||||||
|
mut deltas,
|
||||||
|
}) = run.take()
|
||||||
|
{
|
||||||
deltas.reverse();
|
deltas.reverse();
|
||||||
out.push(SeqEvent {
|
out.push(SeqEvent {
|
||||||
seq,
|
seq,
|
||||||
ts,
|
ts,
|
||||||
event: Event::AssistantText {
|
event: kind.of_delta(deltas.concat()),
|
||||||
delta: deltas.concat(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -364,19 +438,35 @@ impl<'a> Indexed<'a> {
|
|||||||
}
|
}
|
||||||
index -= 1;
|
index -= 1;
|
||||||
let entry = self.parse_one(index)?;
|
let entry = self.parse_one(index)?;
|
||||||
if let Event::AssistantText { delta } = entry.event {
|
match Streamed::of(entry.event) {
|
||||||
match run {
|
Ok((kind, delta)) => {
|
||||||
Some((ref mut seq, ref mut ts, ref mut deltas)) => {
|
// A run of a different kind ends here, whatever it was
|
||||||
*seq = entry.seq;
|
// gathering: the two are separate rows.
|
||||||
*ts = entry.ts;
|
if run.as_ref().is_some_and(|open| open.kind != kind) {
|
||||||
deltas.push(delta);
|
|
||||||
}
|
|
||||||
None => run = Some((entry.seq, entry.ts, vec![delta])),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// The run above this event (newer) is complete: it is a row, and so is this event.
|
|
||||||
flush(&mut run, &mut out);
|
flush(&mut run, &mut out);
|
||||||
out.push(entry);
|
}
|
||||||
|
match run {
|
||||||
|
Some(ref mut open) => {
|
||||||
|
open.seq = entry.seq;
|
||||||
|
open.ts = entry.ts;
|
||||||
|
open.deltas.push(delta);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
run = Some(Run {
|
||||||
|
kind,
|
||||||
|
seq: entry.seq,
|
||||||
|
ts: entry.ts,
|
||||||
|
deltas: vec![delta],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(event) => {
|
||||||
|
// The run above this event (newer) is complete: it is a row, and so is this
|
||||||
|
// event.
|
||||||
|
flush(&mut run, &mut out);
|
||||||
|
out.push(SeqEvent { event, ..entry });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flush(&mut run, &mut out);
|
flush(&mut run, &mut out);
|
||||||
@@ -385,6 +475,41 @@ impl<'a> Indexed<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One run of same-kind deltas being gathered by [`Indexed::parse_coalesced`].
|
||||||
|
struct Run {
|
||||||
|
kind: Streamed,
|
||||||
|
seq: u64,
|
||||||
|
ts: f64,
|
||||||
|
deltas: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event kinds that arrive a fragment at a time and are read as one row.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Streamed {
|
||||||
|
Text,
|
||||||
|
Thinking,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Streamed {
|
||||||
|
/// The kind and fragment of a streamed event, or the event back unchanged
|
||||||
|
/// when it is not one -- so the caller cannot forget to put it back.
|
||||||
|
fn of(event: Event) -> std::result::Result<(Self, String), Event> {
|
||||||
|
match event {
|
||||||
|
Event::AssistantText { delta } => Ok((Self::Text, delta)),
|
||||||
|
Event::Thinking { delta } => Ok((Self::Thinking, delta)),
|
||||||
|
other => Err(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The run put back together as the event it was a run of.
|
||||||
|
fn of_delta(self, delta: String) -> Event {
|
||||||
|
match self {
|
||||||
|
Self::Text => Event::AssistantText { delta },
|
||||||
|
Self::Thinking => Event::Thinking { delta },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -396,6 +521,51 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The failure that took every live session down on 2026-09-06: an event
|
||||||
|
/// kind was removed from the enum after transcripts had already recorded
|
||||||
|
/// it, so every read of those files failed and the sessions in them could
|
||||||
|
/// not be opened, listed, paged or sent to.
|
||||||
|
///
|
||||||
|
/// A transcript is append-only and permanent, so **the set of kinds that
|
||||||
|
/// can appear in one only ever grows** -- what this build writes is not
|
||||||
|
/// what it may have to read. A line it cannot make sense of has to be a
|
||||||
|
/// line, not the end of the file.
|
||||||
|
#[test]
|
||||||
|
fn a_line_of_a_kind_this_build_does_not_know_does_not_break_the_file() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("transcript.jsonl");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
concat!(
|
||||||
|
r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#,
|
||||||
|
"\n",
|
||||||
|
r#"{"seq":2,"ts":2.0,"type":"assistantText","delta":"hi"}"#,
|
||||||
|
"\n",
|
||||||
|
r#"{"seq":3,"ts":3.0,"type":"taskNote","about":"t","title":"h","status":"completed"}"#,
|
||||||
|
"\n",
|
||||||
|
r#"{"seq":4,"ts":4.0,"type":"somethingFromTheFuture","whatever":[1,2]}"#,
|
||||||
|
"\n",
|
||||||
|
r#"{"seq":5,"ts":5.0,"type":"status","state":"idle"}"#,
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
|
||||||
|
let entries = read_after(&path, 0).expect("a strange line is not a broken file");
|
||||||
|
assert_eq!(entries.len(), 5, "every line is still a line: {entries:?}");
|
||||||
|
assert_eq!(entries[4].seq, 5);
|
||||||
|
|
||||||
|
let transcript = Transcript::open(&path).expect("open");
|
||||||
|
assert_eq!(transcript.last_status(), Some(SessionStatus::Idle));
|
||||||
|
// The next seq is counted from the newest *line*, whatever kind it is.
|
||||||
|
// Skipping the ones this build cannot read would hand out a seq the
|
||||||
|
// file already contains.
|
||||||
|
assert_eq!(transcript.next_seq, 6);
|
||||||
|
|
||||||
|
let window = read_window(&path, None, None, 80, false).expect("window");
|
||||||
|
assert_eq!(window.len(), 5, "{window:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn assigns_increasing_seqs_and_replays_after_a_cursor() {
|
fn assigns_increasing_seqs_and_replays_after_a_cursor() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
@@ -586,6 +756,55 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// Thinking streams a fragment at a time exactly as a reply does, so a page
|
||||||
|
/// counted in rows has to coalesce it too -- otherwise one block of working
|
||||||
|
/// is a whole page of near-duplicate events. And the two runs stay two: a
|
||||||
|
/// weld across the boundary would put the model's working inside what it
|
||||||
|
/// said, in the prompt as well as on screen.
|
||||||
|
fn thinking_deltas_coalesce_into_a_row_of_their_own() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("transcript.jsonl");
|
||||||
|
let mut transcript = Transcript::open(&path).expect("append");
|
||||||
|
for delta in ["think", "ing"] {
|
||||||
|
transcript
|
||||||
|
.append(
|
||||||
|
Event::Thinking {
|
||||||
|
delta: delta.into(),
|
||||||
|
},
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
.expect("append");
|
||||||
|
}
|
||||||
|
transcript
|
||||||
|
.append(Event::ThinkingDone { ms: 1200 }, 0.0)
|
||||||
|
.expect("append");
|
||||||
|
for delta in ["said", " it"] {
|
||||||
|
transcript.append(text(delta), 0.0).expect("append");
|
||||||
|
}
|
||||||
|
|
||||||
|
// `before` past the end, since coalescing is only ever done on settled
|
||||||
|
// history -- see [`read_window`].
|
||||||
|
let rows = read_window(&path, Some(6), None, 10, true).expect("window");
|
||||||
|
assert_eq!(rows.len(), 3);
|
||||||
|
assert!(matches!(
|
||||||
|
&rows[0],
|
||||||
|
SeqEvent { seq: 1, event: Event::Thinking { delta }, .. } if delta == "thinking"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
&rows[1],
|
||||||
|
SeqEvent {
|
||||||
|
seq: 3,
|
||||||
|
event: Event::ThinkingDone { ms: 1200 },
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
&rows[2],
|
||||||
|
SeqEvent { seq: 4, event: Event::AssistantText { delta }, .. } if delta == "said it"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_floor_inside_a_delta_run_leaves_the_partial_run_it_cuts() {
|
fn a_floor_inside_a_delta_run_leaves_the_partial_run_it_cuts() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
@@ -738,6 +957,9 @@ mod tests {
|
|||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
text("hello"),
|
text("hello"),
|
||||||
|
Event::AssistantTextFinal {
|
||||||
|
text: "hello, revised".into(),
|
||||||
|
},
|
||||||
Event::ToolStart {
|
Event::ToolStart {
|
||||||
id: "t1".into(),
|
id: "t1".into(),
|
||||||
tool: "bash".into(),
|
tool: "bash".into(),
|
||||||
@@ -767,12 +989,18 @@ mod tests {
|
|||||||
id: "q1".into(),
|
id: "q1".into(),
|
||||||
answers: vec!["Yes".into()],
|
answers: vec!["Yes".into()],
|
||||||
},
|
},
|
||||||
|
Event::BackgroundTasks { count: 5 },
|
||||||
Event::Status {
|
Event::Status {
|
||||||
state: SessionStatus::Idle,
|
state: SessionStatus::Idle,
|
||||||
},
|
},
|
||||||
Event::UsageDelta {
|
Event::UsageDelta {
|
||||||
tokens: 42,
|
tokens: 42,
|
||||||
context: Some(42),
|
context: Some(42),
|
||||||
|
tokens_per_second: None,
|
||||||
|
prefill_ms: None,
|
||||||
|
},
|
||||||
|
Event::AuthenticationRequired {
|
||||||
|
message: "sign in again".into(),
|
||||||
},
|
},
|
||||||
Event::Error {
|
Event::Error {
|
||||||
message: "boom".into(),
|
message: "boom".into(),
|
||||||
|
|||||||
@@ -91,21 +91,99 @@ pub enum Streams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The machine a session's process runs on.
|
/// The machine a session's process runs on.
|
||||||
|
#[derive(Clone)]
|
||||||
pub enum Transport {
|
pub enum Transport {
|
||||||
/// The machine this server is running on.
|
/// The machine this server is running on.
|
||||||
Here,
|
Here,
|
||||||
/// Reached with the system `ssh` client. Owns its entry rather than
|
/// Reached with the system `ssh` client. Owns its entry rather than
|
||||||
/// borrowing it, so a session keeps working against the config it was
|
/// borrowing it, so a session keeps working against the config it was
|
||||||
/// spawned with even if the setup is edited afterwards.
|
/// spawned with even if the machine is edited afterwards.
|
||||||
Ssh { name: String, ssh: SshConfig },
|
Ssh { name: String, ssh: SshConfig },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Transport {
|
impl Transport {
|
||||||
/// The transport a setup describes; a setup with no `ssh` is here.
|
/// Exchanges newline-delimited JSON requests with a short-lived stdio
|
||||||
pub fn for_setup(setup: &crate::config::SetupConfig) -> Self {
|
/// server. `initial` is written first; after its response arrives,
|
||||||
match &setup.ssh {
|
/// `requests` is written and the response bearing `wanted_id` is returned.
|
||||||
|
///
|
||||||
|
/// This is the shape Codex's app-server requires for a usage read: an
|
||||||
|
/// initialize round trip must finish before the initialized notification
|
||||||
|
/// and account request are accepted.
|
||||||
|
pub fn request_json_blocking(
|
||||||
|
&self,
|
||||||
|
launch: &Launch,
|
||||||
|
initial: &serde_json::Value,
|
||||||
|
requests: &[serde_json::Value],
|
||||||
|
wanted_id: u64,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
|
||||||
|
let host = match self {
|
||||||
|
Self::Here => None,
|
||||||
|
Self::Ssh { ssh, .. } => Some(ssh),
|
||||||
|
};
|
||||||
|
let mut command = crate::ssh::command(
|
||||||
|
host,
|
||||||
|
&launch.program,
|
||||||
|
&launch.args,
|
||||||
|
launch.cwd.as_deref(),
|
||||||
|
launch.forward,
|
||||||
|
);
|
||||||
|
command
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let mut child = command
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("couldn't run \"{}\" {}", launch.program, self.describe()))?;
|
||||||
|
let mut stdin = child.stdin.take().context("the JSON server has no stdin")?;
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.context("the JSON server has no stdout")?;
|
||||||
|
writeln!(stdin, "{initial}")?;
|
||||||
|
stdin.flush()?;
|
||||||
|
let mut reader = BufReader::new(stdout);
|
||||||
|
let mut line = String::new();
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
if reader.read_line(&mut line)? == 0 {
|
||||||
|
anyhow::bail!("the JSON server exited before initialization completed");
|
||||||
|
}
|
||||||
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if value.get("id").and_then(serde_json::Value::as_u64)
|
||||||
|
== initial.get("id").and_then(serde_json::Value::as_u64)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for request in requests {
|
||||||
|
writeln!(stdin, "{request}")?;
|
||||||
|
}
|
||||||
|
stdin.flush()?;
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
if reader.read_line(&mut line)? == 0 {
|
||||||
|
anyhow::bail!("the JSON server exited before answering request {wanted_id}");
|
||||||
|
}
|
||||||
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if value.get("id").and_then(serde_json::Value::as_u64) == Some(wanted_id) {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transport a machine describes; a machine with no `ssh` is here.
|
||||||
|
pub fn for_machine(machine: &crate::config::MachineConfig) -> Self {
|
||||||
|
match &machine.ssh {
|
||||||
Some(ssh) => Self::Ssh {
|
Some(ssh) => Self::Ssh {
|
||||||
name: setup.name.clone(),
|
name: machine.name.clone(),
|
||||||
ssh: ssh.clone(),
|
ssh: ssh.clone(),
|
||||||
},
|
},
|
||||||
None => Self::Here,
|
None => Self::Here,
|
||||||
@@ -174,6 +252,12 @@ impl Transport {
|
|||||||
/// would otherwise need a runtime to ask a machine a question. Both build the
|
/// would otherwise need a runtime to ask a machine a question. Both build the
|
||||||
/// invocation the same way.
|
/// invocation the same way.
|
||||||
pub fn capture_blocking(&self, launch: &Launch) -> Result<String> {
|
pub fn capture_blocking(&self, launch: &Launch) -> Result<String> {
|
||||||
|
Ok(String::from_utf8_lossy(&self.capture_bytes_blocking(launch)?).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The byte-preserving form of [`capture_blocking`](Self::capture_blocking), used when a
|
||||||
|
/// driver copies a file back from the machine it runs on.
|
||||||
|
pub fn capture_bytes_blocking(&self, launch: &Launch) -> Result<Vec<u8>> {
|
||||||
let host = match self {
|
let host = match self {
|
||||||
Self::Here => None,
|
Self::Here => None,
|
||||||
Self::Ssh { ssh, .. } => Some(ssh),
|
Self::Ssh { ssh, .. } => Some(ssh),
|
||||||
@@ -195,7 +279,7 @@ impl Transport {
|
|||||||
stderr
|
stderr
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
Ok(output.stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs `launch` with `input` on its stdin and reports everything it
|
/// Runs `launch` with `input` on its stdin and reports everything it
|
||||||
|
|||||||
+464
-90
@@ -1,4 +1,4 @@
|
|||||||
//! Usage-limit reporting -- the same numbers as Claude Code's `/usage`.
|
//! Usage-limit reporting -- the same numbers the provider CLIs show.
|
||||||
//!
|
//!
|
||||||
//! Polls `https://api.anthropic.com/api/oauth/usage` with the OAuth access
|
//! Polls `https://api.anthropic.com/api/oauth/usage` with the OAuth access
|
||||||
//! token from Claude Code's local credential store. The endpoint is
|
//! token from Claude Code's local credential store. The endpoint is
|
||||||
@@ -12,16 +12,16 @@
|
|||||||
//! below enforces the latter across any number of phone refreshes; there is no
|
//! below enforces the latter across any number of phone refreshes; there is no
|
||||||
//! background poll at all.
|
//! background poll at all.
|
||||||
//!
|
//!
|
||||||
//! One [`UsageProvider`] per paid service, so a second service later is a new
|
//! One [`UsageProvider`] per paid service keeps each provider's wire format
|
||||||
//! impl behind the same snapshot shape, not a parallel screen.
|
//! behind the same snapshot shape.
|
||||||
//!
|
//!
|
||||||
//! **Asked of the machine that spends the tokens, not of this one.** A session
|
//! **Asked of the machine that spends the tokens, not of this one.** A session
|
||||||
//! runs wherever its setup says, so the account being billed is that machine's.
|
//! runs wherever its machine says, so the account being billed is that machine's.
|
||||||
//! In the layout this project aims at, `ai-server` is on the host, the host has
|
//! In the layout this project aims at, `ai-server` is on the host, the host has
|
||||||
//! no `claude` CLI, and the CLI machine is a remote -- so the one set of numbers
|
//! no `claude` CLI, and the CLI machine is a remote -- so the one set of numbers
|
||||||
//! the screen could show would be an account with no sessions. Credentials are
|
//! the screen could show would be an account with no sessions. Credentials are
|
||||||
//! read through the session `Transport`, one snapshot per setup that offers
|
//! read through the session `Transport`, one snapshot per machine that offers
|
||||||
//! Claude.
|
//! that provider.
|
||||||
//!
|
//!
|
||||||
//! The token is read *to* the backend and the HTTP call is made from here, so
|
//! The token is read *to* the backend and the HTTP call is made from here, so
|
||||||
//! the far machine needs nothing beyond a shell and the wire format stays in
|
//! the far machine needs nothing beyond a shell and the wire format stays in
|
||||||
@@ -36,7 +36,7 @@ use std::time::{Duration, Instant};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::config::SetupConfig;
|
use crate::config::MachineConfig;
|
||||||
use crate::session::transport::{Launch, Transport};
|
use crate::session::transport::{Launch, Transport};
|
||||||
|
|
||||||
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
|
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
|
||||||
@@ -60,6 +60,9 @@ pub struct UsageWindow {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
/// 0-100.
|
/// 0-100.
|
||||||
pub percent: f64,
|
pub percent: f64,
|
||||||
|
/// Length of the cycle when the provider reports one.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration_minutes: Option<u64>,
|
||||||
/// ISO-8601, as the API sends it; absent for windows that never reset.
|
/// ISO-8601, as the API sends it; absent for windows that never reset.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub resets_at: Option<String>,
|
pub resets_at: Option<String>,
|
||||||
@@ -69,12 +72,12 @@ pub struct UsageWindow {
|
|||||||
|
|
||||||
/// What came back when a machine was asked about its limits.
|
/// What came back when a machine was asked about its limits.
|
||||||
///
|
///
|
||||||
/// Four answers rather than a flag and a message, because the screen has to
|
/// Named answers rather than a flag and a message, because the screen has to
|
||||||
/// treat them differently. "Nobody is logged in here" is a machine working
|
/// treat them differently. "Nobody is logged in here" is a machine working
|
||||||
/// exactly as configured, while "I could not reach it" is a fault worth
|
/// exactly as configured, while "I could not reach it" is a fault worth
|
||||||
/// chasing, and "the endpoint refused me" says nothing about the machine at
|
/// chasing, and "the endpoint refused me" says nothing about the machine at
|
||||||
/// all. Collapsing them into one `error` string made the first look like the
|
/// all. Collapsing them into one `error` string made the first look like the
|
||||||
/// last, so a perfectly healthy setup read as broken.
|
/// last, so a perfectly healthy machine read as broken.
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
#[serde(tag = "state", rename_all = "camelCase")]
|
#[serde(tag = "state", rename_all = "camelCase")]
|
||||||
pub enum UsageState {
|
pub enum UsageState {
|
||||||
@@ -85,6 +88,13 @@ pub enum UsageState {
|
|||||||
NotLoggedIn,
|
NotLoggedIn,
|
||||||
/// The machine could not be asked at all.
|
/// The machine could not be asked at all.
|
||||||
Unreachable { detail: String },
|
Unreachable { detail: String },
|
||||||
|
/// An explicit Claude login is waiting for its browser code. Kept apart
|
||||||
|
/// from a failure because nothing is broken while somebody is completing
|
||||||
|
/// the operation on the phone.
|
||||||
|
Authenticating,
|
||||||
|
/// Credentials exist, but the CLI could not renew them. Unlike a generic
|
||||||
|
/// provider failure, this has a direct action the phone can offer.
|
||||||
|
LoginRequired { detail: String },
|
||||||
/// The machine is logged in, but the usage endpoint did not answer.
|
/// The machine is logged in, but the usage endpoint did not answer.
|
||||||
Failed { detail: String },
|
Failed { detail: String },
|
||||||
}
|
}
|
||||||
@@ -95,10 +105,16 @@ pub struct UsageSnapshot {
|
|||||||
pub provider: String,
|
pub provider: String,
|
||||||
/// Which machine these are the numbers for. The point of the whole module:
|
/// Which machine these are the numbers for. The point of the whole module:
|
||||||
/// they belong to an account on a particular box.
|
/// they belong to an account on a particular box.
|
||||||
pub setup: String,
|
pub machine: String,
|
||||||
/// That machine's current label, resolved when the snapshot is built, so
|
/// That machine's current label, resolved when the snapshot is built, so
|
||||||
/// renaming a setup renames it here too.
|
/// renaming a machine renames it here too.
|
||||||
pub setup_name: String,
|
pub machine_name: String,
|
||||||
|
/// The provider's billing pool, when it exposes more than one. Codex uses
|
||||||
|
/// this to keep regular usage separate from its Luna reserve allowance.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit_name: Option<String>,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub state: UsageState,
|
pub state: UsageState,
|
||||||
pub windows: Vec<UsageWindow>,
|
pub windows: Vec<UsageWindow>,
|
||||||
@@ -112,14 +128,17 @@ pub struct UsageSnapshot {
|
|||||||
/// labels a row with, and [`crate::config::DriverKind::usage_provider`],
|
/// labels a row with, and [`crate::config::DriverKind::usage_provider`],
|
||||||
/// which is how a session says which of those rows is about it.
|
/// which is how a session says which of those rows is about it.
|
||||||
pub const CLAUDE: &str = "claude";
|
pub const CLAUDE: &str = "claude";
|
||||||
|
/// ChatGPT-backed Codex CLI subscription usage.
|
||||||
|
pub const CODEX: &str = "codex";
|
||||||
/// The invented one, for testing the screens that draw these -- see
|
/// The invented one, for testing the screens that draw these -- see
|
||||||
/// [`Fixture`].
|
/// [`Fixture`].
|
||||||
pub const ECHO: &str = "echo";
|
pub const ECHO: &str = "echo";
|
||||||
|
|
||||||
pub trait UsageProvider: Send + Sync {
|
pub trait UsageProvider: Send + Sync {
|
||||||
fn name(&self) -> &'static str;
|
fn name(&self) -> &'static str;
|
||||||
/// Blocking -- call off the async workers.
|
/// Blocking -- call off the async workers. A provider may expose more than
|
||||||
fn fetch(&self) -> UsageSnapshot;
|
/// one billing pool, so each answer is a separately labeled snapshot.
|
||||||
|
fn fetch(&self) -> Vec<UsageSnapshot>;
|
||||||
/// How long an answer from this one may be reused.
|
/// How long an answer from this one may be reused.
|
||||||
///
|
///
|
||||||
/// A property of the provider rather than of the cache, because what
|
/// A property of the provider rather than of the cache, because what
|
||||||
@@ -137,8 +156,8 @@ pub trait UsageProvider: Send + Sync {
|
|||||||
/// credentials that machine stores -- nothing to configure, and it reports on
|
/// credentials that machine stores -- nothing to configure, and it reports on
|
||||||
/// exactly the account whose CLI runs the sessions there.
|
/// exactly the account whose CLI runs the sessions there.
|
||||||
pub struct ClaudeUsage {
|
pub struct ClaudeUsage {
|
||||||
pub setup: String,
|
pub machine: String,
|
||||||
pub setup_name: String,
|
pub machine_name: String,
|
||||||
/// How to reach that machine. `Here` for the backend's own.
|
/// How to reach that machine. `Here` for the backend's own.
|
||||||
pub transport: Transport,
|
pub transport: Transport,
|
||||||
/// The CLI to run there, for the one thing this asks of it: refreshing its
|
/// The CLI to run there, for the one thing this asks of it: refreshing its
|
||||||
@@ -156,8 +175,10 @@ impl ClaudeUsage {
|
|||||||
fn snapshot(&self, state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
|
fn snapshot(&self, state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
|
||||||
UsageSnapshot {
|
UsageSnapshot {
|
||||||
provider: self.name().to_string(),
|
provider: self.name().to_string(),
|
||||||
setup: self.setup.clone(),
|
machine: self.machine.clone(),
|
||||||
setup_name: self.setup_name.clone(),
|
machine_name: self.machine_name.clone(),
|
||||||
|
limit_id: None,
|
||||||
|
limit_name: None,
|
||||||
state,
|
state,
|
||||||
windows,
|
windows,
|
||||||
fetched_at: crate::session::now(),
|
fetched_at: crate::session::now(),
|
||||||
@@ -197,25 +218,186 @@ impl UsageProvider for ClaudeUsage {
|
|||||||
CLAUDE
|
CLAUDE
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch(&self) -> UsageSnapshot {
|
fn fetch(&self) -> Vec<UsageSnapshot> {
|
||||||
let token = match self.access_token() {
|
let token = match self.access_token() {
|
||||||
Ok(token) => token,
|
Ok(token) => token,
|
||||||
Err(state) => return self.snapshot(state, Vec::new()),
|
Err(state) => return vec![self.snapshot(state, Vec::new())],
|
||||||
};
|
};
|
||||||
let body = match self.call(&token) {
|
let body = match self.call(&token) {
|
||||||
Ok(body) => body,
|
Ok(body) => body,
|
||||||
Err(Refused::Other(detail)) => {
|
Err(Refused::Other(detail)) => {
|
||||||
return self.snapshot(UsageState::Failed { detail }, Vec::new());
|
return vec![self.snapshot(UsageState::Failed { detail }, Vec::new())];
|
||||||
}
|
}
|
||||||
Err(Refused::Unauthorized) => match self.after_cli_refresh(&token) {
|
Err(Refused::Unauthorized) => match self.after_cli_refresh(&token) {
|
||||||
Ok(body) => body,
|
Ok(body) => body,
|
||||||
Err(state) => return self.snapshot(state, Vec::new()),
|
Err(state) => return vec![self.snapshot(state, Vec::new())],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
self.snapshot(UsageState::Ok, parse_windows(&body))
|
vec![self.snapshot(UsageState::Ok, parse_windows(&body))]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads the same snapshot as Codex's status display through the CLI's local
|
||||||
|
/// app-server protocol. The CLI owns authentication and token refresh; this
|
||||||
|
/// process never opens or copies its credentials.
|
||||||
|
pub struct CodexUsage {
|
||||||
|
pub machine: String,
|
||||||
|
pub machine_name: String,
|
||||||
|
pub transport: Transport,
|
||||||
|
pub program: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodexUsage {
|
||||||
|
fn snapshot(&self, state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
|
||||||
|
UsageSnapshot {
|
||||||
|
provider: CODEX.to_string(),
|
||||||
|
machine: self.machine.clone(),
|
||||||
|
machine_name: self.machine_name.clone(),
|
||||||
|
limit_id: None,
|
||||||
|
limit_name: None,
|
||||||
|
state,
|
||||||
|
windows,
|
||||||
|
fetched_at: crate::session::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsageProvider for CodexUsage {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
CODEX
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch(&self) -> Vec<UsageSnapshot> {
|
||||||
|
let launch = Launch::new(
|
||||||
|
&self.program,
|
||||||
|
vec!["app-server".to_string(), "--stdio".to_string()],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let initialized = serde_json::json!({
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {"clientInfo": {"name": "ai-app", "title": "AI Sessions", "version": env!("CARGO_PKG_VERSION")}}
|
||||||
|
});
|
||||||
|
let requests = [
|
||||||
|
serde_json::json!({"method": "initialized"}),
|
||||||
|
serde_json::json!({"id": 2, "method": "account/rateLimits/read"}),
|
||||||
|
];
|
||||||
|
let answer = match self
|
||||||
|
.transport
|
||||||
|
.request_json_blocking(&launch, &initialized, &requests, 2)
|
||||||
|
{
|
||||||
|
Ok(answer) => answer,
|
||||||
|
Err(err) => {
|
||||||
|
return vec![self.snapshot(
|
||||||
|
UsageState::Unreachable {
|
||||||
|
detail: format!("couldn't ask Codex on {}: {err:#}", self.machine_name),
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(error) = answer.pointer("/error/message").and_then(Value::as_str) {
|
||||||
|
let state = if error.to_ascii_lowercase().contains("login")
|
||||||
|
|| error.to_ascii_lowercase().contains("authentication")
|
||||||
|
{
|
||||||
|
UsageState::NotLoggedIn
|
||||||
|
} else {
|
||||||
|
UsageState::Failed {
|
||||||
|
detail: error.to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return vec![self.snapshot(state, Vec::new())];
|
||||||
|
}
|
||||||
|
let Some(limits) = answer.pointer("/result/rateLimits") else {
|
||||||
|
return vec![self.snapshot(
|
||||||
|
UsageState::Failed {
|
||||||
|
detail: "Codex returned no rate-limit snapshot".to_string(),
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
)];
|
||||||
|
};
|
||||||
|
let mut snapshots = vec![self.snapshot_for_limit("codex", None, limits)];
|
||||||
|
if let Some(pools) = answer
|
||||||
|
.pointer("/result/rateLimitsByLimitId")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
{
|
||||||
|
for (key, pool) in pools {
|
||||||
|
let limit_id = pool.get("limitId").and_then(Value::as_str).unwrap_or(key);
|
||||||
|
if limit_id == "codex" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let limit_name = pool.get("limitName").and_then(Value::as_str);
|
||||||
|
snapshots.push(self.snapshot_for_limit(limit_id, limit_name, pool));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapshots
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodexUsage {
|
||||||
|
fn snapshot_for_limit(
|
||||||
|
&self,
|
||||||
|
limit_id: &str,
|
||||||
|
limit_name: Option<&str>,
|
||||||
|
limits: &Value,
|
||||||
|
) -> UsageSnapshot {
|
||||||
|
let mut snapshot = self.snapshot(UsageState::Ok, parse_codex_windows(limits));
|
||||||
|
snapshot.limit_id = Some(limit_id.to_string());
|
||||||
|
snapshot.limit_name = limit_name.map(str::to_string);
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_codex_windows(limits: &Value) -> Vec<UsageWindow> {
|
||||||
|
[("primary", true), ("secondary", false)]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(kind, primary)| {
|
||||||
|
let window = limits.get(kind)?;
|
||||||
|
if window.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let minutes = window.get("windowDurationMins").and_then(Value::as_u64);
|
||||||
|
let label = match minutes {
|
||||||
|
Some(300) => "5-hour window".to_string(),
|
||||||
|
Some(10_080) => "Weekly".to_string(),
|
||||||
|
Some(minutes) if minutes % 1_440 == 0 => {
|
||||||
|
format!("{}-day window", minutes / 1_440)
|
||||||
|
}
|
||||||
|
Some(minutes) if minutes % 60 == 0 => {
|
||||||
|
format!("{}-hour window", minutes / 60)
|
||||||
|
}
|
||||||
|
Some(minutes) => format!("{minutes}-minute window"),
|
||||||
|
None if primary => "Primary window".to_string(),
|
||||||
|
None => "Secondary window".to_string(),
|
||||||
|
};
|
||||||
|
Some(UsageWindow {
|
||||||
|
// Keep the common semantic names where the duration establishes them. `primary`
|
||||||
|
// is only the protocol's position and can itself be a weekly window.
|
||||||
|
kind: match minutes {
|
||||||
|
Some(300) => "session",
|
||||||
|
Some(10_080) => "weekly_all",
|
||||||
|
_ if primary => "primary",
|
||||||
|
_ => "secondary",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
label,
|
||||||
|
percent: window.get("usedPercent")?.as_f64()?,
|
||||||
|
duration_minutes: minutes,
|
||||||
|
resets_at: window
|
||||||
|
.get("resetsAt")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|seconds| time::OffsetDateTime::from_unix_timestamp(seconds).ok())
|
||||||
|
.and_then(|at| {
|
||||||
|
at.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.ok()
|
||||||
|
}),
|
||||||
|
// Unlike Claude's response, Codex does not say which window is binding.
|
||||||
|
active: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Why one call to the usage endpoint did not produce numbers.
|
/// Why one call to the usage endpoint did not produce numbers.
|
||||||
///
|
///
|
||||||
/// 401 is apart from the rest because it is the only one with a way out: the
|
/// 401 is apart from the rest because it is the only one with a way out: the
|
||||||
@@ -271,7 +453,7 @@ impl ClaudeUsage {
|
|||||||
return Err(UsageState::Failed {
|
return Err(UsageState::Failed {
|
||||||
detail: format!(
|
detail: format!(
|
||||||
"the Claude login on {} has expired, and `{} doctor` couldn't be run there to refresh it: {err:#}",
|
"the Claude login on {} has expired, and `{} doctor` couldn't be run there to refresh it: {err:#}",
|
||||||
self.setup_name, self.program
|
self.machine_name, self.program
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -288,10 +470,10 @@ impl ClaudeUsage {
|
|||||||
/// A login the CLI could not renew: the one state here somebody has to act
|
/// A login the CLI could not renew: the one state here somebody has to act
|
||||||
/// on, so it says where and what to run.
|
/// on, so it says where and what to run.
|
||||||
fn still_expired(&self) -> UsageState {
|
fn still_expired(&self) -> UsageState {
|
||||||
UsageState::Failed {
|
UsageState::LoginRequired {
|
||||||
detail: format!(
|
detail: format!(
|
||||||
"the Claude login on {} has expired and could not be refreshed; run `{} /login` there",
|
"The Claude login on {} has expired and could not be refreshed.",
|
||||||
self.setup_name, self.program
|
self.machine_name
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +546,11 @@ fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
|||||||
kind: kind.to_string(),
|
kind: kind.to_string(),
|
||||||
label,
|
label,
|
||||||
percent,
|
percent,
|
||||||
|
duration_minutes: match kind {
|
||||||
|
"session" => Some(300),
|
||||||
|
"weekly_all" | "weekly_scoped" => Some(10_080),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
resets_at: limit
|
resets_at: limit
|
||||||
.get("resets_at")
|
.get("resets_at")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -498,6 +685,7 @@ fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec<UsageWindow> {
|
|||||||
kind: "session".to_string(),
|
kind: "session".to_string(),
|
||||||
label: "5-hour window".to_string(),
|
label: "5-hour window".to_string(),
|
||||||
percent,
|
percent,
|
||||||
|
duration_minutes: Some(300),
|
||||||
resets_at: resets_at.clone(),
|
resets_at: resets_at.clone(),
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
@@ -505,6 +693,7 @@ fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec<UsageWindow> {
|
|||||||
kind: "weekly_all".to_string(),
|
kind: "weekly_all".to_string(),
|
||||||
label: "Weekly (all models)".to_string(),
|
label: "Weekly (all models)".to_string(),
|
||||||
percent: percent / 2.0,
|
percent: percent / 2.0,
|
||||||
|
duration_minutes: Some(10_080),
|
||||||
resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)),
|
resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)),
|
||||||
active: false,
|
active: false,
|
||||||
},
|
},
|
||||||
@@ -512,6 +701,7 @@ fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec<UsageWindow> {
|
|||||||
kind: "weekly_scoped".to_string(),
|
kind: "weekly_scoped".to_string(),
|
||||||
label: "Weekly (Echo)".to_string(),
|
label: "Weekly (Echo)".to_string(),
|
||||||
percent: percent / 4.0,
|
percent: percent / 4.0,
|
||||||
|
duration_minutes: Some(10_080),
|
||||||
resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)),
|
resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)),
|
||||||
active: false,
|
active: false,
|
||||||
},
|
},
|
||||||
@@ -544,6 +734,8 @@ fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
|
|||||||
},
|
},
|
||||||
UsageState::NotLoggedIn => "nobody is logged in on this machine".to_string(),
|
UsageState::NotLoggedIn => "nobody is logged in on this machine".to_string(),
|
||||||
UsageState::Unreachable { detail } => format!("machine unreachable ({detail})"),
|
UsageState::Unreachable { detail } => format!("machine unreachable ({detail})"),
|
||||||
|
UsageState::Authenticating => "sign-in is in progress".to_string(),
|
||||||
|
UsageState::LoginRequired { detail } => format!("sign-in required ({detail})"),
|
||||||
UsageState::Failed { detail } => format!("the meter failed ({detail})"),
|
UsageState::Failed { detail } => format!("the meter failed ({detail})"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -551,8 +743,8 @@ fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
|
|||||||
/// The fixture, as a provider, so it travels the same route and the same
|
/// The fixture, as a provider, so it travels the same route and the same
|
||||||
/// cache as a real meter rather than being spliced in at the screen.
|
/// cache as a real meter rather than being spliced in at the screen.
|
||||||
struct EchoUsage {
|
struct EchoUsage {
|
||||||
setup: String,
|
machine: String,
|
||||||
setup_name: String,
|
machine_name: String,
|
||||||
fixture: Fixture,
|
fixture: Fixture,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +753,7 @@ impl UsageProvider for EchoUsage {
|
|||||||
ECHO
|
ECHO
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch(&self) -> UsageSnapshot {
|
fn fetch(&self) -> Vec<UsageSnapshot> {
|
||||||
let (state, windows) = self
|
let (state, windows) = self
|
||||||
.fixture
|
.fixture
|
||||||
.read()
|
.read()
|
||||||
@@ -574,14 +766,16 @@ impl UsageProvider for EchoUsage {
|
|||||||
},
|
},
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
));
|
));
|
||||||
UsageSnapshot {
|
vec![UsageSnapshot {
|
||||||
provider: self.name().to_string(),
|
provider: self.name().to_string(),
|
||||||
setup: self.setup.clone(),
|
machine: self.machine.clone(),
|
||||||
setup_name: self.setup_name.clone(),
|
machine_name: self.machine_name.clone(),
|
||||||
|
limit_id: None,
|
||||||
|
limit_name: None,
|
||||||
state,
|
state,
|
||||||
windows,
|
windows,
|
||||||
fetched_at: crate::session::now(),
|
fetched_at: crate::session::now(),
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read from memory, and set by somebody who is about to look at the
|
/// Read from memory, and set by somebody who is about to look at the
|
||||||
@@ -593,7 +787,7 @@ impl UsageProvider for EchoUsage {
|
|||||||
|
|
||||||
/// Which paid services a machine can be asked about.
|
/// Which paid services a machine can be asked about.
|
||||||
///
|
///
|
||||||
/// Derived from what the setup says it can run, so a machine with no Claude
|
/// Derived from what the machine says it can run, so a machine with no Claude
|
||||||
/// provider is not asked about Claude limits -- it has none, and a row saying
|
/// provider is not asked about Claude limits -- it has none, and a row saying
|
||||||
/// so would be a fact about nothing.
|
/// so would be a fact about nothing.
|
||||||
///
|
///
|
||||||
@@ -602,9 +796,9 @@ impl UsageProvider for EchoUsage {
|
|||||||
/// one of these rows by that same name: two lists that disagreed would leave a
|
/// one of these rows by that same name: two lists that disagreed would leave a
|
||||||
/// session looking for a snapshot nothing produces. A second service later is a
|
/// session looking for a snapshot nothing produces. A second service later is a
|
||||||
/// name there and an impl beside [`ClaudeUsage`], not a screen.
|
/// name there and an impl beside [`ClaudeUsage`], not a screen.
|
||||||
fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsageProvider>> {
|
fn providers_for(machine: &MachineConfig, fixture: &Fixture) -> Vec<Box<dyn UsageProvider>> {
|
||||||
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
|
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
|
||||||
for provider in &setup.providers {
|
for provider in &machine.providers {
|
||||||
let Some(name) = provider.kind.usage_provider() else {
|
let Some(name) = provider.kind.usage_provider() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -616,17 +810,23 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
|
|||||||
}
|
}
|
||||||
match name {
|
match name {
|
||||||
CLAUDE => found.push(Box::new(ClaudeUsage {
|
CLAUDE => found.push(Box::new(ClaudeUsage {
|
||||||
setup: setup.id.clone(),
|
machine: machine.id.clone(),
|
||||||
setup_name: setup.name.clone(),
|
machine_name: machine.name.clone(),
|
||||||
transport: Transport::for_setup(setup),
|
transport: Transport::for_machine(machine),
|
||||||
|
program: provider.program().to_string(),
|
||||||
|
})),
|
||||||
|
CODEX => found.push(Box::new(CodexUsage {
|
||||||
|
machine: machine.id.clone(),
|
||||||
|
machine_name: machine.name.clone(),
|
||||||
|
transport: Transport::for_machine(machine),
|
||||||
program: provider.program().to_string(),
|
program: provider.program().to_string(),
|
||||||
})),
|
})),
|
||||||
// Nothing at all until a test has asked for something: an
|
// Nothing at all until a test has asked for something: an
|
||||||
// echo session costs nothing, so the honest answer is no row
|
// echo session costs nothing, so the honest answer is no row
|
||||||
// rather than a row saying zero.
|
// rather than a row saying zero.
|
||||||
ECHO if fixture.is_set() => found.push(Box::new(EchoUsage {
|
ECHO if fixture.is_set() => found.push(Box::new(EchoUsage {
|
||||||
setup: setup.id.clone(),
|
machine: machine.id.clone(),
|
||||||
setup_name: setup.name.clone(),
|
machine_name: machine.name.clone(),
|
||||||
fixture: fixture.clone(),
|
fixture: fixture.clone(),
|
||||||
})),
|
})),
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -638,16 +838,22 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
|
|||||||
/// One machine's numbers for one service, and when they were fetched.
|
/// One machine's numbers for one service, and when they were fetched.
|
||||||
///
|
///
|
||||||
/// Keyed by the machine and the service rather than by position: the set is not
|
/// Keyed by the machine and the service rather than by position: the set is not
|
||||||
/// fixed at startup -- setups are added, renamed and removed from the phone --
|
/// fixed at startup -- machines are added, renamed and removed from the phone --
|
||||||
/// and a positional cache would hand one machine's numbers to another the
|
/// and a positional cache would hand one machine's numbers to another the
|
||||||
/// moment the list shifted.
|
/// moment the list shifted.
|
||||||
type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>;
|
type Cached = HashMap<(String, &'static str), (Instant, Vec<UsageSnapshot>)>;
|
||||||
|
type ProviderGate = Arc<Mutex<()>>;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
/// The cache in front of whatever machines exist: at most one real fetch per
|
/// The cache in front of whatever machines exist: at most one real fetch per
|
||||||
/// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks.
|
/// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks.
|
||||||
pub struct UsageMonitor {
|
pub struct UsageMonitor {
|
||||||
cache: Mutex<Cached>,
|
cache: Mutex<Cached>,
|
||||||
|
/// One gate per machine and metered provider. The cache lock cannot cover
|
||||||
|
/// a network call without making an unreachable machine stall every other
|
||||||
|
/// one, but releasing it used to let concurrent `/usage` requests launch
|
||||||
|
/// two token refreshers against the same rotating credential.
|
||||||
|
gates: Mutex<HashMap<(String, &'static str), ProviderGate>>,
|
||||||
/// The invented meter an echo session can put up; empty unless one
|
/// The invented meter an echo session can put up; empty unless one
|
||||||
/// has. Shared with the session layer, which is where the command
|
/// has. Shared with the session layer, which is where the command
|
||||||
/// that sets it is typed -- see [`Fixture`].
|
/// that sets it is typed -- see [`Fixture`].
|
||||||
@@ -658,50 +864,134 @@ impl UsageMonitor {
|
|||||||
pub fn new(fixture: Fixture) -> Self {
|
pub fn new(fixture: Fixture) -> Self {
|
||||||
Self {
|
Self {
|
||||||
cache: Mutex::new(Cached::new()),
|
cache: Mutex::new(Cached::new()),
|
||||||
|
gates: Mutex::new(HashMap::new()),
|
||||||
fixture,
|
fixture,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One snapshot per machine that offers a paid service, in the order the
|
fn gate_for(&self, key: &(String, &'static str)) -> ProviderGate {
|
||||||
/// machines are configured.
|
self.gates
|
||||||
///
|
.lock()
|
||||||
/// Blocking -- call via `spawn_blocking`. Takes the setups rather than
|
.unwrap()
|
||||||
/// holding the manager, so this module stays below the session layer rather
|
.entry(key.clone())
|
||||||
/// than reaching up into it.
|
.or_default()
|
||||||
pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> {
|
.clone()
|
||||||
let mut fresh = Vec::new();
|
|
||||||
for setup in setups {
|
|
||||||
for provider in providers_for(setup, &self.fixture) {
|
|
||||||
let key = (setup.id.clone(), provider.name());
|
|
||||||
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
|
|
||||||
&& fetched.elapsed() < provider.poll_interval()
|
|
||||||
{
|
|
||||||
// Cached numbers, but the machine's *name* is read fresh: a
|
|
||||||
// rename should show immediately rather than waiting out a
|
|
||||||
// poll interval it has nothing to do with.
|
|
||||||
let mut snapshot = snapshot.clone();
|
|
||||||
snapshot.setup_name = setup.name.clone();
|
|
||||||
fresh.push(snapshot);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
// Fetched without the lock held: this makes a network call per
|
|
||||||
// machine, and holding the cache across them would serialise
|
fn cached(
|
||||||
// every phone asking for the screen behind the slowest ssh.
|
&self,
|
||||||
let snapshot = provider.fetch();
|
key: &(String, &'static str),
|
||||||
|
machine_name: &str,
|
||||||
|
max_age: Option<Duration>,
|
||||||
|
) -> Option<Vec<UsageSnapshot>> {
|
||||||
|
let cache = self.cache.lock().unwrap();
|
||||||
|
let (fetched, snapshots) = cache.get(key)?;
|
||||||
|
if max_age.is_some_and(|age| fetched.elapsed() >= age) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut snapshots = snapshots.clone();
|
||||||
|
for snapshot in &mut snapshots {
|
||||||
|
snapshot.machine_name = machine_name.to_string();
|
||||||
|
}
|
||||||
|
Some(snapshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prevents automatic refresh from overlapping an explicit login. The
|
||||||
|
/// fresh cache entry closes the small gap before the login worker acquires
|
||||||
|
/// the same gate, while an attempt lasting beyond the normal cache window
|
||||||
|
/// is protected by the worker holding it.
|
||||||
|
pub(crate) fn claude_authentication_started(&self, machine: &MachineConfig) -> ProviderGate {
|
||||||
|
let key = (machine.id.clone(), CLAUDE);
|
||||||
|
let gate = self.gate_for(&key);
|
||||||
|
let guard = gate.lock().unwrap();
|
||||||
|
self.cache.lock().unwrap().insert(
|
||||||
|
key,
|
||||||
|
(
|
||||||
|
Instant::now(),
|
||||||
|
vec![UsageSnapshot {
|
||||||
|
provider: CLAUDE.to_string(),
|
||||||
|
machine: machine.id.clone(),
|
||||||
|
machine_name: machine.name.clone(),
|
||||||
|
limit_id: None,
|
||||||
|
limit_name: None,
|
||||||
|
state: UsageState::Authenticating,
|
||||||
|
windows: Vec::new(),
|
||||||
|
fetched_at: crate::session::now(),
|
||||||
|
}],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
drop(guard);
|
||||||
|
gate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes the first read after a login ask the provider instead of keeping
|
||||||
|
/// the pre-login state for the ordinary cache interval.
|
||||||
|
pub(crate) fn claude_authentication_finished(&self, machine: &str) {
|
||||||
self.cache
|
self.cache
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.insert(key, (Instant::now(), snapshot.clone()));
|
.remove(&(machine.to_string(), CLAUDE));
|
||||||
fresh.push(snapshot);
|
}
|
||||||
|
|
||||||
|
/// One or more snapshots per machine that offers a paid service, in the
|
||||||
|
/// order the machines are configured and the provider reports them.
|
||||||
|
///
|
||||||
|
/// Blocking -- call via `spawn_blocking`. Takes the machines rather than
|
||||||
|
/// holding the manager, so this module stays below the session layer rather
|
||||||
|
/// than reaching up into it.
|
||||||
|
pub fn snapshots(&self, machines: &[MachineConfig]) -> Vec<UsageSnapshot> {
|
||||||
|
let mut fresh = Vec::new();
|
||||||
|
for machine in machines {
|
||||||
|
for provider in providers_for(machine, &self.fixture) {
|
||||||
|
let key = (machine.id.clone(), provider.name());
|
||||||
|
if let Some(snapshots) =
|
||||||
|
self.cached(&key, &machine.name, Some(provider.poll_interval()))
|
||||||
|
{
|
||||||
|
fresh.extend(snapshots);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gate = self.gate_for(&key);
|
||||||
|
let _guard = match gate.try_lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(std::sync::TryLockError::WouldBlock) => {
|
||||||
|
// A refresh already in flight can keep serving the last
|
||||||
|
// measured answer. An explicit login seeds its own
|
||||||
|
// authenticating answer before taking the gate.
|
||||||
|
if let Some(snapshots) = self.cached(&key, &machine.name, None) {
|
||||||
|
fresh.extend(snapshots);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The first-ever fetch has no honest stale answer. Wait
|
||||||
|
// for its one producer, then recheck below.
|
||||||
|
gate.lock().unwrap()
|
||||||
|
}
|
||||||
|
Err(std::sync::TryLockError::Poisoned(err)) => err.into_inner(),
|
||||||
|
};
|
||||||
|
if let Some(snapshots) =
|
||||||
|
self.cached(&key, &machine.name, Some(provider.poll_interval()))
|
||||||
|
{
|
||||||
|
fresh.extend(snapshots);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let snapshots = provider.fetch();
|
||||||
|
self.cache
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(key, (Instant::now(), snapshots.clone()));
|
||||||
|
fresh.extend(snapshots);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Machines that have gone away should not keep their numbers alive.
|
// Machines that have gone away should not keep their numbers alive.
|
||||||
let live: std::collections::HashSet<&str> =
|
let live: std::collections::HashSet<&str> =
|
||||||
setups.iter().map(|setup| setup.id.as_str()).collect();
|
machines.iter().map(|machine| machine.id.as_str()).collect();
|
||||||
self.cache
|
self.cache
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.retain(|(setup, _), _| live.contains(setup.as_str()));
|
.retain(|(machine, _), _| live.contains(machine.as_str()));
|
||||||
|
self.gates.lock().unwrap().retain(|(machine, _), gate| {
|
||||||
|
live.contains(machine.as_str()) || Arc::strong_count(gate) > 1
|
||||||
|
});
|
||||||
fresh
|
fresh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -711,6 +1001,31 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::DriverKind;
|
use crate::config::DriverKind;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refresh_gates_are_shared_per_machine_and_provider_only() {
|
||||||
|
let monitor = UsageMonitor::new(Fixture::default());
|
||||||
|
let claude_here = monitor.gate_for(&("here".to_string(), CLAUDE));
|
||||||
|
let same = monitor.gate_for(&("here".to_string(), CLAUDE));
|
||||||
|
let claude_there = monitor.gate_for(&("there".to_string(), CLAUDE));
|
||||||
|
let codex_here = monitor.gate_for(&("here".to_string(), CODEX));
|
||||||
|
|
||||||
|
let held = claude_here.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(same.try_lock(), Err(std::sync::TryLockError::WouldBlock)),
|
||||||
|
"the same machine/provider pair must have one credential writer"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
claude_there.try_lock().is_ok(),
|
||||||
|
"another machine must not wait"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
codex_here.try_lock().is_ok(),
|
||||||
|
"another provider must not wait"
|
||||||
|
);
|
||||||
|
drop(held);
|
||||||
|
assert!(same.try_lock().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_refusal_the_endpoint_answered_is_not_reported_as_an_unreachable_one() {
|
fn a_refusal_the_endpoint_answered_is_not_reported_as_an_unreachable_one() {
|
||||||
assert!(why(&ureq::Error::StatusCode(500)).contains("HTTP 500"));
|
assert!(why(&ureq::Error::StatusCode(500)).contains("HTTP 500"));
|
||||||
@@ -720,9 +1035,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn an_expired_login_says_where_to_log_in_rather_than_naming_the_network() {
|
fn an_expired_login_says_where_to_log_in_rather_than_naming_the_network() {
|
||||||
let provider = ClaudeUsage {
|
let provider = ClaudeUsage {
|
||||||
setup: "far".to_string(),
|
machine: "far".to_string(),
|
||||||
setup_name: "somewhere else".to_string(),
|
machine_name: "somewhere else".to_string(),
|
||||||
transport: Transport::for_setup(&unreachable_setup()),
|
transport: Transport::for_machine(&unreachable_machine()),
|
||||||
program: "/opt/claude".to_string(),
|
program: "/opt/claude".to_string(),
|
||||||
};
|
};
|
||||||
// The machine cannot be reached, so the refresh attempt fails there
|
// The machine cannot be reached, so the refresh attempt fails there
|
||||||
@@ -741,10 +1056,10 @@ mod tests {
|
|||||||
"the token must never be quoted back"
|
"the token must never be quoted back"
|
||||||
);
|
);
|
||||||
|
|
||||||
let UsageState::Failed { detail } = provider.still_expired() else {
|
let UsageState::LoginRequired { detail } = provider.still_expired() else {
|
||||||
panic!("still expired is a fault");
|
panic!("still expired must offer a new login");
|
||||||
};
|
};
|
||||||
assert!(detail.contains("/opt/claude /login"), "{detail}");
|
assert!(detail.contains("somewhere else"), "{detail}");
|
||||||
assert!(!detail.contains("unreachable"), "{detail}");
|
assert!(!detail.contains("unreachable"), "{detail}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,6 +1078,7 @@ mod tests {
|
|||||||
.expect("json");
|
.expect("json");
|
||||||
let windows = parse_windows(&body);
|
let windows = parse_windows(&body);
|
||||||
assert_eq!(windows.len(), 4);
|
assert_eq!(windows.len(), 4);
|
||||||
|
assert_eq!(windows[0].kind, "session");
|
||||||
assert_eq!(windows[0].label, "5-hour window");
|
assert_eq!(windows[0].label, "5-hour window");
|
||||||
assert_eq!(windows[0].percent, 70.0);
|
assert_eq!(windows[0].percent, 70.0);
|
||||||
assert!(windows[0].active);
|
assert!(windows[0].active);
|
||||||
@@ -773,10 +1089,10 @@ mod tests {
|
|||||||
assert_eq!(windows[3].resets_at, None);
|
assert_eq!(windows[3].resets_at, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A setup naming a machine that cannot be dialled, so nothing here touches
|
/// A machine naming a machine that cannot be dialled, so nothing here touches
|
||||||
/// the network beyond ssh failing to resolve it.
|
/// the network beyond ssh failing to resolve it.
|
||||||
fn unreachable_setup() -> SetupConfig {
|
fn unreachable_machine() -> MachineConfig {
|
||||||
SetupConfig {
|
MachineConfig {
|
||||||
id: "far".to_string(),
|
id: "far".to_string(),
|
||||||
name: "somewhere else".to_string(),
|
name: "somewhere else".to_string(),
|
||||||
ssh: Some(crate::config::SshConfig {
|
ssh: Some(crate::config::SshConfig {
|
||||||
@@ -792,6 +1108,9 @@ mod tests {
|
|||||||
kind: DriverKind::ClaudeCli,
|
kind: DriverKind::ClaudeCli,
|
||||||
command: None,
|
command: None,
|
||||||
models: vec![],
|
models: vec![],
|
||||||
|
mcp_servers: Vec::new(),
|
||||||
|
model_settings: Default::default(),
|
||||||
|
max_loaded: None,
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -799,12 +1118,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_machine_that_cannot_be_asked_says_so_rather_than_looking_logged_out() {
|
fn a_machine_that_cannot_be_asked_says_so_rather_than_looking_logged_out() {
|
||||||
let provider = ClaudeUsage {
|
let provider = ClaudeUsage {
|
||||||
setup: "far".to_string(),
|
machine: "far".to_string(),
|
||||||
setup_name: "somewhere else".to_string(),
|
machine_name: "somewhere else".to_string(),
|
||||||
transport: Transport::for_setup(&unreachable_setup()),
|
transport: Transport::for_machine(&unreachable_machine()),
|
||||||
program: "claude".to_string(),
|
program: "claude".to_string(),
|
||||||
};
|
};
|
||||||
let snapshot = provider.fetch();
|
let snapshot = provider.fetch().remove(0);
|
||||||
// The distinction the old single `error` string could not make: this
|
// The distinction the old single `error` string could not make: this
|
||||||
// machine was never reached, which is not the same as a machine that
|
// machine was never reached, which is not the same as a machine that
|
||||||
// answered and has nobody logged in.
|
// answered and has nobody logged in.
|
||||||
@@ -813,8 +1132,8 @@ mod tests {
|
|||||||
"{:?}",
|
"{:?}",
|
||||||
snapshot.state
|
snapshot.state
|
||||||
);
|
);
|
||||||
assert_eq!(snapshot.setup, "far");
|
assert_eq!(snapshot.machine, "far");
|
||||||
assert_eq!(snapshot.setup_name, "somewhere else");
|
assert_eq!(snapshot.machine_name, "somewhere else");
|
||||||
assert!(snapshot.windows.is_empty());
|
assert!(snapshot.windows.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,12 +1168,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_machines_that_can_run_claude_are_asked_about_it() {
|
fn only_machines_that_can_run_claude_are_asked_about_it() {
|
||||||
let mut echo_only = unreachable_setup();
|
let mut echo_only = unreachable_machine();
|
||||||
echo_only.providers = vec![crate::config::ProviderConfig {
|
echo_only.providers = vec![crate::config::ProviderConfig {
|
||||||
name: "echo".to_string(),
|
name: "echo".to_string(),
|
||||||
kind: DriverKind::Echo,
|
kind: DriverKind::Echo,
|
||||||
command: None,
|
command: None,
|
||||||
models: vec![],
|
models: vec![],
|
||||||
|
mcp_servers: Vec::new(),
|
||||||
|
model_settings: Default::default(),
|
||||||
|
max_loaded: None,
|
||||||
}];
|
}];
|
||||||
// A machine with no Claude on it has no Claude limits, and a row
|
// A machine with no Claude on it has no Claude limits, and a row
|
||||||
// reporting on it would be a fact about nothing. Echo included:
|
// reporting on it would be a fact about nothing. Echo included:
|
||||||
@@ -862,7 +1184,7 @@ mod tests {
|
|||||||
// otherwise there is no meter to report.
|
// otherwise there is no meter to report.
|
||||||
let unset = Fixture::new();
|
let unset = Fixture::new();
|
||||||
assert!(providers_for(&echo_only, &unset).is_empty());
|
assert!(providers_for(&echo_only, &unset).is_empty());
|
||||||
assert_eq!(providers_for(&unreachable_setup(), &unset).len(), 1);
|
assert_eq!(providers_for(&unreachable_machine(), &unset).len(), 1);
|
||||||
|
|
||||||
// And with one set, that machine has exactly the invented meter
|
// And with one set, that machine has exactly the invented meter
|
||||||
// -- under the name the session's `usageProvider` will name.
|
// -- under the name the session's `usageProvider` will name.
|
||||||
@@ -873,6 +1195,7 @@ mod tests {
|
|||||||
assert_eq!(found[0].name(), ECHO);
|
assert_eq!(found[0].name(), ECHO);
|
||||||
assert_eq!(DriverKind::Echo.usage_provider(), Some(ECHO));
|
assert_eq!(DriverKind::Echo.usage_provider(), Some(ECHO));
|
||||||
assert_eq!(DriverKind::ClaudeCli.usage_provider(), Some(CLAUDE));
|
assert_eq!(DriverKind::ClaudeCli.usage_provider(), Some(CLAUDE));
|
||||||
|
assert_eq!(DriverKind::CodexCli.usage_provider(), Some(CODEX));
|
||||||
// A local model costs nothing to run, so it meters nothing.
|
// A local model costs nothing to run, so it meters nothing.
|
||||||
assert_eq!(DriverKind::LlamaCpp.usage_provider(), None);
|
assert_eq!(DriverKind::LlamaCpp.usage_provider(), None);
|
||||||
}
|
}
|
||||||
@@ -920,4 +1243,55 @@ mod tests {
|
|||||||
assert!(parse_windows(&serde_json::json!({})).is_empty());
|
assert!(parse_windows(&serde_json::json!({})).is_empty());
|
||||||
assert!(parse_windows(&serde_json::json!({"limits": "what"})).is_empty());
|
assert!(parse_windows(&serde_json::json!({"limits": "what"})).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_codex_primary_and_secondary_windows() {
|
||||||
|
let limits = serde_json::json!({
|
||||||
|
"primary": {"usedPercent": 10, "windowDurationMins": 300, "resetsAt": 1788853003_i64},
|
||||||
|
"secondary": {"usedPercent": 2, "windowDurationMins": 10080, "resetsAt": 1789439803_i64}
|
||||||
|
});
|
||||||
|
let windows = parse_codex_windows(&limits);
|
||||||
|
assert_eq!(windows.len(), 2);
|
||||||
|
assert_eq!(windows[0].label, "5-hour window");
|
||||||
|
assert_eq!(windows[0].percent, 10.0);
|
||||||
|
assert_eq!(windows[0].duration_minutes, Some(300));
|
||||||
|
assert!(!windows[0].active);
|
||||||
|
assert_eq!(windows[1].kind, "weekly_all");
|
||||||
|
assert_eq!(windows[1].label, "Weekly");
|
||||||
|
assert_eq!(windows[1].duration_minutes, Some(10_080));
|
||||||
|
assert!(
|
||||||
|
windows[1]
|
||||||
|
.resets_at
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|at| at.ends_with('Z'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_codex_reserve_as_a_named_pool() {
|
||||||
|
let provider = CodexUsage {
|
||||||
|
machine: "local".to_string(),
|
||||||
|
machine_name: "this machine".to_string(),
|
||||||
|
transport: Transport::Here,
|
||||||
|
program: "codex".to_string(),
|
||||||
|
};
|
||||||
|
let snapshot = provider.snapshot_for_limit(
|
||||||
|
"base_model_inference",
|
||||||
|
Some("gpt-reserve"),
|
||||||
|
&serde_json::json!({
|
||||||
|
"primary": {
|
||||||
|
"usedPercent": 12,
|
||||||
|
"windowDurationMins": 10080,
|
||||||
|
"resetsAt": 1789446343_i64
|
||||||
|
},
|
||||||
|
"secondary": null
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.limit_id.as_deref(), Some("base_model_inference"));
|
||||||
|
assert_eq!(snapshot.limit_name.as_deref(), Some("gpt-reserve"));
|
||||||
|
assert_eq!(snapshot.windows.len(), 1);
|
||||||
|
assert_eq!(snapshot.windows[0].kind, "weekly_all");
|
||||||
|
assert_eq!(snapshot.windows[0].label, "Weekly");
|
||||||
|
assert_eq!(snapshot.windows[0].duration_minutes, Some(10_080));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user