Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a92857079 | ||
|
|
da6b003a1e | ||
|
|
8adad924b9 | ||
|
|
0b60713806 | ||
|
|
5b34d406c7 | ||
|
|
29dd84e04e | ||
|
|
5bf0491ee1 | ||
|
|
adce0fcdbe | ||
|
|
0ace0e017c | ||
|
|
734c521f5d | ||
|
|
718923c217 | ||
|
|
03c8933612 | ||
|
|
362191c3f3 | ||
|
|
52a5c2f40c | ||
|
|
55b51d9442 | ||
|
|
1442443e78 | ||
|
|
d12cbcd499 | ||
|
|
424f655d60 | ||
|
|
721a8fc50d | ||
|
|
61a2f601bb | ||
|
|
265ce6f829 | ||
|
|
56437dc90f | ||
|
|
1f9eea879f | ||
|
|
93a6830fcd | ||
|
|
90400f50ac | ||
|
|
f3c9ab8106 | ||
|
|
67584cedda | ||
|
|
3ecd676bcf | ||
|
|
e4fada298d | ||
|
|
1ef31bc9e7 | ||
|
|
2fa734cd63 | ||
|
|
2a3072b90a | ||
|
|
5798877829 | ||
|
|
150245071f | ||
|
|
36ce66554b | ||
|
|
da1807664a | ||
|
|
e0b4308de4 | ||
|
|
aa1a85b316 | ||
|
|
f33981e1e6 | ||
|
|
1c52bc4e19 | ||
|
|
9f988a8315 | ||
|
|
82c0ac4960 | ||
|
|
ef324aa88b | ||
|
|
39978ff8ea | ||
|
|
3eda6f00c8 | ||
|
|
280fad7472 | ||
|
|
bdddb610c0 | ||
|
|
b2484b900e | ||
|
|
4b976e240e | ||
|
|
152bed7ec4 | ||
|
|
1777a92205 | ||
|
|
398e4efaeb | ||
|
|
5c35efd498 | ||
|
|
dc45f7bb3e | ||
|
|
2bee0b4b53 | ||
|
|
cb4557f1bc | ||
|
|
12b7364998 | ||
|
|
384b6a1150 | ||
|
|
ccc42f34f8 | ||
|
|
32151f7f9f | ||
|
|
035c7f20e8 | ||
|
|
264dc4f0c2 | ||
|
|
8f1a5e0a46 | ||
|
|
0080bcbcba | ||
|
|
3084491b9b | ||
|
|
8c16f9d0fd | ||
|
|
0cdda1713f | ||
|
|
be895375ec | ||
|
|
faa4e98311 | ||
|
|
f64d6a8d4d | ||
|
|
4763e1a70d | ||
|
|
c0b0921973 | ||
|
|
4e75019b9a | ||
|
|
9a26862bce | ||
|
|
82646e8408 | ||
|
|
9f825ed6fb | ||
|
|
da2f93254e | ||
|
|
b0c13b85a9 | ||
|
|
82c006cd59 | ||
|
|
a271795408 | ||
|
|
06f9ae7799 | ||
|
|
bb5b79f2f6 | ||
|
|
10e46522b4 | ||
|
|
663a95f4a2 | ||
|
|
9ed90505b3 | ||
|
|
388a6a060d | ||
|
|
f83be016ba | ||
|
|
6081726314 | ||
|
|
2d860587a4 | ||
|
|
617331f913 | ||
|
|
ddf746d8a4 | ||
|
|
1a73aba1cd | ||
|
|
76334cd5aa | ||
|
|
085fc97334 | ||
|
|
cf7b33da39 | ||
|
|
8db0969d9d | ||
|
|
7b8b43a7d2 | ||
|
|
3a5d96a8ae | ||
|
|
af751599f6 | ||
|
|
df55e181d9 | ||
|
|
a128648bd2 | ||
|
|
324f0f02ef | ||
|
|
e6b9cb29b7 | ||
|
|
89d20c31dc | ||
|
|
155b7650a9 | ||
|
|
39409b6683 | ||
|
|
98dcb31c23 | ||
|
|
1ffe6ea067 | ||
|
|
9bfda08f85 | ||
|
|
f486f00ec9 | ||
|
|
cb2942bf58 | ||
|
|
2a40aa01db | ||
|
|
ba97265191 | ||
|
|
d0c80a2037 | ||
|
|
592cc808ca | ||
|
|
60bf2568f8 | ||
|
|
d50e791130 | ||
|
|
2acfcf51aa | ||
|
|
ecce4448f0 | ||
|
|
974d6c7a52 | ||
|
|
986f98380d | ||
|
|
ccc75d24fe | ||
|
|
cb1cfff0bf | ||
|
|
38f6f9ac07 | ||
|
|
ba275fc966 | ||
|
|
d5486b124a | ||
|
|
e442481a30 | ||
|
|
19b9ca7413 | ||
|
|
5959d58e44 | ||
|
|
c683e62e4f | ||
|
|
e90b026969 | ||
|
|
836c222f0c | ||
|
|
9f039d6440 | ||
|
|
d373a1b05c | ||
|
|
270fe66802 | ||
|
|
0aaa1f92db | ||
|
|
80527ff843 | ||
|
|
8e6d709ff3 | ||
|
|
0f24d3ac23 | ||
|
|
e04f873453 | ||
|
|
9c85ea4864 | ||
|
|
f3ba05d446 | ||
|
|
d0606f46ec | ||
|
|
c2b1c07895 | ||
|
|
8d9680547a | ||
|
|
69b40d555e | ||
|
|
ba678e97f3 | ||
|
|
c5a10ac845 | ||
|
|
a009261fae | ||
|
|
64d8042f52 | ||
|
|
912602d52b | ||
|
|
2937a48133 | ||
|
|
8beb461d83 | ||
|
|
43f6bda92d | ||
|
|
8daf1bcac1 | ||
|
|
26f86bc6f7 | ||
|
|
bc555f9ded | ||
|
|
a9d63babe4 | ||
|
|
e774b465fd | ||
|
|
50d214e5b1 | ||
|
|
715c86e7e2 | ||
|
|
badd758d9b | ||
|
|
45cdfed293 | ||
|
|
77b39a97e3 | ||
|
|
b1416d86bf | ||
|
|
9fe6aca1f1 | ||
|
|
9097744a7c | ||
|
|
adb8276ef4 | ||
|
|
93c106e2f8 | ||
|
|
1f0a7b5f94 | ||
|
|
cb5f11d21a | ||
|
|
90dffce514 | ||
|
|
c97df72015 | ||
|
|
a8093b002b | ||
|
|
4cbe7baea0 | ||
|
|
bdc9c914c4 | ||
|
|
f7e7950908 | ||
|
|
779f5c63d3 | ||
|
|
0ead062379 | ||
|
|
aa33d55a70 | ||
|
|
6efd0cf0aa | ||
|
|
ecf74055c7 | ||
|
|
8218e84b62 | ||
|
|
df1290904b | ||
|
|
e137f38a5d | ||
|
|
213a0debb7 | ||
|
|
d8bb1699a8 | ||
|
|
a8602c1626 | ||
|
|
f49b284b46 | ||
|
|
599d33287c | ||
|
|
b53a17c436 | ||
|
|
de92fccba5 | ||
|
|
25370731d0 | ||
|
|
5428cd75c9 | ||
|
|
8c6e2ed9cf | ||
|
|
4bc23172fd | ||
|
|
e5fee03da8 | ||
|
|
5ece49b8d9 | ||
|
|
e212ed8d02 | ||
|
|
f5183af306 | ||
|
|
fffed42f9e | ||
|
|
0aa03cf621 | ||
|
|
ae0af8f5e3 | ||
|
|
18c5f9aaac | ||
|
|
a428cba41a | ||
|
|
3c7d3db370 | ||
|
|
77cee6a8fa | ||
|
|
43a3a345e4 | ||
|
|
9bf714fa2e | ||
|
|
42d54eec95 | ||
|
|
4ccfda6b8e | ||
|
|
09778346a0 | ||
|
|
6d5a231f5c | ||
|
|
e9a6562dc6 | ||
|
|
afbc2ad132 | ||
|
|
1318e149f5 | ||
|
|
4fdabc39d0 | ||
|
|
bf8658c404 | ||
|
|
00e0a63887 | ||
|
|
b7474f61b0 | ||
|
|
8e5928cc6a | ||
|
|
76fcbdccb9 | ||
|
|
a00376994e | ||
|
|
ba57086361 | ||
|
|
1a9655414e | ||
|
|
5e34dba2fd | ||
|
|
cc8148cbec | ||
|
|
a2e5e5881c | ||
|
|
c121bc0725 | ||
|
|
fe7dc9c728 | ||
|
|
02b277e7ad | ||
|
|
fc82d9d7e8 | ||
|
|
9e301f30c6 | ||
|
|
341b7a5922 | ||
|
|
c8785b6091 | ||
|
|
9c560e3492 | ||
|
|
e5a90c6135 | ||
|
|
af1b0c5ab2 | ||
|
|
cce4b28324 | ||
|
|
94d8373289 | ||
|
|
8310431497 | ||
|
|
b863f9f3df | ||
|
|
cdeb7b0857 | ||
|
|
476609e1d3 | ||
|
|
2756087e1c | ||
|
|
af7d5f3782 | ||
|
|
db0a41a7cd | ||
|
|
b9924e7617 | ||
|
|
f014e8d9cf | ||
|
|
0ccc444246 | ||
|
|
c6da735134 | ||
|
|
4f6ec3a900 | ||
|
|
38bf6309cb | ||
|
|
3eb0e033d5 | ||
|
|
b8ea723718 | ||
|
|
7485d78d50 | ||
|
|
b87f5a597e | ||
|
|
80a75c128e | ||
|
|
50e69995b6 | ||
|
|
85869d02f8 | ||
|
|
f99ae4c366 | ||
|
|
203f53470c | ||
|
|
b38e797db3 | ||
|
|
92985ba8e3 | ||
|
|
181ba64606 | ||
|
|
a6a100edc6 | ||
|
|
ff1d6ea932 | ||
|
|
2b20bb2c91 | ||
|
|
3c80d9d696 | ||
|
|
06b8a1f4b0 | ||
|
|
e10582a2cd | ||
|
|
551c01398f | ||
|
|
7e79ec11e0 | ||
|
|
2ec0fee84c | ||
|
|
992c472975 | ||
|
|
729098756d | ||
|
|
d8562d96a3 | ||
|
|
22210a42f5 | ||
|
|
ade572973a | ||
|
|
9b27e858b5 | ||
|
|
7e4e26a335 | ||
|
|
84a13e806b | ||
|
|
452c44249f | ||
|
|
238057ad5e | ||
|
|
896c93a59a | ||
|
|
690161e5e9 | ||
|
|
e922b73d7a | ||
|
|
d507ae4c96 | ||
|
|
9ed01e2812 | ||
|
|
5be9f1baac | ||
|
|
977bdb9ee0 | ||
|
|
9cd1263080 | ||
|
|
42af780639 | ||
|
|
4274b8b8d0 | ||
|
|
73f956f8e0 | ||
|
|
038f6a3832 | ||
|
|
1121d7cc83 | ||
|
|
232de0ec53 | ||
|
|
e430880cde | ||
|
|
a999bd106a | ||
|
|
6840edf61e | ||
|
|
333220196e | ||
|
|
7f4ea7e8fd | ||
|
|
591128eef1 | ||
|
|
ba0f2ea93f | ||
|
|
ed04d4c735 | ||
|
|
ba2afbaedb | ||
|
|
10267dec27 | ||
|
|
7e7cbb5402 | ||
|
|
a200ddbddd | ||
|
|
b332873894 | ||
|
|
a4809b3026 | ||
|
|
1ad2f9ec6e | ||
|
|
33e8ab83a2 | ||
|
|
f5b88932b4 | ||
|
|
9079276ec8 | ||
|
|
3cb18ac5c2 | ||
|
|
69525bd131 | ||
|
|
64f64b54e5 | ||
|
|
20303e0b4c | ||
|
|
6973a89815 | ||
|
|
c3cfc67bb3 | ||
|
|
155d899e55 | ||
|
|
e63e923d44 | ||
|
|
a56a928b0c | ||
|
|
0449a324ef | ||
|
|
e1030d69f6 | ||
|
|
167862ca1b | ||
|
|
d73db97629 | ||
|
|
fb6b459c2c | ||
|
|
76b1f99277 | ||
|
|
3e72a4ef19 | ||
|
|
c02152a4f4 | ||
|
|
d9872989fa | ||
|
|
2fed8b34b3 | ||
|
|
1f379e8384 | ||
|
|
bf3479f5c4 | ||
|
|
312455956d | ||
|
|
73251d6b8b | ||
|
|
2e00e71552 | ||
|
|
f802de94b5 | ||
|
|
9717d1c4b0 | ||
|
|
9458f443ad | ||
|
|
e12c708246 | ||
|
|
543f6d92f0 | ||
|
|
27ca5b2349 | ||
|
|
20b12255e1 | ||
|
|
71a3fae655 | ||
|
|
c3984da623 | ||
|
|
2e3f4ada38 | ||
|
|
03c6be80a3 | ||
|
|
4afc453faa | ||
|
|
1aab61bf26 | ||
|
|
dc01f88d75 | ||
|
|
c589a75fa0 | ||
|
|
4b62cc642e | ||
|
|
80c2eadec9 | ||
|
|
0b587629e6 | ||
|
|
3163256d2c | ||
|
|
6102e0d4d9 | ||
|
|
f0da383e28 | ||
|
|
2d3695a1d3 | ||
|
|
f06ee259b4 | ||
|
|
560a74caf8 | ||
|
|
fd7e17523d | ||
|
|
c7682297fa | ||
|
|
27511302f2 | ||
|
|
184a6c5b33 | ||
|
|
5b2ca039f1 | ||
|
|
a8d24553d5 | ||
|
|
3b80a88f3b | ||
|
|
d8e6bc6e9b | ||
|
|
b887a96765 | ||
|
|
2aaa3733c3 | ||
|
|
46246ea511 | ||
|
|
a27fbdb029 | ||
|
|
c07d544aeb | ||
|
|
46d3a6fd41 | ||
|
|
5655fa8093 | ||
|
|
b3b1d47dd6 | ||
|
|
50fe4828a2 | ||
|
|
800da46188 | ||
|
|
00767eed4d | ||
|
|
683db4908a | ||
|
|
8d23a20792 | ||
|
|
d01c105037 | ||
|
|
88631f5e8b | ||
|
|
e6924298bc | ||
|
|
68b48cfd14 | ||
|
|
e6c884a0cd | ||
|
|
a6cb9a9082 | ||
|
|
0be6a571c4 | ||
|
|
bfe93c4188 | ||
|
|
5b7dc0e4e2 | ||
|
|
621f08d725 | ||
|
|
e49d0e606f | ||
|
|
e2a1fadbec | ||
|
|
0e4629361b | ||
|
|
1e7b1cddb7 | ||
|
|
470f8e5019 | ||
|
|
7ae53ad797 | ||
|
|
d17040b601 | ||
|
|
bf5087a598 | ||
|
|
aa3d11471f | ||
|
|
78aff64844 | ||
|
|
9a33cb5384 | ||
|
|
45ced405f3 | ||
|
|
62199aa3a7 | ||
|
|
b133d85943 | ||
|
|
ba6817fee5 | ||
|
|
73ee63bc1b | ||
|
|
8f0aec449a | ||
|
|
6d5fd64bb0 | ||
|
|
e5880c33f4 | ||
|
|
a853eb5a4d | ||
|
|
22d5c6585a | ||
|
|
b063fbd7f9 | ||
|
|
3f25e7ebca | ||
|
|
0af4c88d08 | ||
|
|
ceabd00805 | ||
|
|
32a5256a0d | ||
|
|
4cfe0ef6e6 | ||
|
|
c9b273ff16 | ||
|
|
8adda94a7a | ||
|
|
3a9208f38b | ||
|
|
e898370bf4 | ||
|
|
6e0bd06e4d | ||
|
|
03da47e550 | ||
|
|
a2cd119985 | ||
|
|
19c36e37f2 | ||
|
|
e2873df92e | ||
|
|
ea13889a21 | ||
|
|
6317685d1a | ||
|
|
f79bd7ca71 | ||
|
|
9c935f8ce8 | ||
|
|
982449293d | ||
|
|
288853c094 | ||
|
|
fba572427d | ||
|
|
85ec5416b6 | ||
|
|
643daf5637 | ||
|
|
8db0184384 | ||
|
|
1a6599e1b2 | ||
|
|
0a2f4fa1fe | ||
|
|
237886c11e | ||
|
|
e8dbcaa7db | ||
|
|
26163b25b2 | ||
|
|
762c1290a1 | ||
|
|
62dd6b7912 | ||
|
|
bc3db183e3 | ||
|
|
e0a473e090 | ||
|
|
1c937e2f48 | ||
|
|
d194d73439 | ||
|
|
4400966928 | ||
|
|
6e49ce8c92 | ||
|
|
79b9cd789a | ||
|
|
c70a670356 | ||
|
|
43743ba171 | ||
|
|
1a97d0ef5c | ||
|
|
ff7e9c0435 | ||
|
|
68a7f41ed0 | ||
|
|
9b331a5e93 | ||
|
|
3fc224b584 | ||
|
|
10500ae8aa | ||
|
|
8d441d3d59 | ||
|
|
e0ee7d6e94 | ||
|
|
b6b0928087 | ||
|
|
12221ea025 | ||
|
|
5e23c8b0c0 | ||
|
|
caaa733caa | ||
|
|
4ab26f068e | ||
|
|
0f8ba49f4a |
No files matched your search
@@ -1 +0,0 @@
|
|||||||
../../.claude/skills/ai-app-rigs
|
|
||||||
@@ -72,19 +72,13 @@ 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 script that ordinarily runs
|
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
|
||||||
`cat > /dev/null` and it behaves the way the lifecycle code cares about:
|
`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*. The sandbox's
|
`debug-transcript.sh` when it is *what the transcript draws*.
|
||||||
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
|
||||||
@@ -147,55 +141,28 @@ 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** and need nothing typed. The prebuilt llama.cpp lives
|
**Both are set up here as of 2026-09-04** and need nothing typed. The
|
||||||
outside the repo at `~/.local/opt/llama.cpp-vk` — a **Vulkan** build as of
|
prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp`
|
||||||
2026-09-19, replacing the CPU one that was there before — and is symlinked as
|
(the 15 MB `ubuntu-x64` release asset) and is symlinked as
|
||||||
both `~/.local/bin/llama-server` and `/usr/local/bin/llama-server`. The second
|
`/usr/local/bin/llama-server`, which is what makes **discovery find it over
|
||||||
is what makes **discovery find it over ssh**: `~/.local/bin` is not on the
|
ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets.
|
||||||
PATH a non-interactive ssh session gets. It resolves its own libraries through
|
It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is
|
||||||
`$ORIGIN`, so no `LD_LIBRARY_PATH` is needed.
|
needed. One model is downloaded — `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`,
|
||||||
|
639 MB under `~/.local/share/ai-app/models` — and answers at usable speed on
|
||||||
Two models are downloaded under `~/.local/share/ai-app/models`:
|
this VM's 8 cores. **Do not test with a 2-bit quant**: the
|
||||||
|
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
|
||||||
- `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`, 639 MB, loads in ~4s. It
|
broken driver — `llama-cli` produces the same from the file directly, which
|
||||||
calls tools correctly and is the right rig for the driver's shape. Do not
|
is how to tell the two apart in a hurry.
|
||||||
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
|
||||||
machine called **"this vm over ssh"** — `bob@127.0.0.1` with that
|
setup 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 machine of your
|
this one and the model file is the same file. For a throwaway setup of your
|
||||||
own, point a provider's `command` at something harmless like `/bin/echo`
|
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
|
||||||
@@ -233,7 +200,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`, currently Claude Code or Codex). Off by default,
|
(`keepsOwnTranscript`, which today means Claude Code). 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
|
||||||
@@ -243,57 +210,6 @@ 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
|
||||||
|
|||||||
+2
-11
@@ -17,9 +17,7 @@ label: "AI Sessions",
|
|||||||
// three constants -- there is nothing here worth spawning a process for.
|
// three constants -- there is nothing here worth spawning a process for.
|
||||||
resources: Ron("resources.ron"),
|
resources: Ron("resources.ron"),
|
||||||
|
|
||||||
// The two halves this checkout produces: the server a phone talks to, and
|
// The server and Rust app build in parallel.
|
||||||
// the app that talks to it. They are built in parallel -- this list is the
|
|
||||||
// set, not a sequence, so nothing here should be read as an order.
|
|
||||||
components: [
|
components: [
|
||||||
Server(
|
Server(
|
||||||
name: "server",
|
name: "server",
|
||||||
@@ -39,15 +37,8 @@ components: [
|
|||||||
),
|
),
|
||||||
Apk(
|
Apk(
|
||||||
name: "app",
|
name: "app",
|
||||||
// Release first: the first mode is the default, and the phone runs
|
|
||||||
// the release build -- a debuggable one runs Compose at a fraction
|
|
||||||
// of the speed. Each command below is run with the chosen mode as
|
|
||||||
// its last argument, which is exactly build-apk.sh's interface.
|
|
||||||
modes: ["release", "debug"],
|
modes: ["release", "debug"],
|
||||||
// Resolved against this directory, and run in `app/` -- the script
|
build: "./build-apk.sh",
|
||||||
// cds to its own directory anyway, so the cwd is here to say where
|
|
||||||
// the app is rather than because the build needs it.
|
|
||||||
build: "app/build-apk.sh",
|
|
||||||
cwd: "app",
|
cwd: "app",
|
||||||
// The Enroll button in this component's settings: prints the link
|
// The Enroll button in this component's settings: prints the link
|
||||||
// that enrols the phone against the server built here, for the
|
// that enrols the phone against the server built here, for the
|
||||||
|
|||||||
+7
-8
@@ -1,12 +1,6 @@
|
|||||||
.gradle/
|
|
||||||
build/
|
|
||||||
app/androidApp/build/
|
|
||||||
local.properties
|
|
||||||
.kotlin/
|
|
||||||
*.iml
|
|
||||||
.idea/
|
|
||||||
.DS_Store
|
|
||||||
server/target/
|
server/target/
|
||||||
|
event-model/target/
|
||||||
|
app/target/
|
||||||
|
|
||||||
# Server logs from a development run (ai-server.log by convention,
|
# Server logs from a development run (ai-server.log by convention,
|
||||||
# wg-test.log from ./test-wg-tunnel.sh).
|
# wg-test.log from ./test-wg-tunnel.sh).
|
||||||
@@ -21,3 +15,8 @@ certs/
|
|||||||
config.ron
|
config.ron
|
||||||
config.json
|
config.json
|
||||||
sessions/
|
sessions/
|
||||||
|
|
||||||
|
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
||||||
|
iris/target/
|
||||||
|
|
||||||
|
scripts/rigs/gpu-probe/target/
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
[submodule "wg-app-link"]
|
[submodule "wg-app-link"]
|
||||||
path = wg-app-link
|
path = wg-app-link
|
||||||
url = git@git.arirex.me:iris/wg-app-link.git
|
url = git@git.arirex.me:iris/wg-app-link.git
|
||||||
|
[submodule "iris"]
|
||||||
|
path = iris
|
||||||
|
url = git@git.arirex.me:iris-ai/iris.git
|
||||||
|
branch = app-pin
|
||||||
@@ -1,595 +1,183 @@
|
|||||||
# ai-app
|
# ai-app
|
||||||
|
|
||||||
A phone interface to AI coding sessions (Codex, Claude Code and llama.cpp),
|
A phone and desktop interface to AI coding sessions. The backend is Rust/Axum;
|
||||||
replacing the Claude app for daily use. Rust/Axum backend on the desktop,
|
the shared client and UI are Rust, drawn by the `iris` framework pinned as a
|
||||||
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
submodule. The
|
||||||
between them.
|
Android app uses a thin Java activity and `android-view`; desktop uses winit.
|
||||||
|
|
||||||
**`PLAN.md` is the design source of truth** — every decision with its date,
|
`docs/PLAN.md` is the design source of truth. Read it before structural work
|
||||||
its rationale, and what was rejected. Read it before changing anything
|
and update it when a decision changes. `docs/HANDOFF.md` is where the work in
|
||||||
structural, and update it in place when a decision changes rather than
|
flight stands; read it first in a fresh session and keep it current. Working
|
||||||
letting this file and the plan become two versions of the truth. This file is
|
documents are pruned as work lands: preserve current invariants, measurements,
|
||||||
the working notes layer: layout, commands, and things that have bitten.
|
and failed hypotheses, not a chronicle of completed tasks. Do not create a
|
||||||
|
decisions log.
|
||||||
|
|
||||||
**The rigs are the `ai-app-rigs` skill** — the sandbox and bench scripts, the
|
## Architecture
|
||||||
rule that no UI-driving script may tap a coordinate, how to test llama.cpp and
|
|
||||||
ssh here, how importing behaves, and the measurements not worth re-taking.
|
|
||||||
They moved there on 2026-09-04 because they are 12 KB that only matter once
|
|
||||||
you are actually running one, and this file is sent with every request. Read
|
|
||||||
it before writing or running a benchmark, driving the UI from a script, or
|
|
||||||
touching the import screen.
|
|
||||||
|
|
||||||
The central design point, worth not undoing by accident: **a session is a
|
A session is a child process translated by a driver into one common event
|
||||||
child process, translated into one common event model.** A new session type
|
model. A new session type is a new driver, never a session-type branch in
|
||||||
is a new driver — never a session-type branch in shared code (routes,
|
shared routes, transcripts, or screens.
|
||||||
transcript, app screens).
|
|
||||||
|
Android and desktop share `app/src/client` and `app/src/ui`. Platform modules
|
||||||
|
own only what the platform forces: JNI, lifecycle, insets and IME on one side;
|
||||||
|
winit and argv on the other. Layouts may differ, but widgets, styling, folding,
|
||||||
|
paging, config, and network logic are shared.
|
||||||
|
|
||||||
|
`iris/` is a UI framework and nothing else. It must not know about sessions,
|
||||||
|
transcripts, setups, or servers. Product code belongs in `app/`, and the
|
||||||
|
dependency runs one way.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
- `server/` — `ai-server`. `routes.rs`'s module comment is the HTTP table.
|
||||||
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
|
- `event-model/` — the wire contract shared by server and app.
|
||||||
`:androidApp` module), same cert scheme, same registry pattern. Read
|
- `app/` — the `ai-app` crate. `client` is platform/UI independent; `ui`
|
||||||
dev-updater's `README.md` and `AGENTS.md` before diverging from them.
|
contains Iris widget trees; `android` and `desktop` are thin hosts.
|
||||||
Module-by-module intent is in PLAN.md's "Backend layout".
|
`android-project/` packages the Rust cdylib. The `bench` feature and
|
||||||
|
`bench-fixture/` are retained performance rigs, not a second app.
|
||||||
|
- `iris/` — the pinned framework submodule: proc macro, demos, and input rig.
|
||||||
|
- `scripts/` — repository-wide scripts and independent profiling rigs.
|
||||||
|
- `wg-app-link/` — a git submodule shared with dev-updater.
|
||||||
|
- `docs/` — design and working documents.
|
||||||
|
|
||||||
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
Clone with `--recurse-submodules` or run `git submodule update --init` to
|
||||||
comment is the HTTP table and the surface's source of truth.
|
populate both `iris/` and `wg-app-link/`.
|
||||||
**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
|
|
||||||
port the server binds *there* and the port that reaches it *here*, and
|
|
||||||
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
|
|
||||||
the command. Three things fell out of it and are easy to get wrong again —
|
|
||||||
a forwarded launch gets a pty (`-tt`) and every other one keeps `-T`,
|
|
||||||
because `llama-server` never reads the stdin whose closing ends a CLI and
|
|
||||||
the same kill left it loaded on the far machine; the model is looked for on
|
|
||||||
the machine that will serve it, so the spawn screen offers
|
|
||||||
`GET /machines/{id}/providers/{p}/models` rather than any list of this
|
|
||||||
backend's own; and the
|
|
||||||
readiness poll watches the process as well as the port, since a model that
|
|
||||||
will not load exits in a second and was being reported as "gave up after
|
|
||||||
300s". See PLAN.md's "Transport" and "llama-server management".
|
|
||||||
**A llama session has tools and runs the loop itself** (2026-09-19):
|
|
||||||
`--tools all` gives the router `llama-server`'s built-in set, which it also
|
|
||||||
*runs* (`GET /tools` for the definitions, `POST /tools` to call one), while
|
|
||||||
web search comes from an MCP server this backend connects to directly
|
|
||||||
(`session/llama/mcp.rs`, Exa preset in a discovered provider's
|
|
||||||
`mcpServers`). Driving the loop is what makes the permission gate ours:
|
|
||||||
`manual` asks before every call and remembers a tool you answer
|
|
||||||
"Always allow …" to, `bypassPermissions` never asks, and the allowances are
|
|
||||||
folded back out of the transcript. Which tools a *session* offers is a
|
|
||||||
filter applied to those definitions here, not a flag over there: one shared
|
|
||||||
server has one set, and the filter costs no reload (2,181 tokens of prompt
|
|
||||||
with all seven, 698 with none). Three more things fall out of it and are
|
|
||||||
easy to get wrong again — a model change **asks for another model** and
|
|
||||||
stops nothing, since the one being left may be another session's;
|
|
||||||
`parallel = 1` unless that model's settings say otherwise, and it is what
|
|
||||||
decides whether the MTP draft head is a 50% speed-up or a 33% loss; and
|
|
||||||
`spec-type = draft-mtp` is conditional on the file actually having a head,
|
|
||||||
because asking for one that is not there makes `llama-server` **exit**.
|
|
||||||
**A llama session'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".
|
|
||||||
`AppRoot.kt` is the navigation `when`; `SidePanels.kt` the one drag that
|
|
||||||
slides the whole main screen over a session from the left (`MainPanel.kt`)
|
|
||||||
and what it has running beside the turn -- its background tasks over its
|
|
||||||
subagents (`BackgroundTasks.kt`, `SubagentPanel.kt`) -- from the right, both keeping
|
|
||||||
the session composed underneath; `MainScreen.kt` the root's three tabs
|
|
||||||
(sessions, import, machines); `Reorder.kt` the drag that moves a row of a
|
|
||||||
lazy list, used by the session list's handles -- **the order of that list is
|
|
||||||
the reader's own and nothing sorts it** (`POST /sessions/order`);
|
|
||||||
`MachineModels.kt` the models on one machine
|
|
||||||
and the downloads putting them there, drawn inside `ProviderScreen.kt` for a
|
|
||||||
provider that serves files off that machine's disk; `Api.kt`/`EventStream.kt`
|
|
||||||
the REST + SSE clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
|
||||||
the Keystore-sealed token.
|
|
||||||
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
|
||||||
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
|
||||||
binding and the certificate's SANs (`netif`), owner-only files (`private`),
|
|
||||||
and the RON house rules (`format`). Clone with `--recurse-submodules`, or
|
|
||||||
`git submodule update --init` in an existing checkout — `server/` will not
|
|
||||||
build without it, since it is a path dependency, which is what keeps the two
|
|
||||||
projects version-locked to the commit this repo pins. What deliberately did
|
|
||||||
**not** move is the API surface and the config *schema*: routes, drivers,
|
|
||||||
sessions and machines are what makes this project itself.
|
|
||||||
- `SUBAGENTS.md` — a session's subagents as transcripts of their own
|
|
||||||
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
|
|
||||||
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
|
|
||||||
choices made there that are still awaiting review.
|
|
||||||
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
|
|
||||||
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
|
||||||
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
|
|
||||||
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening
|
|
||||||
and stream effects in `SessionScreen.kt`.
|
|
||||||
- `TODO.md` — the working list.
|
|
||||||
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
|
||||||
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
|
||||||
rather than a script kept here) and the APK, in parallel. It points at
|
|
||||||
`resources.ron`, which is *ours* rather than Dev Updater's — it names
|
|
||||||
`~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can
|
|
||||||
offer them. Note what deleting the config directory takes with it: the CA
|
|
||||||
under `certs`, which is the one-way door. **Stop** on the server card stops
|
|
||||||
the server a phone reaches through the tunnel, so on that phone it stays
|
|
||||||
down until somebody starts it again; Dev Updater reaches it over its own
|
|
||||||
port and is unaffected, which is what makes the button safe to press and
|
|
||||||
easy to regret.
|
|
||||||
|
|
||||||
### Icons
|
Nerd Font icons are an app-owned committed subset. `app/build-icon-font.sh`
|
||||||
|
produces `app/assets/fonts/nerd_icons.ttf`; its codepoints must match
|
||||||
|
`app/src/ui/icon.rs`. The app registers it with Iris at startup. Body and
|
||||||
|
monospace fonts come from the platform; Iris ships no font assets.
|
||||||
|
|
||||||
**Nerd Fonts glyphs from a committed subset**, not vector assets and not
|
## Checking work
|
||||||
ordinary Unicode. `NerdIcons.kt` declares each codepoint and
|
|
||||||
`app/build-icon-font.sh` subsets the font; the two lists have to agree,
|
|
||||||
because a codepoint in the Kotlin that the script did not subset is a glyph
|
|
||||||
that silently isn't there. Rerun the script and commit its output when adding
|
|
||||||
one — it needs network access. `md-cog` and `md-refresh` are deliberately the
|
|
||||||
same codepoints dev-updater uses and must not drift from it. The subset is
|
|
||||||
the **Mono** face, where every glyph is one em square, which is what makes
|
|
||||||
two icon buttons the same width without either being given one — and why
|
|
||||||
`GLYPH_SIZE` is smaller than it looks like it should be.
|
|
||||||
|
|
||||||
## Checking your work
|
Commit each coherent, warning-clean slice and push it.
|
||||||
|
|
||||||
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from
|
- Whole product: `./scripts/run-tests.sh`.
|
||||||
`server/`), plus `cargo clippy --all-targets` and `cargo fmt`. The build
|
- Framework: `cd iris && cargo fmt --all --check && cargo clippy --all-targets
|
||||||
stays warning-clean and rustfmt-clean at the defaults — there is no
|
-- -D warnings && cargo test`.
|
||||||
`rustfmt.toml` and there should not be one.
|
- App: `cd app && cargo fmt --all --check && cargo clippy --all-targets --
|
||||||
- **App**: from `app/`,
|
-D warnings && cargo test`.
|
||||||
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
- Android: `cd app && ./build-apk.sh debug --abi x86_64` for this machine's
|
||||||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
emulator, or `./build-apk.sh release` for a phone. The script builds with
|
||||||
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
|
cargo-ndk, packages with Gradle, and verifies the APK. Never infer phone
|
||||||
syntax highlighter, the ANSI parser and the transcript cache — the app's
|
frame times from a debug emulator build.
|
||||||
pure logic with no Android in it.
|
|
||||||
- **Android Lint is not optional and is not run by a build.** It found a
|
|
||||||
crash that had been shipping (`java.time` on a minSdk-24 app with
|
|
||||||
desugaring off) and later a permission check that silently dropped every
|
|
||||||
notification on Android 12 and below. Fully clean as of 2026-08-31; keep it
|
|
||||||
that way, and suppress with `tools:ignore` plus a written reason rather
|
|
||||||
than by lowering the bar.
|
|
||||||
- Then `./build-apk.sh` for the APK to install on a phone through Dev
|
|
||||||
Updater, or `./run-android.sh` to build, install and launch on the
|
|
||||||
emulator. **The phone gets the release build**, signed with a key the
|
|
||||||
script generates once under `~/.config/ai-app/release.jks` (never in the
|
|
||||||
repo); `./build-apk.sh debug` builds the other variant, and Dev Updater's
|
|
||||||
build modes call the script with exactly that word. Dev Updater lists every
|
|
||||||
variant under `build/outputs/apk`, so pick `release` there; a phone still
|
|
||||||
holding the debug build has to uninstall it first, since the two are signed
|
|
||||||
differently.
|
|
||||||
- The emulator scripts stay on the debug build. **Never read a frame time
|
|
||||||
from one as the app's** — a debuggable build runs Compose at a fraction of
|
|
||||||
release speed; the render report says which build it came from.
|
|
||||||
|
|
||||||
## Running it here
|
`app/`, `iris/`, and `scripts/rigs/ui-profile/` use rolling nightly through
|
||||||
|
per-directory toolchain files. `server/` and `event-model/` use stable.
|
||||||
|
|
||||||
- Run the server for development with `--bind 127.0.0.1`. Without it the
|
The release signing key lives at `~/.config/ai-app/release.jks`, never in the
|
||||||
server binds wg0, which exists here but is unreachable from the emulator
|
checkout. `build-apk.sh` creates it once. Normal builds use application id
|
||||||
(it dials 10.0.2.2). First run prints the enrollment QR/URI with the token.
|
`com.example.aiapp`; benchmark builds add `.bench` and are built explicitly:
|
||||||
`ai-server --enroll-link` mints one more device's link while the server
|
|
||||||
keeps running; the server adopts that token on its first use. It is what
|
|
||||||
Dev Updater's Enroll button runs.
|
|
||||||
- Point development at a scratch state directory rather than the real one:
|
|
||||||
`--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`.
|
|
||||||
- **The APK pins the CA of the machine that builds it**, read at build time
|
|
||||||
from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides). So the
|
|
||||||
server must have started once on that machine first — the build stops with
|
|
||||||
that instruction otherwise — and an APK built in this VM only works against
|
|
||||||
a server in this VM.
|
|
||||||
- Prefer exercising the server directly over going through the UI:
|
|
||||||
`curl --cacert ~/.config/ai-app/certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions`.
|
|
||||||
The CA is wherever `--certs` put it — by default under `$XDG_CONFIG_HOME`,
|
|
||||||
never in the checkout, so a relative `certs/ca.pem` finds nothing.
|
|
||||||
The emulator app reaches it at `https://10.0.2.2:8443`; enroll with
|
|
||||||
`adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"`.
|
|
||||||
- **`ai-server --delay MS` holds every response back.** Over the tunnel a
|
|
||||||
phone's requests take tens to hundreds of milliseconds, and several faults
|
|
||||||
live entirely in what the app does *while* one is outstanding. On a
|
|
||||||
loopback server those windows close before anything can be observed, so the
|
|
||||||
bug looks like it is not there.
|
|
||||||
- **`RUST_LOG=ai_server=debug`** logs every transcript page with its `before`,
|
|
||||||
`after` and what came back, and logs each SSE subscriber's cursor and
|
|
||||||
whether it was continued or reset (`stream backlog:`). That is the only
|
|
||||||
place "how far had this phone fallen behind" is answerable — the app sees a
|
|
||||||
window arrive and cannot tell.
|
|
||||||
- **`./test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
|
|
||||||
network namespaces inside one machine and drives the server through it — a
|
|
||||||
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
|
||||||
involved. That is how to verify the wg0-only posture.
|
|
||||||
|
|
||||||
## Where things run (host vs this VM)
|
./build-apk.sh release --features "screens bench"
|
||||||
|
|
||||||
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
## Running the server
|
||||||
VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that
|
|
||||||
means here:
|
|
||||||
|
|
||||||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
Use `--bind 127.0.0.1` for emulator development. Without it the server binds
|
||||||
address the phone can reach is, and where WireGuard terminates.
|
wg0, which the emulator cannot reach. Use scratch state:
|
||||||
`wg-machine-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
|
||||||
there with `sudo WG_ENDPOINT=<ddns name>`.
|
|
||||||
- **The tunnel and the real phone can never terminate in the VM**, because
|
|
||||||
nothing outside can open a connection into it. Phone bring-up is host work.
|
|
||||||
- `wg0` (10.66.0.1) exists in this VM too, so the production path is
|
|
||||||
exercisable during development. It has no reachable peer and does not need
|
|
||||||
one — but with no `--bind` the emulator cannot reach the server.
|
|
||||||
- **The `claude` CLI is only in the VM, so from the host it is a remote.**
|
|
||||||
The backend reaches it as it would any other machine.
|
|
||||||
- Starting the server in the VM makes a separate throwaway dev CA. **Never
|
|
||||||
install a build pinning that on the real phone.**
|
|
||||||
|
|
||||||
## Sessions outlive the backend
|
ai-server --bind 127.0.0.1 --config /tmp/ai-config.ron \
|
||||||
|
--data-dir /tmp/ai-sessions --port 8444
|
||||||
|
|
||||||
Since 2026-08-29 a session's process is deliberately left running when
|
The emulator reaches the host at `10.0.2.2`. `ai-server --enroll-link` mints
|
||||||
`ai-server` stops, and adopted again when it starts. PLAN.md has the design;
|
another device link while the server runs. `--delay MS` is important for UI
|
||||||
day to day:
|
states that disappear too quickly on loopback. `RUST_LOG=ai_server=debug`
|
||||||
|
logs transcript page bounds and SSE catch-up/reset decisions.
|
||||||
|
|
||||||
- **Stopping the server no longer stops the sessions.** After `pkill
|
Exercise the server directly when possible:
|
||||||
ai-server` the `claude` processes are still there, on purpose
|
|
||||||
(`reattaching to the claude-cli it left running` in the log). To end one,
|
|
||||||
`POST /sessions/{id}/stop` — which keeps the session and its transcript,
|
|
||||||
and `/start` brings the process back on the same conversation — or delete
|
|
||||||
the session, which ends the conversation too.
|
|
||||||
- **A message or a command sent to a stopped session starts it**, so the
|
|
||||||
Start button is for when you want a process and nothing to say to it yet.
|
|
||||||
- **A backend start adopts and starts nothing.** If you are looking for a
|
|
||||||
stopped session's process after a restart, there is deliberately none.
|
|
||||||
- **A session spawned while testing cleans itself up**: `--throwaway-sessions`,
|
|
||||||
which a debug build defaults to on. Pass `--throwaway-sessions=false` to
|
|
||||||
keep what a development server spawns. The flag decides only what **new**
|
|
||||||
sessions are marked as; what happens on the way out is decided by the
|
|
||||||
**mark**.
|
|
||||||
- **A llama.cpp router is not cleaned up by any of that**, throwaway sessions
|
|
||||||
included: it belongs to the machine rather than to a session, and a
|
|
||||||
development server that has loaded a model leaves it loaded — gigabytes of
|
|
||||||
VRAM — after `pkill ai-server`. Stop it from the machines tab's provider
|
|
||||||
view, or `pkill -f "[l]lama-server"` when testing.
|
|
||||||
- Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and
|
|
||||||
`stderr.log`. `stdout.log` is the driver's input, read from the byte offset
|
|
||||||
in `process.json`; removing either by hand while the session is live loses
|
|
||||||
output or replays it.
|
|
||||||
|
|
||||||
## Auto-resume
|
curl --cacert ~/.config/ai-app/certs/ca.pem \
|
||||||
|
-H "Authorization: Bearer …" https://127.0.0.1:8443/sessions
|
||||||
|
|
||||||
**A session switched to it sends itself a message once the account's usage
|
`./scripts/test-wg-tunnel.sh up|test|down` builds a real WireGuard tunnel
|
||||||
limit lifts** — off by default, per session, in the session settings dialog.
|
between network namespaces and verifies pinned TLS against 10.66.0.1.
|
||||||
PLAN.md's "Auto-resume" is the design; day to day:
|
|
||||||
|
|
||||||
- **The schedule is a plan to ask.** `resume.rs` wakes at the scheduled time,
|
## Rigs
|
||||||
asks `GET /usage`'s meter for that machine and provider, and only sends when
|
|
||||||
it answers `ok` with nothing at 100%. Anything else — still spent, logged
|
|
||||||
out, unreachable — is a longer wait, and a still-spent window reschedules to
|
|
||||||
the reset time the *meter* now gives.
|
|
||||||
- **Test it with echo, never with a real account.** `/limit [minutes]` reports
|
|
||||||
the same `limitReached` event a real driver does, and `/usage 100 5` sets
|
|
||||||
what the meter answers. They are deliberately separate: the two disagreeing
|
|
||||||
is the case the design exists for. `/usage 20` is the limit lifting.
|
|
||||||
- The wait is on the session in `config.ron` (`resume`), so it survives a
|
|
||||||
backend restart. A day after the limit was hit it gives up and says so in
|
|
||||||
the transcript.
|
|
||||||
|
|
||||||
## A session waiting on its own work
|
- `app/ui-sandbox.sh` runs an isolated delayed server with invented
|
||||||
|
transcripts, a fake CLI, stable enrollment, and a file-explorer fixture.
|
||||||
|
Its HOME and data are disposable; never point import/delete tests at real
|
||||||
|
`~/.claude/projects`.
|
||||||
|
- A two-line fake CLI (`#!/bin/sh`, `cat > /dev/null`) exercises adoption,
|
||||||
|
stop, restart, and process lifetime without using an account or token.
|
||||||
|
- `app/run-bench.sh` installs a benchmark APK on this checkout's emulator,
|
||||||
|
taps its accessibility-labelled control, and prints the report.
|
||||||
|
- `cd app && cargo test` drives the real transcript screen without a window
|
||||||
|
through `iris::harness`; touch recordings live in `app/touch/`.
|
||||||
|
- `iris/scripts/run-headless.sh phone --phone --dir ../app --shot …` opens the same
|
||||||
|
screen at phone size. `--replay ../app/touch/flick-120hz.touch` replays a
|
||||||
|
recorded gesture.
|
||||||
|
- `scripts/rigs/ui-profile/tests/frame_profile.rs` measures CPU frame cost;
|
||||||
|
`arena_churn.rs` measures GPU-array upload. Run ignored profiling tests in
|
||||||
|
release mode or the numbers are meaningless.
|
||||||
|
|
||||||
Since 2026-09-06 a session whose turn ended with a **backgrounded subagent or
|
The checked-in benchmark transcript is synthetic. Never put a real transcript
|
||||||
command still running** reports `waiting` rather than `idle` — its own status,
|
in this repository; it contains conversation text, tool input, and file data.
|
||||||
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
|
The emulator is a GLES rig. Its Vulkan implementation is SwiftShader, while
|
||||||
subagent sends it a real message that wakes it — which is the peer path, and
|
GLES is host-accelerated through virgl. Let Iris's runtime fallback select
|
||||||
already has a row. A row per finished background task was tried and was a
|
GLES; do not pass `force-gles`. Verify the `iris renderer:` log line before
|
||||||
screenful of dividers about work nobody was asking after, one of them a whole
|
interpreting a measurement. Vulkan is verified on desktop and a real phone.
|
||||||
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
|
## Driving Android UI
|
||||||
|
|
||||||
- **A row something is happening to is dimmed, drained of colour, and says
|
Read the installed `this-machine-android` skill before using Gradle, adb, an
|
||||||
which operation in a word** — `BusyItem`, used by both the session list and
|
AVD, screenshots, or UI traces. This checkout gets its own AVD; resolve it
|
||||||
the import list so the appearance is learned once. The word rather than a
|
with `emu serial` rather than typing a device name.
|
||||||
bare spinner because "deleting" and "importing" differ in kind. It does
|
|
||||||
**not** make the row inert: the caller disables its own click handler while
|
|
||||||
it passes a label. An overlay consuming pointer events was tried and
|
|
||||||
swallowed the drag along with the tap, so a list could not be scrolled
|
|
||||||
while anything in it was busy.
|
|
||||||
|
|
||||||
- **A rate-limit bar belongs to a session's provider, not to its machine.**
|
Scripts tap controls by accessibility label, never by coordinate. Coordinates
|
||||||
One machine offers echo, the Claude CLI and a local model at once and only
|
are allowed for swipes because a swipe describes a distance across a scrolling
|
||||||
the CLI spends anything, so a session says which meter reports on it
|
surface. A coordinate tap can silently hit a different control and turn a
|
||||||
(`usageProvider`, from `DriverKind::usage_provider`, which
|
failed run into a plausible-looking result.
|
||||||
`usage::providers_for` reads too so the two lists cannot disagree) and the
|
|
||||||
phone matches a snapshot on machine *and* provider. Nothing meters a llama
|
|
||||||
or echo session, and the phone draws **nothing** for one — not a zero, and
|
|
||||||
not "unknown". Nothing while the first fetch is out either: "checking"
|
|
||||||
under a session that turns out to meter nothing is a row the screen then
|
|
||||||
has to withdraw.
|
|
||||||
|
|
||||||
## Things that have bitten
|
## Host and VM boundary
|
||||||
|
|
||||||
- **A server started with no `--tools` answers 403 at `GET /tools`, not an
|
Production `ai-server` runs on the host, where the phone can reach WireGuard.
|
||||||
empty list.** The route is off rather than empty, so reading that as a
|
The Claude CLI is in this VM, so the host reaches it as a remote provider.
|
||||||
failure made "no tools" — the one setting whose entire purpose is to have
|
The VM's wg0 is useful for development but has no reachable phone peer. A dev
|
||||||
none — a session that never started. The router is always given
|
server in the VM creates a throwaway CA; never install an APK enrolled against
|
||||||
`--tools all` now and the choice is a filter here, so this is a trap for
|
that CA on the real phone.
|
||||||
whoever next changes how the server is started.
|
|
||||||
|
|
||||||
- **`POST /models/load` answers 400 for a model that is already loaded**, and
|
For llama.cpp tests, the CPU build is at `~/.local/opt/llama.cpp`; add that
|
||||||
that is the *ordinary* case once one server is shared: a second session
|
directory to `LD_LIBRARY_PATH`. Avoid 2-bit quants for driver diagnosis because
|
||||||
naming a model somebody else loaded. The router driver asks what is loaded
|
their fluent nonsense resembles a broken integration.
|
||||||
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
|
For SSH transport tests, SSH this VM to itself with a throwaway key and a
|
||||||
model is minutes of disk, so it runs on a `std::thread` — and tokio's
|
harmless command. Remove the key afterwards. The remote login shell is fish,
|
||||||
`Command::spawn` registers the child with the reactor, so calling it with no
|
so POSIX-quoting assumptions require explicit verification.
|
||||||
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.**
|
## Session invariants
|
||||||
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
|
Sessions deliberately outlive `ai-server`. Shutdown leaves marked processes
|
||||||
silent endings were found on 2026-09-20 and both looked, on the phone, like
|
running; restart adopts their process records without starting stopped
|
||||||
a message that was sent and never answered: an `{"error": ...}` chunk
|
sessions. Sending a message to a stopped session starts it. Use
|
||||||
arriving mid-stream on an otherwise successful response (a GPU that ran out
|
`--throwaway-sessions` for test-created sessions.
|
||||||
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
|
Each session directory contains `process.json`, `stdin.fifo`, `stdout.log`,
|
||||||
adding it made every transcript that had recorded one unreadable, so
|
and `stderr.log`. Do not edit or remove them while live: the stdout byte offset
|
||||||
`launch` failed for those sessions and `SessionManager::new` skipped them —
|
in `process.json` prevents replay and loss.
|
||||||
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
|
Never import a Claude Code session open in a terminal. One Claude session id
|
||||||
belongs in `~/.claude/MACHINE.md` or the `this-machine-*` skill for its
|
may occur in multiple project directories; import listing deduplicates by id
|
||||||
subject; one that would bite any project anywhere belongs in the
|
and prefers the copy with more lines, while deletion removes every copy.
|
||||||
`code-lessons` skill, under the admission test at its end.
|
|
||||||
|
|
||||||
- **tracing caches callsite interest process-wide.** A test that hits a
|
Deleting an app session only deletes the provider's transcript when
|
||||||
`tracing::warn!` with no subscriber installed can poison the interest cache
|
`deleteForeign=true`. The server deletes the foreign transcript first so an
|
||||||
for a concurrent test that captures logs (flaky "nothing was logged"
|
unreachable machine cannot leave a half-deleted session.
|
||||||
failures). Keep every exercise of a logging code path under the one
|
|
||||||
capturing subscriber — that is why the auth middleware has a single
|
## Known traps
|
||||||
combined gating+logging test.
|
|
||||||
- **The composer can get stuck floating above the bottom of the screen after
|
- `tracing` caches callsite interest process-wide. Logging tests must install
|
||||||
the keyboard closes, while a reply is streaming.** The composer's position
|
their capturing subscriber before any tested callsite runs.
|
||||||
and the transcript's bottom padding are both driven by the raw, animated
|
- `serde_json` needs `float_roundtrip`: transcript pages and SSE must preserve
|
||||||
`WindowInsets.ime` value read inside a `graphicsLayer` block, to avoid
|
identical timestamp bytes.
|
||||||
recomposing the whole screen every frame of the keyboard's animation. That
|
- Import lookup must use `import::find`, not list every transcript. Validate
|
||||||
animation is carried by a `WindowInsetsAnimationCallback`, and a callback
|
ids before putting them in a glob.
|
||||||
interrupted mid-flight leaves whatever it was carrying frozen at its last
|
- Transcript sequence numbers increase, so page edges are found by bisection.
|
||||||
value with nothing left to correct it. A streaming reply invalidates the
|
Do not replace indexed window reads with whole-transcript parsing.
|
||||||
view every frame, which is exactly the condition known to starve that
|
- A page's event count has no fixed relationship to visible rows because
|
||||||
callback of its `onEnd`. `WindowInsets.isImeVisible` does not share the
|
deltas and tool calls fold together. History cushions are measured in
|
||||||
failure mode — it is set once, from the platform's own start/end of the
|
viewports, not row counts.
|
||||||
transition over a different path — so it is read once per keyboard toggle
|
- Android generic motion is separate from touch. Keep hover, wheel, and mouse
|
||||||
and used to force both places back to zero.
|
button handling in `iris::android`; product UI consumes the same pointer
|
||||||
**The guard is a boolean; the inset itself must never be read in the
|
state on desktop and Android.
|
||||||
composable body.** That correction first shipped as a `padding(bottom = …
|
|
||||||
imeInsets.getBottom(this) …)`, which subscribes the whole screen to a value
|
|
||||||
that changes every frame: measured at **16 full recompositions of
|
|
||||||
`SessionScreen` per keyboard open, against 1**. It is
|
|
||||||
`.then(if (imeVisible) Modifier.imePadding() else Modifier)` instead —
|
|
||||||
`imePadding` reads the inset in the layout phase, and dropping the modifier
|
|
||||||
is the same coercion to zero the boolean was added for. The counter to
|
|
||||||
check is `session screen recomposed` in the debug report, which should move
|
|
||||||
by one across a keyboard open, not by the number of frames it took.
|
|
||||||
- **The keyboard pans the window unless the activity opts into resize.**
|
|
||||||
Without `android:windowSoftInputMode="adjustResize"`, opening the IME
|
|
||||||
slides the whole window up (top bar off screen) instead of resizing —
|
|
||||||
`imePadding()` alone does not fix it and the transcript looks empty.
|
|
||||||
- **A PEM constant must start at the opening quotes.** A generated
|
|
||||||
`"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory` its
|
|
||||||
preamble sniff, so it tries DER instead and fails at runtime with
|
|
||||||
`ASN.1 … DECODE_ERROR` — nowhere near the code that produced it.
|
|
||||||
- **ZXing only looks for a dark code on a light ground.** The enrollment QR
|
|
||||||
is block characters in the terminal's foreground colour, so a dark-themed
|
|
||||||
terminal renders it as a negative and the in-app scanner silently never
|
|
||||||
matches — while the phone's own camera app, which tries both, does. The
|
|
||||||
scanner asks for `Intents.Scan.MIXED_SCAN`, which alternates normal and
|
|
||||||
inverted frames; keep it that way rather than making the server dictate the
|
|
||||||
colours.
|
|
||||||
- **`serde_json`'s default float parser is not correctly rounded**, so the
|
|
||||||
server handed out the same transcript line two different ways: a `ts` of
|
|
||||||
`1788546972.6030757` came back from `/transcript` as `…0755` while the SSE
|
|
||||||
stream sent the original. Nothing on screen could show it — a `ts` is drawn
|
|
||||||
as a relative time — and what found it was the phone's cache comparing a
|
|
||||||
line it held against the server's answer. The `float_roundtrip` feature in
|
|
||||||
`server/Cargo.toml` is the fix and
|
|
||||||
`a_line_read_back_is_the_line_that_was_written` is what keeps it; that test
|
|
||||||
fails within a second of the feature being dropped.
|
|
||||||
- **Resolving one importable session used to list every one of them.**
|
|
||||||
`import::delete` and the import seed both called `list`, which reads every
|
|
||||||
transcript Claude Code has ever written — measured at 3.7 seconds against
|
|
||||||
the 867 MB in this VM, paid once per session in a batch. `import::find`
|
|
||||||
takes the same script with one glob narrower: 78ms. Ids are checked
|
|
||||||
(`is_session_id`) before they reach that glob, since a `/` or `..` walks it
|
|
||||||
out of the projects directory.
|
|
||||||
- **A transcript page used to cost the whole transcript.** `read_window` read
|
|
||||||
and parsed every line and then kept the last `limit` of them, so the work
|
|
||||||
was the size of the conversation rather than the size of the answer: one
|
|
||||||
page of a 21 MB, 24,000-event transcript took ~500ms to return 620 KB, and
|
|
||||||
took the same 500ms whichever page was asked for. It is a bisection now
|
|
||||||
(`Indexed` in `transcript.rs`) — sequence numbers only increase, so the
|
|
||||||
edge of a range is found by parsing one line per halving. Same page,
|
|
||||||
~110ms, of which ~20ms is the file scan. The file is still read whole; that
|
|
||||||
is where the remaining cost is, and going further means a chunked backwards
|
|
||||||
reader.
|
|
||||||
- **Paging back has two failures that look like "there is simply no more
|
|
||||||
history", and neither says anything on screen.** Both invisible on a
|
|
||||||
loopback server and reproducible at `--delay 150`. The pager fires on the
|
|
||||||
*first layout*, before any event has arrived — `moreHistory` starts true,
|
|
||||||
so the spinner is in the list and `visibleItemsInfo` is not empty — and
|
|
||||||
`before = 0` asks for the events before the first one, which is none, which
|
|
||||||
is exactly how this code is told it has reached the start. `loadOlderPage`
|
|
||||||
refuses `oldestSeq == 0` now. And `joinPages` only ran `adoptRun` on the
|
|
||||||
path where a *split* call had been found, so a boundary landing cleanly
|
|
||||||
between two calls — most of them — left one run of tool calls drawn as two
|
|
||||||
groups with the seam wherever the reader happened to have paged.
|
|
||||||
Reproducing either takes a boundary placed on purpose: the opening page is
|
|
||||||
80 events, so arrange the transcript so that event counts back from the
|
|
||||||
newest.
|
|
||||||
- **A page is 800 events and a screen is a handful of rows, and the two have
|
|
||||||
no fixed ratio.** A run of thirty-five tool calls is one row; a reply is
|
|
||||||
hundreds of text deltas folded into one. So anything that budgets in rows
|
|
||||||
has to measure a screen rather than name a number: the history cushion was
|
|
||||||
eight rows, which on a tool-heavy transcript is less than one screenful, so
|
|
||||||
the reader hit the end of what was loaded on every swipe and stood there
|
|
||||||
for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what
|
|
||||||
is actually on screen.
|
|
||||||
- **A page landing while the history observer was fetching it must trigger its
|
|
||||||
own successor.** The observer once collected only `LazyListState.layoutInfo`;
|
|
||||||
while its collector was suspended in `loadOlderPage`, a compact page could
|
|
||||||
be composed and laid out without leaving another change to observe afterward.
|
|
||||||
Keying the effect on `oldestSeq` still missed the opening prefetch: that key
|
|
||||||
changed while `loadingHistory` was true, so the restarted effect declined to
|
|
||||||
overlap it and never noticed the flag returning to false. Codex exposes both
|
|
||||||
failures because a page full of calls collapses into one tool group: loading
|
|
||||||
stopped until expanding that group forced a layout. The observer now collects
|
|
||||||
the cursor, loading, restoring and failure state with the layout, so returning
|
|
||||||
to not-loading always rechecks the settled height. A failed page turns the
|
|
||||||
history boundary into a Try again control rather than retrying in a loop or
|
|
||||||
requiring another scroll.
|
|
||||||
- **Only `fetchTranscript` was off the main thread; the fold was not.**
|
|
||||||
`foldEvent` returns a new list per event, so a page is that many copies of
|
|
||||||
a growing list — fine at 80 events and about 300,000 element copies at 800,
|
|
||||||
run in the middle of the scroll that asked for it. `warm` had the same
|
|
||||||
shape: the `markdownIn` scan that decides *what* to parse ran before the
|
|
||||||
hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
|
|
||||||
that wraps the *fetch* and leaves the work done with the result outside it.
|
|
||||||
- **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.
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Decisions awaiting review
|
|
||||||
|
|
||||||
Choices made while working autonomously, for Bryan to keep or change. Each
|
|
||||||
says what was picked and why; the detail is in the design doc it names.
|
|
||||||
Delete an entry once it has been looked at.
|
|
||||||
|
|
||||||
## Subagent views (2026-09-05, `SUBAGENTS.md`)
|
|
||||||
|
|
||||||
Made on my own judgement, limited blast radius:
|
|
||||||
|
|
||||||
1. **A subagent is a transcript, not a session.** It has no process,
|
|
||||||
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}`
|
|
||||||
and stored under the session's directory, so deleting the session takes
|
|
||||||
it. Alternative rejected: registering it as a session of its own, which
|
|
||||||
would give it a card in the main list and a driver that can do nothing.
|
|
||||||
2. **Read-only view is the session screen minus its controls**, rather than
|
|
||||||
a second, simpler transcript screen. Keeps paging, caching, selection
|
|
||||||
and rendering in one place. Cost: a `readOnly` mode threaded through
|
|
||||||
`SessionScreen`.
|
|
||||||
3. **The list only carries a count.** Each session row says how many
|
|
||||||
subagents it has; their titles and statuses are fetched when the card is
|
|
||||||
expanded. Keeps `GET /sessions` from reading every subagent transcript.
|
|
||||||
Consequence: an expanded card's statuses refresh with the list, not live.
|
|
||||||
4. **Expanded/collapsed is remembered per session on the phone**, not on
|
|
||||||
the server. Collapsed by default, per the transcript convention that new
|
|
||||||
things arrive collapsed.
|
|
||||||
5. **Subagents of imported sessions are not shown.** The import path still
|
|
||||||
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files
|
|
||||||
are not read. Only subagents run while this backend was watching exist.
|
|
||||||
6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a
|
|
||||||
paid turn to exercise.
|
|
||||||
|
|
||||||
Deferred, because they reach further than this feature:
|
|
||||||
|
|
||||||
- **Live status on the list.** Whether the session list should follow a
|
|
||||||
stream at all (it refreshes on demand today) decides whether subagent
|
|
||||||
status can ever be live there. Not changed.
|
|
||||||
- **Nested subagents.** A subagent's own Task calls are shown as tool calls
|
|
||||||
in its transcript and are not given transcripts of their own. Supporting
|
|
||||||
that is the same mechanism one level down, but the UI would need nested
|
|
||||||
expanders.
|
|
||||||
|
|
||||||
- **The subagent status row says "context unknown".** Nothing measures a
|
|
||||||
subagent's context; the row could leave it out rather than admit it.
|
|
||||||
-289
@@ -1,289 +0,0 @@
|
|||||||
# Subagents
|
|
||||||
|
|
||||||
A session's subagents -- helpers started by Claude Code's Task tool or Codex's
|
|
||||||
collaboration tools -- each get a transcript of their own, listed in a panel
|
|
||||||
over the open session and readable in the same transcript view the session has.
|
|
||||||
Designed 2026-09-05; extended to Codex's multiplexed app-server threads on
|
|
||||||
2026-09-13. The decisions Bryan has not yet reviewed are in `DECISIONS.md`.
|
|
||||||
|
|
||||||
## What a subagent is here
|
|
||||||
|
|
||||||
**A subagent is a second transcript owned by a session, in the same event
|
|
||||||
model, with no process and no controls.** It is not a session: it cannot be
|
|
||||||
messaged, stopped or started, and it has no machine, model or usage of its
|
|
||||||
own. Everything it shares with a session -- the transcript file format, the
|
|
||||||
paging routes, the SSE stream, the phone's cache and rendering -- is reused
|
|
||||||
by addressing, not by copying.
|
|
||||||
|
|
||||||
Claude reports a subagent's messages on the parent's own stream-json output,
|
|
||||||
each carrying `parent_tool_use_id` = the id of the Task `tool_use` that started
|
|
||||||
it. Before this the translator dropped those lines
|
|
||||||
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
|
|
||||||
them to that subagent's own translator and transcript. The parent's
|
|
||||||
transcript still shows only the Task call itself.
|
|
||||||
|
|
||||||
Codex app-server multiplexes every thread in the session tree onto the root
|
|
||||||
process's stdout. Its notifications carry `threadId`; `subAgentActivity`
|
|
||||||
items name the child thread and its lifecycle, and `collabAgentToolCall`
|
|
||||||
items carry the spawn prompt. The Codex translator routes a non-root
|
|
||||||
`threadId` exactly as Claude routes a `parent_tool_use_id`. The child thread
|
|
||||||
id is the subagent id on disk. An asynchronously delivered `agentMessage` is
|
|
||||||
a `PeerMessage`, not assistant text from the recipient. Its delta notification
|
|
||||||
does not repeat the completed item's `delivery` field, so the translator
|
|
||||||
remembers that field from `item/started` and suppresses those deltas. Letting
|
|
||||||
one into the recipient's provisional assistant row makes its next completed
|
|
||||||
message replace the combined row, visibly erasing text that Codex still has.
|
|
||||||
The parent draws the initial `spawnAgent` as its ordinary `Task` card and
|
|
||||||
closes it when the matching `subAgentActivity.started` arrives. The remaining
|
|
||||||
collaboration calls remain visible as coordination -- waiting, messaging,
|
|
||||||
listing and lifecycle controls -- rather than being mistaken for generic task
|
|
||||||
output. Null optional fields and a bare `completed` status carry no information
|
|
||||||
and are omitted; their useful result is the child transcript, status or peer
|
|
||||||
message beside them.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
Under the session directory:
|
|
||||||
|
|
||||||
```
|
|
||||||
<session>/subagents/<subagent_id>/meta.json {title, created}
|
|
||||||
<session>/subagents/<subagent_id>/transcript.jsonl same SeqEvent lines as the session's
|
|
||||||
```
|
|
||||||
|
|
||||||
The id is Claude's Task tool_use id (`toolu_…`) or Codex's child thread id.
|
|
||||||
Both are unique, stable across a backend restart, and already the key their
|
|
||||||
parent-side lifecycle uses.
|
|
||||||
Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
|
|
||||||
id becomes a path.
|
|
||||||
|
|
||||||
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
|
|
||||||
`read_window`, `catch_up` and `read_after` work on it unchanged.
|
|
||||||
|
|
||||||
Its path out: deleting the session deletes its directory, subagents included,
|
|
||||||
and `POST /sessions/{id}/subagents/delete` removes finished ones on their own
|
|
||||||
-- all or nothing, and refused while any named one is still running, since its
|
|
||||||
transcript is still being written to and its process is the session's to stop.
|
|
||||||
|
|
||||||
## Lifecycle, as events in the subagent's transcript
|
|
||||||
|
|
||||||
1. Created when the parent Task/Agent call is seen. A current Claude CLI's
|
|
||||||
`task_started` with `task_type: local_agent` is a recovery source when an
|
|
||||||
adopted stream begins after that call. A bare `parent_tool_use_id` is not
|
|
||||||
enough: other operations can also parent nested lines, and treating one as
|
|
||||||
proof created false subagents named after their first subcommand.
|
|
||||||
First lines written:
|
|
||||||
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when
|
|
||||||
the prompt is known -- it genuinely is the subagent's first user turn.
|
|
||||||
2. Every child line is translated by that subagent's own `Translator`
|
|
||||||
(one per subagent: tool ids are unique but streaming deltas are by
|
|
||||||
content-block index, and parallel subagents interleave).
|
|
||||||
3. **What ends a subagent is the CLI's own task lifecycle**, on top-level
|
|
||||||
`system` lines that carry no `parent_tool_use_id`: `task_started`
|
|
||||||
(`task_id`, `tool_use_id`, `task_type`, `is_backgrounded`, the prompt),
|
|
||||||
`task_progress` repeatedly, then `task_updated` (`patch.status`, naming the
|
|
||||||
*task* only) and `task_notification` (`tool_use_id`, `status`, and `summary`
|
|
||||||
-- the agent's own report). `translate_task` keeps the
|
|
||||||
`task_id -> tool_use_id` mapping from the first so the update can be
|
|
||||||
attributed, records the summary as the subagent's closing text, and writes
|
|
||||||
`Status Exited`. A
|
|
||||||
`completed` update is deliberately not the end: its notification carries
|
|
||||||
the summary and would otherwise land after the ending. Any other terminal
|
|
||||||
status ends it from the update, since the failure to avoid is a subagent
|
|
||||||
nothing ever finishes.
|
|
||||||
|
|
||||||
Since Claude Code 2.1.261, `background_tasks_changed { tasks: [...] }` is
|
|
||||||
the authoritative level beside those edges: its set replaces the previous
|
|
||||||
set, so a missed terminal edge cannot leave a subagent running forever. Its
|
|
||||||
ids are deliberately not correlated with the edge stream; what is read off
|
|
||||||
each entry is its own description and kind, and what is read off the set is
|
|
||||||
whether it is empty and how large. The session API and stream expose that
|
|
||||||
size as `backgroundTasks`, which the phone draws beside the status, and
|
|
||||||
`GET /sessions/{id}/background` serves the entries themselves -- listed in
|
|
||||||
the session's panel *above* the subagents and never as subagent cards. An
|
|
||||||
`ambient` entry is excluded from both, on the CLI's own instruction: a
|
|
||||||
live-update watcher is not activity. A backgrounded subagent is legitimately
|
|
||||||
in both lists, since it is both running and a transcript. The edges still
|
|
||||||
carry mapping, outcome and closing summary. On adoption the driver sends a repeated `initialize`,
|
|
||||||
which makes a current CLI send the full set; an older CLI accepts it and sends no level,
|
|
||||||
leaving the edge-based path unchanged. A snapshot is reconciled immediately
|
|
||||||
when the persisted parent status proves it is between turns, and otherwise
|
|
||||||
at the next `result` boundary -- while a turn is open, a foreground agent is
|
|
||||||
legitimately absent from the background set. Reconciliation writes
|
|
||||||
`Status Exited`, which is also what makes a formerly stale row deletable;
|
|
||||||
a task notification ordered after the level can still add its summary.
|
|
||||||
|
|
||||||
The two rules this replaces were both wrong, in opposite directions. The
|
|
||||||
parent's `tool_result` is not it: a backgrounded Task's arrives at launch
|
|
||||||
("Async agent launched..."), so ending there truncated a running agent's
|
|
||||||
transcript at the moment it started. Nor is the subagent's own
|
|
||||||
`end_turn`: measured against 2.1.237 on 2026-09-06, **a subagent's lines
|
|
||||||
carry no `stream_event` at all** -- they are whole `user`/`assistant`
|
|
||||||
lines with a null `stop_reason`, no `result` line is sent for one, and the
|
|
||||||
sub's final report never appears as a child line -- so that rule could
|
|
||||||
never fire and every subagent stayed `running` for ever. `ends_a_turn` is
|
|
||||||
kept as a second detector for a dialect that does say either, and must
|
|
||||||
never be the only one again.
|
|
||||||
|
|
||||||
`Status Exited` either way; the subagent's vocabulary has no `Idle` or
|
|
||||||
`Waiting`, so the end-of-turn status `dispatch` produces for an ordinary
|
|
||||||
session is dropped rather than written.
|
|
||||||
|
|
||||||
**The ending reaches the parent's transcript as nothing at all**
|
|
||||||
(2026-09-06). It was tried, and a row per finished subagent is a screenful
|
|
||||||
of dividers about work the reader was not asking after; the closing report
|
|
||||||
is *this* transcript's last line and here is where somebody reads it. What
|
|
||||||
the parent gets a row for is a message a subagent genuinely sends it, which
|
|
||||||
arrives by the peer path. A backgrounded *command* is the other half of
|
|
||||||
this and goes the other way: it has no transcript of its own, so its report
|
|
||||||
updates the tool card that launched it, which was still saying the command
|
|
||||||
was running. The two lifecycle shapes are still handled once:
|
|
||||||
whichever gets there first is the one that finds the task still open, and
|
|
||||||
`finish` below closes it. See PLAN.md's "Two turns must never be drawn as
|
|
||||||
one".
|
|
||||||
|
|
||||||
**While any task is outstanding the session's turn ends in
|
|
||||||
`Status Waiting` rather than `Idle`.** `Idle` means "waiting for a person",
|
|
||||||
and a session with a backgrounded subagent is not doing that. The edge
|
|
||||||
fallback has two sources: the translator's `open_tasks`, and
|
|
||||||
`Subagents::any_open` -- which covers a subagent launched before a backend
|
|
||||||
restart adopted the session, whose `task_started` is behind the durable
|
|
||||||
stdout offset. On current Claude versions the replace-semantics level above
|
|
||||||
reconciles both at a safe turn boundary.
|
|
||||||
|
|
||||||
**A limit the account hits inside a subagent is hoisted to the session**
|
|
||||||
as well as recorded here, because `resume.rs` can only schedule against a
|
|
||||||
session, and a background subagent outliving its parent's turn is the
|
|
||||||
ordinary case -- see PLAN.md's "A limit a subagent hits is the session's".
|
|
||||||
4. **A child line for a subagent that already finished reopens it**
|
|
||||||
(`Status Running`) rather than being dropped: a background Task can be
|
|
||||||
sent another message long after its first turn ended, and that is
|
|
||||||
exactly what a further line for it means. Same transcript, same child
|
|
||||||
`Translator`, just picking back up.
|
|
||||||
5. When the parent session's process exits (`Status Exited` on the
|
|
||||||
session), every subagent still `Running` gets `Status Exited` too: its
|
|
||||||
process was the parent's. Read from the directory rather than from the
|
|
||||||
live map, because one left `Running` by a previous run of the server is
|
|
||||||
precisely the one nothing in this process has touched -- and it would
|
|
||||||
otherwise read `running` again every time its session was started.
|
|
||||||
|
|
||||||
For Codex the same lifecycle is expressed by app-server rather than Claude's
|
|
||||||
task notices: `subAgentActivity.started` creates the child,
|
|
||||||
`subAgentActivity.interacted` reopens it, and `completed` or `interrupted`
|
|
||||||
finishes it. A child's own `turn/completed` is not its end; it remains running
|
|
||||||
until that activity edge. The root's `turn/completed` reports `waiting` while
|
|
||||||
the registry contains an open child, and the last activity completion reports
|
|
||||||
`idle` if the root is between turns. Because the child thread id is also the
|
|
||||||
on-disk id, an adopted driver can route and finish a child whose spawn record
|
|
||||||
is already behind the durable stdout offset. The registry's open count is also
|
|
||||||
Codex's `backgroundTasks` measurement: lifecycle changes send it through the
|
|
||||||
same event and session-summary fields as Claude's provider snapshot. The other
|
|
||||||
part of that measurement is app-server's runtime
|
|
||||||
`thread/backgroundTerminals/list` set. Its process ids are held only in memory
|
|
||||||
and added to the open-child count; the driver refreshes the set at terminal
|
|
||||||
boundaries and while it remains nonempty, rather than decrementing for an
|
|
||||||
unmatched ending edge.
|
|
||||||
|
|
||||||
A subagent that was mid-flight when the backend restarted keeps working:
|
|
||||||
the registry reopens the existing transcript on the next child line, and
|
|
||||||
the file continues its sequence -- the same reopening #4 describes, whether
|
|
||||||
what closed it was a restart or its own `end_turn`. If its turn ended while
|
|
||||||
the backend was down nothing recorded that until the next line arrives, so
|
|
||||||
its last status stays `Running`, which the list reports as **unknown**
|
|
||||||
rather than as running (see the wire shape) until then.
|
|
||||||
|
|
||||||
Title: for Claude, the Task call's `description` input, then
|
|
||||||
` (<subagent_type>)` when one is given; falling back to `Task` when the
|
|
||||||
description is absent. An adopted current CLI can recover the same fields from
|
|
||||||
its `local_agent` lifecycle record. For Codex, the first lifecycle record uses
|
|
||||||
the spawned thread's name or the last segment of `agentPath`, with underscores
|
|
||||||
shown as spaces, then falls back to `subagent`.
|
|
||||||
|
|
||||||
## Server layout
|
|
||||||
|
|
||||||
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
|
|
||||||
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
|
|
||||||
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
|
|
||||||
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk, and
|
|
||||||
`delete(ids)` -- its path out. Drivers get an
|
|
||||||
`Arc<Subagents>` beside their `EventSink`; llama ignores it.
|
|
||||||
- `session/claude/translate.rs` -- routes child lines by parent id, holds
|
|
||||||
one child `Translator` per subagent, remembers pending Task calls'
|
|
||||||
description/prompt/subagent_type.
|
|
||||||
- `session/codex/translate.rs` -- routes multiplexed app-server notifications
|
|
||||||
by thread id, remembers collaboration prompts, and translates activity
|
|
||||||
edges into the same registry lifecycle.
|
|
||||||
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
|
|
||||||
subagents at once, each named "helper k". Each writes the prompt as its
|
|
||||||
user message, streams a few words of text, runs one `Bash` tool call, then
|
|
||||||
finishes about three seconds after starting, and the parent's Task calls
|
|
||||||
end when their subagent does. Three seconds so the running state can be
|
|
||||||
seen on the phone.
|
|
||||||
- `routes.rs` -- four routes, in the doc table.
|
|
||||||
|
|
||||||
## Wire shape
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /sessions/{id} SessionInfo gains `subagents: N` (count, 0 when none)
|
|
||||||
GET /sessions same field on each row
|
|
||||||
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
|
|
||||||
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
|
|
||||||
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
|
|
||||||
POST /sessions/{id}/subagents/delete {subagents} -> 204; refused whole if one is running
|
|
||||||
```
|
|
||||||
|
|
||||||
The delete is a batch rather than a `DELETE` per id for the reason the import
|
|
||||||
list's is: the phone deletes what a reader selected, and one request per row
|
|
||||||
means a batch can half-arrive, leaving the rows that were missed looking
|
|
||||||
exactly like rows nobody picked. Unlike an import delete it is local file
|
|
||||||
removal, so it is done by the time the reply is sent and there is no per-row
|
|
||||||
state to follow afterwards. What decides "running" is
|
|
||||||
`Subagents::list`'s own rule, shared through `routes::has_a_process` so the
|
|
||||||
list and the delete cannot disagree about it.
|
|
||||||
|
|
||||||
`status` is the transcript's last `Status` event, serialised like a session's
|
|
||||||
(`running`, `exited`), except that a subagent whose session is not itself
|
|
||||||
running cannot be running: the list answers `unknown` for that one. A
|
|
||||||
subagent never reports `waiting`: that is a session's word for having
|
|
||||||
outstanding work of its own, and a subagent has none. The
|
|
||||||
phone words these as *running*, *finished* and *unknown* on the subcard.
|
|
||||||
|
|
||||||
The count on `SessionInfo` is a directory listing, so the list stays cheap.
|
|
||||||
The per-subagent status is only read when the list route is asked for.
|
|
||||||
|
|
||||||
## Phone
|
|
||||||
|
|
||||||
- The subcards are ordered **still running first, then most recently
|
|
||||||
active** -- a display decision made on the phone (`subagentOrder`), over the
|
|
||||||
server's stable oldest-first answer. Two keys rather than activity alone
|
|
||||||
because a subagent that is thinking reports nothing meanwhile and would sink
|
|
||||||
below one that just finished.
|
|
||||||
- **Holding a subcard selects it, and several at a time**, exactly as the
|
|
||||||
import list works, with the selection bar drawn inside the panel rather than
|
|
||||||
at the bottom of the session: this selection belongs to the subagent list,
|
|
||||||
and a bar under the composer would read as acting on the conversation.
|
|
||||||
Delete is
|
|
||||||
*disabled*, with the reason in words, while anything selected is still
|
|
||||||
running. Deleting confirms first, dims the rows it is acting on
|
|
||||||
(`BusyItem`), and on success takes them out of the panel without refetching
|
|
||||||
anything else. The phone's cached copy of
|
|
||||||
a deleted subagent's transcript is purged with it.
|
|
||||||
- The main session list does not expand or count subagents. Swiping left over
|
|
||||||
an open session pulls an 88%-wide panel in from the right and fetches
|
|
||||||
`/sessions/{id}/subagents`; it draws one `OutlinedCard` per subagent: title,
|
|
||||||
then the status word and a relative time. The transcript remains composed
|
|
||||||
under the panel, so its event stream, draft and scroll position stay live.
|
|
||||||
Horizontal scrollers inside the transcript win the gesture. Collapsing one,
|
|
||||||
or starting over any ordinary part of the session, gives the gesture back to
|
|
||||||
the panel; Android keeps its own edge Back gesture. Swiping right on the
|
|
||||||
panel, tapping outside it, or Back closes it.
|
|
||||||
- Tapping a subcard opens a `SessionScreen` layer in **read-only** form: the
|
|
||||||
same transcript, paging, cache, selection,
|
|
||||||
images and status row, with the composer, the process button, the model
|
|
||||||
picker, the files button, the settings cog and the usage bar left out.
|
|
||||||
The header shows the subagent's title with the session's title beneath it.
|
|
||||||
It is another layer over the still-composed session and its panel; Back
|
|
||||||
returns to the panel.
|
|
||||||
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
|
|
||||||
cache take a transcript address rather than a session id --
|
|
||||||
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
|
|
||||||
subagent's copy under its session's and the same code serves both.
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# TODO
|
|
||||||
|
|
||||||
Working list from Iris, 2026-09-03. Remove an entry when it lands; annotate
|
|
||||||
one in place when it turns out to need a decision.
|
|
||||||
|
|
||||||
## App — transcript
|
|
||||||
|
|
||||||
- [ ] Decide how running background tasks can be inspected. For now the session
|
|
||||||
status shows only the provider-reported count; command details stay in
|
|
||||||
their existing tool cards and must not become subagent cards.
|
|
||||||
|
|
||||||
- [ ] Messages received from other agents are inconsistent — sometimes they
|
|
||||||
appear, sometimes they don't. **Needs a rig.** Read the code rather than
|
|
||||||
measured: a live Claude session only learns of a peer message from the
|
|
||||||
`origin` object on a turn's `result`
|
|
||||||
(`session/claude/translate.rs`), which the CLI attaches to a turn the
|
|
||||||
message *started*. So a message that arrives mid-turn, or a second one
|
|
||||||
within one turn, has nowhere to be reported — while an imported session,
|
|
||||||
which syncs from the CLI's own file, picks up every one of them. That
|
|
||||||
would show exactly as "sometimes". Confirming it means driving a real
|
|
||||||
stream-json session and sending it messages in both states.
|
|
||||||
|
|
||||||
## Session settings
|
|
||||||
|
|
||||||
- [ ] Autocompact belongs in session settings; empty disables it, which is the
|
|
||||||
default. Iris chose "hand it to the driver" — only where a driver has
|
|
||||||
auto-compaction of its own. **That option was offered on a false premise
|
|
||||||
and is not buildable yet.** It named pi's `set_auto_compaction`, but pi
|
|
||||||
was never built as a driver here: `session/llama.rs` talks to
|
|
||||||
`llama-server`'s OpenAI-compatible endpoint directly, and its `compact()`
|
|
||||||
refuses outright. Claude Code's auto-compaction is the CLI's own and
|
|
||||||
nothing in the stream-json control protocol this app uses configures it.
|
|
||||||
So the setting would be stored, passed to a driver, refused by every one
|
|
||||||
of them, and the field would never appear on any session. What is needed
|
|
||||||
first is either a driver that can take it, or a different rule — the
|
|
||||||
server watching `contextTokens` and running `/compact` itself is the one
|
|
||||||
that would work today, for Claude sessions, and it is the option that was
|
|
||||||
not chosen.
|
|
||||||
|
|
||||||
|
|
||||||
## A session the server could not load
|
|
||||||
|
|
||||||
- [ ] **A session whose transcript will not parse is skipped with nothing but a
|
|
||||||
log line, and from the phone it looks exactly like an idle unresponsive
|
|
||||||
one.** `SessionManager::new` catches a failing `launch` and logs
|
|
||||||
"couldn't relaunch session <id>", so the session has no pump and no
|
|
||||||
driver: no status, no history, nothing sendable. That is what the
|
|
||||||
`taskNote` incident (fd71d87) looked like from Bryan's phone, and why it
|
|
||||||
needed a report from him rather than being visible in the app.
|
|
||||||
`Event::Unreadable` removes the cause that time, but not the class — an
|
|
||||||
unreadable `process.json`, a provider edited away and an unreachable host
|
|
||||||
all reach the same place.
|
|
||||||
This is the "design the unknown state first" rule: a session the server
|
|
||||||
could not load is not a session with nothing to say, and only the phone
|
|
||||||
can show the difference. It needs a status the wire can carry for it —
|
|
||||||
the failure with its reason, reported on the session itself — rather than
|
|
||||||
the reader having to tell it apart from silence.
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
android-project/.gradle/
|
||||||
|
android-project/build/
|
||||||
|
android-project/app/build/
|
||||||
|
# Rebuilt by build-apk.sh before every Gradle build.
|
||||||
|
android-project/app/src/main/jniLibs/
|
||||||
|
target/
|
||||||
|
Cargo.lock.orig
|
||||||
Generated
+5194
File diff suppressed because it is too large.
Load diff
+105
@@ -0,0 +1,105 @@
|
|||||||
|
# Product code lives here; reusable UI belongs in `iris/`.
|
||||||
|
[package]
|
||||||
|
name = "ai-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
# Android loads the cdylib; desktop, examples, and tests link the rlib.
|
||||||
|
[lib]
|
||||||
|
name = "ai_app"
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ai-app-desktop"
|
||||||
|
path = "src/bin_desktop.rs"
|
||||||
|
required-features = ["screens"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "transcript"
|
||||||
|
required-features = ["screens"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "phone"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
event-model = { path = "../event-model" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
# Transcript lines must retain exact float values and raw JSON bytes.
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
|
||||||
|
ureq = { version = "3", features = ["json"] }
|
||||||
|
pulldown-cmark = "0.13.4"
|
||||||
|
base64 = "0.23"
|
||||||
|
log = { version = "0.4.34", features = ["std"] }
|
||||||
|
|
||||||
|
# UI dependencies stay optional so client-only tests do not link the renderer.
|
||||||
|
iris = { path = "../iris", optional = true }
|
||||||
|
libc = { version = "0.2.189", optional = true }
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
|
winit = "0.30.13"
|
||||||
|
|
||||||
|
# Keep this pin synchronized with `iris/Cargo.toml`.
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||||
|
android_logger = "0.15.1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["screens", "fixture"]
|
||||||
|
screens = ["dep:iris"]
|
||||||
|
# Default-on for tests; APK builds opt in so ordinary APKs omit the 1.9 MB fixture.
|
||||||
|
fixture = ["screens"]
|
||||||
|
bench = ["screens", "fixture", "dep:libc", "dep:tokio"]
|
||||||
|
force-gles = ["screens", "iris/force-gles"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "time"] }
|
||||||
|
swash = "0.2.10"
|
||||||
|
|
||||||
|
# APK builds select these profiles explicitly.
|
||||||
|
[profile.android-release]
|
||||||
|
inherits = "release"
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
# A warm-fling profile measured p90/p99 0.09/0.26 ms at 3 versus
|
||||||
|
# 0.15/0.42 ms at "s"; the 1.9 MB saving is not worth that frame cost.
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
[profile.android-dev]
|
||||||
|
inherits = "dev"
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
# Full DWARF in each renderer-linked test binary writes tens of gigabytes.
|
||||||
|
[profile.dev]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
[profile.test]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "catch_a_fling"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "fence_fling"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "gesture_cancel"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "input_log_roundtrip"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "phone_screen"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "top_edge"
|
||||||
|
required-features = ["fixture"]
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Android SDK environment for this app's Gradle build: locates the SDK and
|
|
||||||
# exports the PATH/env vars the build needs. Pure Kotlin/Gradle, so nothing
|
|
||||||
# Rust/NDK-specific belongs here.
|
|
||||||
#
|
|
||||||
# Source this directly for one-off commands instead of going through the
|
|
||||||
# full run-android.sh (which also creates/boots the emulator, builds,
|
|
||||||
# installs, and launches):
|
|
||||||
#
|
|
||||||
# . ./android-env.sh
|
|
||||||
# ./gradlew :androidApp:assembleDebug
|
|
||||||
# adb devices
|
|
||||||
#
|
|
||||||
# Safe to source repeatedly. Intentionally does NOT `set -e`/`set -u`: this
|
|
||||||
# file is meant to be sourced into whatever shell is already running --
|
|
||||||
# including a long-lived one a session reuses for unrelated commands -- and
|
|
||||||
# changing that shell's error-handling options as a side effect of sourcing
|
|
||||||
# would be surprising. run-android.sh, which does want strict mode, sets its
|
|
||||||
# own `set -eu` before sourcing this.
|
|
||||||
|
|
||||||
# Hardcoded (not derived from an inherited ANDROID_HOME) so this doesn't
|
|
||||||
# silently follow whatever that happens to be set to elsewhere -- e.g. this
|
|
||||||
# sandbox's own profile exports ANDROID_HOME=/opt/android-sdk system-wide, a
|
|
||||||
# root-owned install this user can't write to. Everything needed lives under
|
|
||||||
# the path below instead, matching Android Studio's own default SDK location
|
|
||||||
# convention on Linux.
|
|
||||||
SDK_ROOT="$HOME/Android/Sdk"
|
|
||||||
ANDROID_HOME="$SDK_ROOT"
|
|
||||||
ANDROID_SDK_ROOT="$SDK_ROOT"
|
|
||||||
# ~/.local/bin is where the `android` CLI itself installs to (see its own
|
|
||||||
# installer); adding it here too means sourcing this script guarantees a
|
|
||||||
# working `android` command even in a shell that hasn't picked up
|
|
||||||
# ~/.profile yet.
|
|
||||||
PATH="$HOME/.local/bin:$SDK_ROOT/cmdline-tools/latest/bin:$SDK_ROOT/platform-tools:$SDK_ROOT/emulator:$PATH"
|
|
||||||
# Pin the AVD directory explicitly so avdmanager (creation) and the emulator
|
|
||||||
# binary (lookup at start time) are guaranteed to agree on where the AVD
|
|
||||||
# lives -- left to their own defaults they can resolve different locations
|
|
||||||
# and disagree on whether it exists.
|
|
||||||
ANDROID_AVD_HOME="${ANDROID_AVD_HOME:-$HOME/.android/avd}"
|
|
||||||
mkdir -p "$ANDROID_AVD_HOME"
|
|
||||||
export ANDROID_HOME ANDROID_SDK_ROOT ANDROID_AVD_HOME PATH
|
|
||||||
|
|
||||||
echo "==> Ensuring required SDK packages are installed in $SDK_ROOT"
|
|
||||||
# $SDK_ROOT is user-owned (unlike /opt/android-sdk), so this genuinely
|
|
||||||
# installs anything missing rather than just probing for it -- still
|
|
||||||
# best-effort (`|| echo`) so a transient network hiccup doesn't abort a
|
|
||||||
# script sourcing this under `set -e`.
|
|
||||||
#
|
|
||||||
# build-tools is needed twice over: by Gradle for this app's own build, and
|
|
||||||
# by ../server at runtime for `aapt2` (reading a discovered APK's package
|
|
||||||
# name) and `llvm-strip`/`apksigner` (the slim-APK pipeline).
|
|
||||||
android sdk install "cmdline-tools/latest" "platform-tools" "emulator" \
|
|
||||||
"platforms/android-37.0" "build-tools/37.0.0" \
|
|
||||||
"system-images/android-36/google_apis/x86_64" \
|
|
||||||
|| echo " (non-fatal: see above)"
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
}
|
||||||
|
|
||||||
|
// build-apk.sh places the Rust cdylib in src/main/jniLibs before Gradle runs.
|
||||||
|
def benchBuild = System.getenv("AI_APP_BENCH") == "1"
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "dev.iris.android.demo"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.example.aiapp"
|
||||||
|
// 29, not 26: `iris::android::view`'s touch handler dates each
|
||||||
|
// sample with `MotionEvent.getEventTimeNanos` and
|
||||||
|
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
|
||||||
|
// method there is a hard crash on the first touch rather than a
|
||||||
|
// degraded fling. Raised deliberately rather than guarded at
|
||||||
|
// runtime: nothing this app is built for runs below 29, and an
|
||||||
|
// untested fallback path is its own defect. `build-apk.sh`'s
|
||||||
|
// `cargo ndk -P` is kept at the same number.
|
||||||
|
minSdk = 29
|
||||||
|
// targetSdk 35+ supplies real IME overlap under enforced edge-to-edge.
|
||||||
|
targetSdk = 37
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
manifestPlaceholders = [appLabel: benchBuild ? "AI Sessions bench" : "AI Sessions"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The signing key is machine-local; build-apk.sh creates and supplies it.
|
||||||
|
def keystore = System.getenv("AI_APP_KEYSTORE")
|
||||||
|
signingConfigs {
|
||||||
|
if (keystore != null) {
|
||||||
|
release {
|
||||||
|
storeFile = file(keystore)
|
||||||
|
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
|
||||||
|
keyAlias = "ai-app"
|
||||||
|
keyPassword = storePassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
if (benchBuild) {
|
||||||
|
applicationIdSuffix ".bench"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
if (benchBuild) {
|
||||||
|
applicationIdSuffix ".bench"
|
||||||
|
}
|
||||||
|
if (keystore != null) {
|
||||||
|
signingConfig = signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- The transcript client talks to ai-server. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:label="${appLabel}"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Enrollment links minted by ai-server and Dev Updater. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="aiapp" android:host="enroll" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<meta-data android:name="android.app.lib_name" android:value="ai_app" />
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Read-only recent logs for Dev Updater. The authority follows
|
||||||
|
applicationId so normal and benchmark builds stay separate. -->
|
||||||
|
<provider
|
||||||
|
android:name=".DevLogProvider"
|
||||||
|
android:authorities="${applicationId}.devlog"
|
||||||
|
android:exported="true"
|
||||||
|
android:readPermission="dev.updater.permission.READ_DEVLOG" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.content.ContentProvider;
|
||||||
|
import android.content.ContentValues;
|
||||||
|
import android.content.UriMatcher;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.database.MatrixCursor;
|
||||||
|
import android.net.Uri;
|
||||||
|
|
||||||
|
/** Read-only Dev Updater log provider; its URI and column schema are an external contract. */
|
||||||
|
public final class DevLogProvider extends ContentProvider {
|
||||||
|
static {
|
||||||
|
// A provider can start the process without creating MainActivity.
|
||||||
|
System.loadLibrary("ai_app");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int FIELDS_PER_LINE = 5;
|
||||||
|
|
||||||
|
private static final String[] LINE_COLUMNS = {"seq", "t_ms", "level", "target", "message"};
|
||||||
|
private static final String[] STATUS_COLUMNS = {"held", "dropped", "newest_seq"};
|
||||||
|
|
||||||
|
private static final int LINES = 1;
|
||||||
|
private static final int STATUS = 2;
|
||||||
|
|
||||||
|
private UriMatcher matcher;
|
||||||
|
|
||||||
|
private static native String[] nativeLinesSince(long since);
|
||||||
|
|
||||||
|
private static native String[] nativeStatus();
|
||||||
|
|
||||||
|
// The provider may be the process's only component, so it must supply
|
||||||
|
// the files directory normally initialized by MainActivity.
|
||||||
|
private static native void nativeReady(String authority, String filesDir);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCreate() {
|
||||||
|
String authority = getContext().getPackageName() + ".devlog";
|
||||||
|
matcher = new UriMatcher(UriMatcher.NO_MATCH);
|
||||||
|
matcher.addURI(authority, "lines", LINES);
|
||||||
|
matcher.addURI(authority, "status", STATUS);
|
||||||
|
nativeReady(authority, getContext().getFilesDir().getAbsolutePath());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Cursor query(
|
||||||
|
Uri uri,
|
||||||
|
String[] projection,
|
||||||
|
String selection,
|
||||||
|
String[] selectionArgs,
|
||||||
|
String sortOrder) {
|
||||||
|
switch (matcher.match(uri)) {
|
||||||
|
case LINES:
|
||||||
|
return lines(sinceOf(uri));
|
||||||
|
case STATUS:
|
||||||
|
return status();
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long sinceOf(Uri uri) {
|
||||||
|
String since = uri.getQueryParameter("since");
|
||||||
|
if (since == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(since);
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Cursor lines(long since) {
|
||||||
|
String[] fields = nativeLinesSince(since);
|
||||||
|
if (fields == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MatrixCursor cursor = new MatrixCursor(LINE_COLUMNS, fields.length / FIELDS_PER_LINE);
|
||||||
|
for (int at = 0; at + FIELDS_PER_LINE <= fields.length; at += FIELDS_PER_LINE) {
|
||||||
|
cursor.addRow(
|
||||||
|
new Object[] {
|
||||||
|
Long.parseLong(fields[at]),
|
||||||
|
Long.parseLong(fields[at + 1]),
|
||||||
|
fields[at + 2],
|
||||||
|
fields[at + 3],
|
||||||
|
fields[at + 4],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Cursor status() {
|
||||||
|
String[] fields = nativeStatus();
|
||||||
|
if (fields == null || fields.length != STATUS_COLUMNS.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MatrixCursor cursor = new MatrixCursor(STATUS_COLUMNS, 1);
|
||||||
|
cursor.addRow(
|
||||||
|
new Object[] {
|
||||||
|
Long.parseLong(fields[0]), Long.parseLong(fields[1]), Long.parseLong(fields[2]),
|
||||||
|
});
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType(Uri uri) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Uri insert(Uri uri, ContentValues values) {
|
||||||
|
throw new UnsupportedOperationException("this app's log is read-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||||
|
throw new UnsupportedOperationException("this app's log is read-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||||
|
throw new UnsupportedOperationException("this app's log is read-only");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.widget.ScrollView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import org.linebender.android.rustview.RustView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* android-view's abstract base plus the two native methods it has no hook
|
||||||
|
* for: window insets and unregistering this view's entry in
|
||||||
|
* iris::android::insets's side table. See iris/src/android/insets.rs's doc
|
||||||
|
* comment for why those could not ride along on an existing android-view
|
||||||
|
* callback the way the back gesture does.
|
||||||
|
*/
|
||||||
|
public final class IrisView extends RustView {
|
||||||
|
@Override
|
||||||
|
protected native long newViewPeer(Context context);
|
||||||
|
|
||||||
|
native void applyWindowInsetsNative(
|
||||||
|
long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible);
|
||||||
|
|
||||||
|
native void unregisterInsetsNative(long peer);
|
||||||
|
|
||||||
|
public IrisView(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyWindowInsets(
|
||||||
|
int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
|
||||||
|
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetachedFromWindow() {
|
||||||
|
unregisterInsetsNative(mViewPeer);
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// This path must not depend on the renderer that failed to initialize.
|
||||||
|
void showRendererError(String report) {
|
||||||
|
Context context = getContext();
|
||||||
|
if (!(context instanceof Activity)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Activity activity = (Activity) context;
|
||||||
|
TextView text = new TextView(activity);
|
||||||
|
text.setText(report);
|
||||||
|
text.setTextIsSelectable(true);
|
||||||
|
text.setGravity(Gravity.TOP | Gravity.START);
|
||||||
|
int pad = (int) (16 * activity.getResources().getDisplayMetrics().density);
|
||||||
|
text.setPadding(pad, pad, pad, pad);
|
||||||
|
ScrollView scroll = new ScrollView(activity);
|
||||||
|
scroll.addView(text);
|
||||||
|
activity.setContentView(scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.WindowInsets;
|
||||||
|
import android.view.WindowInsetsAnimation;
|
||||||
|
import android.widget.FrameLayout;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class MainActivity extends Activity {
|
||||||
|
static {
|
||||||
|
System.loadLibrary("ai_app");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static native void nativeSetFilesDir(String path);
|
||||||
|
|
||||||
|
private static native void nativeEnroll(String uri);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle state) {
|
||||||
|
super.onCreate(state);
|
||||||
|
nativeSetFilesDir(getFilesDir().getAbsolutePath());
|
||||||
|
handleEnrollmentIntent(getIntent());
|
||||||
|
IrisView view = new IrisView(this);
|
||||||
|
view.setLayoutParams(new FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||||
|
view.setFocusable(true);
|
||||||
|
view.setFocusableInTouchMode(true);
|
||||||
|
FrameLayout layout = new FrameLayout(this);
|
||||||
|
layout.addView(view);
|
||||||
|
setContentView(layout);
|
||||||
|
view.requestFocus();
|
||||||
|
|
||||||
|
// Edge-to-edge makes IME-only changes produce fresh inset dispatches.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
getWindow().setDecorFitsSystemWindows(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static dispatch supplies settled insets; the animation callback
|
||||||
|
// supplies intermediate IME heights. An interrupted animation may
|
||||||
|
// omit its final progress frame, so onEnd re-reads the root insets.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
|
||||||
|
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
|
||||||
|
@Override
|
||||||
|
public WindowInsets onProgress(
|
||||||
|
WindowInsets insets, List<WindowInsetsAnimation> running) {
|
||||||
|
sendInsets(view, insets);
|
||||||
|
return insets;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEnd(WindowInsetsAnimation animation) {
|
||||||
|
WindowInsets settled = view.getRootWindowInsets();
|
||||||
|
if (settled != null) {
|
||||||
|
sendInsets(view, settled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||||
|
sendInsets((IrisView) v, insets);
|
||||||
|
return insets;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
// Keep getIntent() consistent with the enrollment being handled.
|
||||||
|
setIntent(intent);
|
||||||
|
handleEnrollmentIntent(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handleEnrollmentIntent(Intent intent) {
|
||||||
|
if (intent == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Uri data = intent.getData();
|
||||||
|
if (data != null) {
|
||||||
|
nativeEnroll(data.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sendInsets(IrisView view, WindowInsets insets) {
|
||||||
|
int left = insets.getSystemWindowInsetLeft();
|
||||||
|
int top = insets.getSystemWindowInsetTop();
|
||||||
|
int right = insets.getSystemWindowInsetRight();
|
||||||
|
int bottom = insets.getSystemWindowInsetBottom();
|
||||||
|
// Visibility and height disagree during IME animation, so neither
|
||||||
|
// can be inferred from the other.
|
||||||
|
int imeBottom = 0;
|
||||||
|
int imeVisible = 0;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||||
|
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
|
||||||
|
}
|
||||||
|
view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package org.linebender.android.rustview;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.view.KeyEvent;
|
||||||
|
import android.view.inputmethod.CompletionInfo;
|
||||||
|
import android.view.inputmethod.CorrectionInfo;
|
||||||
|
import android.view.inputmethod.ExtractedText;
|
||||||
|
import android.view.inputmethod.ExtractedTextRequest;
|
||||||
|
import android.view.inputmethod.InputConnection;
|
||||||
|
import android.view.inputmethod.InputContentInfo;
|
||||||
|
|
||||||
|
class RustInputConnection implements InputConnection {
|
||||||
|
private final RustView mView;
|
||||||
|
|
||||||
|
RustInputConnection(RustView view) {
|
||||||
|
mView = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getViewPeer() {
|
||||||
|
return mView.mViewPeer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharSequence getTextBeforeCursor(int n, int flags) {
|
||||||
|
return mView.getTextBeforeCursorNative(getViewPeer(), n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharSequence getTextAfterCursor(int n, int flags) {
|
||||||
|
return mView.getTextAfterCursorNative(getViewPeer(), n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharSequence getSelectedText(int flags) {
|
||||||
|
return mView.getSelectedTextNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getCursorCapsMode(int reqModes) {
|
||||||
|
return mView.getCursorCapsModeNative(getViewPeer(), reqModes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
|
||||||
|
return mView.deleteSurroundingTextNative(getViewPeer(), beforeLength, afterLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) {
|
||||||
|
return mView.deleteSurroundingTextInCodePointsNative(getViewPeer(), beforeLength, afterLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean setComposingText(CharSequence text, int newCursorPosition) {
|
||||||
|
return mView.setComposingTextNative(getViewPeer(), text.toString(), newCursorPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean setComposingRegion(int start, int end) {
|
||||||
|
return mView.setComposingRegionNative(getViewPeer(), start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean finishComposingText() {
|
||||||
|
return mView.finishComposingTextNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commitText(CharSequence text, int newCursorPosition) {
|
||||||
|
return mView.commitTextNative(getViewPeer(), text.toString(), newCursorPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commitCompletion(CompletionInfo text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commitCorrection(CorrectionInfo correctionInfo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean setSelection(int start, int end) {
|
||||||
|
return mView.setSelectionNative(getViewPeer(), start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performEditorAction(int editorAction) {
|
||||||
|
return mView.performEditorActionNative(getViewPeer(), editorAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performContextMenuAction(int id) {
|
||||||
|
return mView.performContextMenuActionNative(getViewPeer(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean beginBatchEdit() {
|
||||||
|
return mView.beginBatchEditNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean endBatchEdit() {
|
||||||
|
return mView.endBatchEditNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean sendKeyEvent(KeyEvent event) {
|
||||||
|
return mView.inputConnectionSendKeyEventNative(getViewPeer(), event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean clearMetaKeyStates(int states) {
|
||||||
|
return mView.inputConnectionClearMetaKeyStatesNative(getViewPeer(), states);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean reportFullscreenMode(boolean enabled) {
|
||||||
|
return mView.inputConnectionReportFullscreenModeNative(getViewPeer(), enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performPrivateCommand(String action, Bundle data) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean requestCursorUpdates(int cursorUpdateMode) {
|
||||||
|
return mView.requestCursorUpdatesNative(getViewPeer(), cursorUpdateMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Handler getHandler() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void closeConnection() {
|
||||||
|
mView.closeInputConnectionNative(getViewPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commitContent(InputContentInfo inputContentInfo, int flags, Bundle opts) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package org.linebender.android.rustview;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Rect;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.Choreographer;
|
||||||
|
import android.view.KeyEvent;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.SurfaceHolder;
|
||||||
|
import android.view.SurfaceView;
|
||||||
|
import android.view.accessibility.AccessibilityNodeInfo;
|
||||||
|
import android.view.accessibility.AccessibilityNodeProvider;
|
||||||
|
import android.view.inputmethod.EditorInfo;
|
||||||
|
import android.view.inputmethod.InputConnection;
|
||||||
|
import android.view.inputmethod.InputMethodManager;
|
||||||
|
|
||||||
|
public abstract class RustView extends SurfaceView
|
||||||
|
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
|
||||||
|
// Vendored from android-view bec6c62. The only local change is `protected`,
|
||||||
|
// allowing IrisView to forward insets through this native peer.
|
||||||
|
protected final long mViewPeer;
|
||||||
|
final InputMethodManager mInputMethodManager;
|
||||||
|
|
||||||
|
protected abstract long newViewPeer(Context context);
|
||||||
|
|
||||||
|
public RustView(Context context) {
|
||||||
|
super(context);
|
||||||
|
mViewPeer = newViewPeer(context);
|
||||||
|
getHolder().addCallback(this);
|
||||||
|
mInputMethodManager =
|
||||||
|
(InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native int[] onMeasureNative(long peer, int widthSpec, int heightSpec);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onMeasure(int widthSpec, int heightSpec) {
|
||||||
|
int[] result = onMeasureNative(mViewPeer, widthSpec, heightSpec);
|
||||||
|
if (result != null) {
|
||||||
|
setMeasuredDimension(result[0], result[1]);
|
||||||
|
} else {
|
||||||
|
super.onMeasure(widthSpec, heightSpec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onLayoutNative(
|
||||||
|
long peer, boolean changed, int left, int top, int right, int bottom);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
|
||||||
|
onLayoutNative(mViewPeer, changed, left, top, right, bottom);
|
||||||
|
super.onLayout(changed, left, top, right, bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onSizeChangedNative(long peer, int w, int h, int oldw, int oldh);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||||
|
onSizeChangedNative(mViewPeer, w, h, oldw, oldh);
|
||||||
|
super.onSizeChanged(w, h, oldw, oldh);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onKeyDownNative(long peer, int keyCode, KeyEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||||
|
return onKeyDownNative(mViewPeer, keyCode, event) || super.onKeyDown(keyCode, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onKeyUpNative(long peer, int keyCode, KeyEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onKeyUp(int keyCode, KeyEvent event) {
|
||||||
|
return onKeyUpNative(mViewPeer, keyCode, event) || super.onKeyUp(keyCode, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onTrackballEventNative(long peer, MotionEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onTrackballEvent(MotionEvent event) {
|
||||||
|
return onTrackballEventNative(mViewPeer, event) || super.onTrackballEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onTouchEventNative(long peer, MotionEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onTouchEvent(MotionEvent event) {
|
||||||
|
return onTouchEventNative(mViewPeer, event) || super.onTouchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onGenericMotionEventNative(long peer, MotionEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onGenericMotionEvent(MotionEvent event) {
|
||||||
|
return onGenericMotionEventNative(mViewPeer, event) || super.onGenericMotionEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onHoverEventNative(long peer, MotionEvent event);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onHoverEvent(MotionEvent event) {
|
||||||
|
return onHoverEventNative(mViewPeer, event) || super.onHoverEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onFocusChangedNative(
|
||||||
|
long peer, boolean gainFocus, int direction, Rect previouslyFocusedRect);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
|
||||||
|
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
|
||||||
|
onFocusChangedNative(mViewPeer, gainFocus, direction, previouslyFocusedRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onWindowFocusChangedNative(long peer, boolean hasWindowFocus);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onWindowFocusChanged(boolean hasWindowFocus) {
|
||||||
|
super.onWindowFocusChanged(hasWindowFocus);
|
||||||
|
onWindowFocusChangedNative(mViewPeer, hasWindowFocus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onAttachedToWindowNative(long peer);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow();
|
||||||
|
onAttachedToWindowNative(mViewPeer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onDetachedFromWindowNative(long peer);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
onDetachedFromWindowNative(mViewPeer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void onWindowVisibilityChangedNative(long peer, int visibility);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onWindowVisibilityChanged(int visibility) {
|
||||||
|
super.onWindowVisibilityChanged(visibility);
|
||||||
|
onWindowVisibilityChangedNative(mViewPeer, visibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void surfaceCreatedNative(long peer, SurfaceHolder holder);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceCreated(SurfaceHolder holder) {
|
||||||
|
surfaceCreatedNative(mViewPeer, holder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void surfaceChangedNative(
|
||||||
|
long peer, SurfaceHolder holder, int format, int width, int height);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
|
||||||
|
surfaceChangedNative(mViewPeer, holder, format, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void surfaceDestroyedNative(long peer, SurfaceHolder holder);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void surfaceDestroyed(SurfaceHolder holder) {
|
||||||
|
surfaceDestroyedNative(mViewPeer, holder);
|
||||||
|
}
|
||||||
|
|
||||||
|
void postFrameCallback() {
|
||||||
|
Choreographer c = Choreographer.getInstance();
|
||||||
|
c.removeFrameCallback(this);
|
||||||
|
c.postFrameCallback(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeFrameCallback() {
|
||||||
|
Choreographer.getInstance().removeFrameCallback(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void doFrameNative(long peer, long frameTimeNanos);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doFrame(long frameTimeNanos) {
|
||||||
|
doFrameNative(mViewPeer, frameTimeNanos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native void delayedCallbackNative(long peer);
|
||||||
|
|
||||||
|
private final Runnable mDelayedCallback =
|
||||||
|
new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
delayedCallbackNative(mViewPeer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
boolean postDelayed(long delayMillis) {
|
||||||
|
return postDelayed(mDelayedCallback, delayMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean removeDelayedCallbacks() {
|
||||||
|
return removeCallbacks(mDelayedCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean hasAccessibilityNodeProviderNative(long peer);
|
||||||
|
|
||||||
|
private native AccessibilityNodeInfo createAccessibilityNodeInfoNative(
|
||||||
|
long peer, int virtualViewId);
|
||||||
|
|
||||||
|
private native AccessibilityNodeInfo accessibilityFindFocusNative(long peer, int virtualViewId);
|
||||||
|
|
||||||
|
private native boolean performAccessibilityActionNative(
|
||||||
|
long peer, int virtualViewId, int action, Bundle arguments);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccessibilityNodeProvider getAccessibilityNodeProvider() {
|
||||||
|
if (!hasAccessibilityNodeProviderNative(mViewPeer)) {
|
||||||
|
return super.getAccessibilityNodeProvider();
|
||||||
|
}
|
||||||
|
return new AccessibilityNodeProvider() {
|
||||||
|
@Override
|
||||||
|
public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) {
|
||||||
|
return createAccessibilityNodeInfoNative(mViewPeer, virtualViewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccessibilityNodeInfo findFocus(int focusType) {
|
||||||
|
return accessibilityFindFocusNative(mViewPeer, focusType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performAction(int virtualViewId, int action, Bundle arguments) {
|
||||||
|
return performAccessibilityActionNative(
|
||||||
|
mViewPeer, virtualViewId, action, arguments);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private native boolean onCreateInputConnectionNative(long peer, EditorInfo outAttrs);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
|
||||||
|
if (!onCreateInputConnectionNative(mViewPeer, outAttrs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new RustInputConnection(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
native String getTextBeforeCursorNative(long peer, int n);
|
||||||
|
|
||||||
|
native String getTextAfterCursorNative(long peer, int n);
|
||||||
|
|
||||||
|
native String getSelectedTextNative(long peer);
|
||||||
|
|
||||||
|
native int getCursorCapsModeNative(long peer, int reqModes);
|
||||||
|
|
||||||
|
native boolean deleteSurroundingTextNative(long peer, int beforeLength, int afterLength);
|
||||||
|
|
||||||
|
native boolean deleteSurroundingTextInCodePointsNative(
|
||||||
|
long peer, int beforeLength, int afterLength);
|
||||||
|
|
||||||
|
native boolean setComposingTextNative(long peer, String text, int newCursorPosition);
|
||||||
|
|
||||||
|
native boolean setComposingRegionNative(long peer, int start, int end);
|
||||||
|
|
||||||
|
native boolean finishComposingTextNative(long peer);
|
||||||
|
|
||||||
|
native boolean commitTextNative(long peer, String text, int newCursorPosition);
|
||||||
|
|
||||||
|
native boolean setSelectionNative(long peer, int start, int end);
|
||||||
|
|
||||||
|
native boolean performEditorActionNative(long peer, int editorAction);
|
||||||
|
|
||||||
|
native boolean performContextMenuActionNative(long peer, int id);
|
||||||
|
|
||||||
|
native boolean beginBatchEditNative(long peer);
|
||||||
|
|
||||||
|
native boolean endBatchEditNative(long peer);
|
||||||
|
|
||||||
|
native boolean inputConnectionSendKeyEventNative(long peer, KeyEvent event);
|
||||||
|
|
||||||
|
native boolean inputConnectionClearMetaKeyStatesNative(long peer, int states);
|
||||||
|
|
||||||
|
native boolean inputConnectionReportFullscreenModeNative(long peer, boolean enabled);
|
||||||
|
|
||||||
|
native boolean requestCursorUpdatesNative(long peer, int cursorUpdateMode);
|
||||||
|
|
||||||
|
native void closeInputConnectionNative(long peer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "9.4.0" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "iris-android-demo"
|
||||||
|
include(":app")
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
plugins {
|
|
||||||
alias(libs.plugins.androidApplication)
|
|
||||||
alias(libs.plugins.composeMultiplatform)
|
|
||||||
alias(libs.plugins.composeCompiler)
|
|
||||||
alias(libs.plugins.ktfmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Formatting is the formatter's. The one setting is which of ktfmt's two
|
|
||||||
// styles: kotlinlang is the 4-space one, which is what this code already
|
|
||||||
// is -- picking the 2-space default would have reindented every file to
|
|
||||||
// say nothing. Everything else stays at ktfmt's defaults, deliberately.
|
|
||||||
//
|
|
||||||
// ./gradlew :androidApp:ktfmtFormat to apply
|
|
||||||
// ./gradlew :androidApp:ktfmtCheck to verify
|
|
||||||
ktfmt { kotlinLangStyle() }
|
|
||||||
|
|
||||||
// The CA this app pins is baked in at build time from the certificates on
|
|
||||||
// the machine doing the build -- `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`,
|
|
||||||
// which the server generates on first start. AI_APP_CA overrides the path.
|
|
||||||
//
|
|
||||||
// Reading it rather than keeping a pasted copy in the source is what makes
|
|
||||||
// the trust boundary follow the build: an APK built on the backend host
|
|
||||||
// pins the host's CA and never sees any other, while one built in the dev
|
|
||||||
// VM pins that VM's throwaway CA and is only good for its emulator. There
|
|
||||||
// is no second trust anchor to get wrong, and no stale paste to notice
|
|
||||||
// three days later. It also means the private key never has to exist
|
|
||||||
// anywhere near this repo.
|
|
||||||
val pinnedCaPath: String =
|
|
||||||
System.getenv("AI_APP_CA")
|
|
||||||
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
|
|
||||||
"/ai-app/certs/ca.pem"
|
|
||||||
|
|
||||||
abstract class GeneratePinnedCert : DefaultTask() {
|
|
||||||
/** Where the certificate is looked for, reported in failures. */
|
|
||||||
@get:Input abstract val caPath: Property<String>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The certificate itself, set only when it exists -- so a missing one produces this task's own
|
|
||||||
* instructions rather than Gradle's "no such input file", which doesn't say what to run.
|
|
||||||
*/
|
|
||||||
@get:InputFile
|
|
||||||
@get:Optional
|
|
||||||
@get:PathSensitive(PathSensitivity.NONE)
|
|
||||||
abstract val caCertificate: RegularFileProperty
|
|
||||||
|
|
||||||
/** Wired by AGP through `addGeneratedSourceDirectory`. */
|
|
||||||
@get:OutputDirectory abstract val outputDir: DirectoryProperty
|
|
||||||
|
|
||||||
@TaskAction
|
|
||||||
fun generate() {
|
|
||||||
val path = caPath.get()
|
|
||||||
val ca = File(path)
|
|
||||||
if (!ca.isFile) {
|
|
||||||
throw GradleException(
|
|
||||||
"No CA certificate at $path.\n" +
|
|
||||||
"Start ai-server once on this machine first -- it generates the CA the " +
|
|
||||||
"app pins, and the certificate has to exist before an APK can embed it.\n" +
|
|
||||||
"Set AI_APP_CA=/path/to/ca.pem to build against a different one."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val pem = ca.readText().trim()
|
|
||||||
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
|
|
||||||
throw GradleException("$path is not a PEM certificate.")
|
|
||||||
}
|
|
||||||
val file = outputDir.get().file("PinnedCaCertificate.kt").asFile
|
|
||||||
file.parentFile.mkdirs()
|
|
||||||
// The PEM must start immediately after the opening quotes: a
|
|
||||||
// leading newline makes Android's CertificateFactory stop
|
|
||||||
// recognising the "-----BEGIN" preamble and try to parse the whole
|
|
||||||
// thing as DER, which fails with an ASN.1 decode error at runtime
|
|
||||||
// rather than anywhere near this file.
|
|
||||||
file.writeText(
|
|
||||||
"""
|
|
||||||
|// Generated from $path by the generatePinnedCert task. Do not edit.
|
|
||||||
|package com.example.aiapp
|
|
||||||
|
|
|
||||||
|const val PINNED_CA_PEM = ""${'"'}$pem
|
|
||||||
|""${'"'}
|
|
||||||
|
|
|
||||||
"""
|
|
||||||
.trimMargin()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val generatePinnedCert =
|
|
||||||
tasks.register<GeneratePinnedCert>("generatePinnedCert") {
|
|
||||||
val ca = file(pinnedCaPath)
|
|
||||||
caPath.set(pinnedCaPath)
|
|
||||||
if (ca.isFile) {
|
|
||||||
caCertificate.set(ca)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "com.example.aiapp"
|
|
||||||
compileSdk = 37
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "com.example.aiapp"
|
|
||||||
minSdk = 24
|
|
||||||
targetSdk = 37
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "1.0"
|
|
||||||
}
|
|
||||||
packaging {
|
|
||||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
|
||||||
// The one native library here is AndroidX's, a few hundred kilobytes with its symbols.
|
|
||||||
// Stripping them needs an NDK the release build would otherwise not use; keeping them
|
|
||||||
// is declared so AGP stops warning that it could not.
|
|
||||||
jniLibs { keepDebugSymbols += "**/libandroidx.graphics.path.so" }
|
|
||||||
}
|
|
||||||
// A release build must be signed, and the key is per machine rather than per repo: it is
|
|
||||||
// what the phone recognises the app by, and a secret never lives in a checkout (the mount is
|
|
||||||
// shared with an untrusted VM). build-apk.sh keeps it beside the pinned CA and points here
|
|
||||||
// through the environment; without it the release build is unsigned, which is fine for
|
|
||||||
// everything except installing.
|
|
||||||
val keystore = System.getenv("AI_APP_KEYSTORE")
|
|
||||||
signingConfigs {
|
|
||||||
if (keystore != null) {
|
|
||||||
create("release") {
|
|
||||||
storeFile = file(keystore)
|
|
||||||
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
|
|
||||||
keyAlias = "ai-app"
|
|
||||||
keyPassword = storePassword
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
getByName("release") {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
|
||||||
targetCompatibility = JavaVersion.VERSION_21
|
|
||||||
// minSdk is 24 and UsageScreen formats its countdown with
|
|
||||||
// java.time, which the platform only has from 26. Without this it
|
|
||||||
// is a NoClassDefFoundError on 24 and 25 -- an Error, so the
|
|
||||||
// catch around that code does not stop it.
|
|
||||||
isCoreLibraryDesugaringEnabled = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AGP 9 wants generated sources registered through the variant API rather
|
|
||||||
// than added to a source set, so the task dependency is carried properly.
|
|
||||||
androidComponents {
|
|
||||||
onVariants { variant ->
|
|
||||||
variant.sources.java?.addGeneratedSourceDirectory(
|
|
||||||
generatePinnedCert,
|
|
||||||
GeneratePinnedCert::outputDir,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
// The link both this app and Dev Updater's need in order to reach a
|
|
||||||
// machine they were enrolled against: the pinned CA, the enrollment
|
|
||||||
// store, and the QR capture activity. See wg-app-link's README.
|
|
||||||
implementation(project(":link"))
|
|
||||||
// Not a library this code calls: it is what `isCoreLibraryDesugaring
|
|
||||||
// Enabled` above rewrites java.time against, so API 24 and 25 have it.
|
|
||||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
|
||||||
|
|
||||||
implementation(libs.compose.runtime)
|
|
||||||
implementation(libs.compose.runtime.tracing)
|
|
||||||
implementation(libs.compose.foundation)
|
|
||||||
implementation(libs.compose.material3)
|
|
||||||
implementation(libs.compose.ui)
|
|
||||||
implementation(libs.androidx.activity.compose)
|
|
||||||
implementation(libs.androidx.core.ktx)
|
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
|
||||||
implementation(libs.zxing.embedded)
|
|
||||||
implementation(libs.markdown.renderer)
|
|
||||||
implementation(libs.androidx.exifinterface)
|
|
||||||
|
|
||||||
// The syntax scanner (Highlighter.kt) is pure logic with no Android imports, which is what
|
|
||||||
// lets it be tested on the JVM: `./gradlew :androidApp:testDebugUnitTest`. The assertions are
|
|
||||||
// `kotlin.test`, so the tests name no framework; JUnit is what runs them.
|
|
||||||
testImplementation(libs.kotlin.test.junit5)
|
|
||||||
testImplementation(libs.junit.jupiter)
|
|
||||||
testRuntimeOnly(libs.junit.platform.launcher)
|
|
||||||
}
|
|
||||||
|
|
||||||
// JUnit 6 runs on the Platform, which is not Gradle's default for a Test task.
|
|
||||||
tasks.withType<Test>().configureEach { useJUnitPlatform() }
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<!-- Android 17 (API 37) made Local Network Protection mandatory: an app
|
|
||||||
targeting 37+ needs this runtime permission to reach *any* local
|
|
||||||
network address, including a plain socket to a LAN IP literal.
|
|
||||||
Without it the traffic is silently dropped, surfacing only as a
|
|
||||||
connect timeout. See MainActivity.kt's runtime request, and
|
|
||||||
dev-updater's manifest for the full story. -->
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
|
||||||
|
|
||||||
<!-- Telling somebody a session wants them. POST_NOTIFICATIONS is a
|
|
||||||
runtime permission from Android 13; the foreground-service pair
|
|
||||||
below is what lets the connection outlive the app being closed,
|
|
||||||
which is the entire point (see Notifications.kt). -->
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
|
||||||
|
|
||||||
<!-- tools:ignore MissingApplicationIcon: there is no icon yet, and
|
|
||||||
that is a decision rather than an oversight. An app with no icon
|
|
||||||
of its own is obvious to anyone who opens a launcher, so the
|
|
||||||
warning tells nobody here anything they cannot already see, and
|
|
||||||
the fix is a judgement about how this app should look. Drop this
|
|
||||||
suppression when a real icon lands. -->
|
|
||||||
<application
|
|
||||||
android:label="AI Sessions"
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
|
||||||
tools:ignore="MissingApplicationIcon">
|
|
||||||
<!-- Lets the phone's own System Tracing see this app's trace sections
|
|
||||||
(Compose's phases, and the composable names runtime-tracing
|
|
||||||
adds) in a release build, so a frame cost measured on the real
|
|
||||||
device can be attributed. shell="true" limits it to profilers
|
|
||||||
run from the shell; it grants nothing to other apps. -->
|
|
||||||
<profileable android:shell="true" tools:targetApi="q" />
|
|
||||||
<!-- adjustResize (not the system's default pan): the layout handles
|
|
||||||
the keyboard itself via imePadding(), so the window must resize
|
|
||||||
rather than slide the top bar off screen.
|
|
||||||
|
|
||||||
stateUnchanged: coming back to the app leaves the keyboard as it
|
|
||||||
was left. The default, stateUnspecified, lets the system decide,
|
|
||||||
and what it decides with a focused message field is to open the
|
|
||||||
keyboard, so switching away and back covered half the transcript
|
|
||||||
somebody had switched away to compare against. Unchanged rather
|
|
||||||
than hidden, because a keyboard that was up when the app was left
|
|
||||||
is one somebody was in the middle of typing into. -->
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:launchMode="singleTop"
|
|
||||||
android:windowSoftInputMode="adjustResize|stateUnchanged"
|
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
<!-- Enrollment: the server prints its aiapp://enroll QR to the
|
|
||||||
terminal. This intent filter is the fallback path for a
|
|
||||||
camera app that redirects a scanned aiapp:// URI here
|
|
||||||
directly; the Settings screen's own "Scan QR code" button
|
|
||||||
(zxing-android-embedded) is the primary path and needs no
|
|
||||||
filter, since it decodes the QR itself and hands the URI
|
|
||||||
to parseEnrollmentUri in-process. -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:scheme="aiapp" android:host="enroll" />
|
|
||||||
</intent-filter>
|
|
||||||
<!-- The share sheet: a file, a photo or some text from another app
|
|
||||||
lands here and is attached to a session (see Share.kt). Any
|
|
||||||
type, because what a session can be handed is the server's
|
|
||||||
decision rather than the sheet's. -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.SEND" />
|
|
||||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:mimeType="*/*" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<!-- specialUse rather than dataSync, which is the type this looks
|
|
||||||
like: Android 15 caps dataSync at six hours a day, and a
|
|
||||||
connection that stops listening after six hours is one that
|
|
||||||
misses the overnight run it exists for. The subtype below is
|
|
||||||
the reason string that type requires. -->
|
|
||||||
<service
|
|
||||||
android:name=".NotificationService"
|
|
||||||
android:exported="false"
|
|
||||||
android:foregroundServiceType="specialUse">
|
|
||||||
<property
|
|
||||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
|
||||||
android:value="Holds one connection to the user's own backend so a session
|
|
||||||
that needs an answer can be reported while the app is closed. There is no
|
|
||||||
push service: the backend is reachable only over the user's WireGuard
|
|
||||||
tunnel and never talks to a third party." />
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<!-- The scanner behind Settings' "Scan QR code". Declared here so
|
|
||||||
it can drop the library CaptureActivity's landscape pin: the
|
|
||||||
code being scanned is usually on a monitor in front of someone
|
|
||||||
holding the phone upright. zxing_CaptureTheme is the library's
|
|
||||||
own fullscreen theme, which is all the activity needs. -->
|
|
||||||
<!-- tools:ignore DiscouragedApi: lint flags every fixed
|
|
||||||
screenOrientation, because Android 16 ignores most of them.
|
|
||||||
This one is not a pin but its removal. fullSensor is what
|
|
||||||
drops the library's landscape lock, so the activity follows
|
|
||||||
the phone rather than asking anyone to turn it, and where the
|
|
||||||
platform ignores the attribute the behaviour is the one this
|
|
||||||
asked for anyway. Scoped to this activity, so a genuine pin
|
|
||||||
elsewhere would still be reported. -->
|
|
||||||
<activity
|
|
||||||
android:name="com.example.wgapplink.EnrollmentScanActivity"
|
|
||||||
android:clearTaskOnLaunch="true"
|
|
||||||
android:screenOrientation="fullSensor"
|
|
||||||
android:stateNotNeeded="true"
|
|
||||||
android:theme="@style/zxing_CaptureTheme"
|
|
||||||
android:windowSoftInputMode="stateAlwaysHidden"
|
|
||||||
tools:ignore="DiscouragedApi" />
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The sixteen colours a terminal program names, and the two it assumes.
|
|
||||||
*
|
|
||||||
* Its own palette rather than the syntax one: a program that prints in red has chosen red, where a
|
|
||||||
* highlighter's colours are this app's reading of somebody else's code. They come out of the same
|
|
||||||
* Catppuccin values so nothing on screen is a colour from somewhere else, but the two are not one
|
|
||||||
* table -- adding a syntax role to this list would silently move `ls`'s directory blue.
|
|
||||||
*/
|
|
||||||
data class AnsiPalette(
|
|
||||||
/** Indexes 0-7, then 8-15 bright, in the terminal's own order. */
|
|
||||||
val colours: List<Color>,
|
|
||||||
/** What uncoloured text is, needed only where a style has to state a colour. */
|
|
||||||
val foreground: Color,
|
|
||||||
/** What the text sits on, needed for reverse video. */
|
|
||||||
val background: Color,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a tool printed, with its terminal styling applied and everything else taken out.
|
|
||||||
*
|
|
||||||
* Bash output arrives exactly as the program wrote it, escape sequences included, and drawn
|
|
||||||
* verbatim those are line noise in the middle of the thing being read. Stripping them all would be
|
|
||||||
* the other half-answer -- colour is often the whole of what a diff or a test run is saying.
|
|
||||||
*
|
|
||||||
* So the sequences that decide how text *looks* become spans, and every other one is dropped rather
|
|
||||||
* than shown: the rest move a cursor around a grid this is not, and "go to column 40" has no
|
|
||||||
* meaning in a scrolling document.
|
|
||||||
*
|
|
||||||
* A carriage return is honoured the way a terminal honours it: what was written since the last line
|
|
||||||
* break is thrown away and the line starts again. That is what makes a progress bar show its final
|
|
||||||
* state rather than every state it passed through.
|
|
||||||
*
|
|
||||||
* Not a composable, and the palette is a parameter, so this can be remembered against the text it
|
|
||||||
* parsed rather than re-run on every recomposition of the card holding it.
|
|
||||||
*/
|
|
||||||
fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
|
|
||||||
// The common case by a long way -- nothing to do, and nothing allocated to find that out.
|
|
||||||
if (text.indexOf(ESC) < 0 && text.indexOf('\r') < 0) return AnnotatedString(text)
|
|
||||||
|
|
||||||
val runs = mutableListOf<Run>()
|
|
||||||
var sgr = Sgr.PLAIN
|
|
||||||
var at = 0
|
|
||||||
val plain = StringBuilder()
|
|
||||||
|
|
||||||
fun flush() {
|
|
||||||
if (plain.isNotEmpty()) {
|
|
||||||
runs.add(Run(plain.toString(), sgr.span(palette)))
|
|
||||||
plain.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (at < text.length) {
|
|
||||||
val c = text[at]
|
|
||||||
when {
|
|
||||||
c == ESC -> {
|
|
||||||
flush()
|
|
||||||
at =
|
|
||||||
skipEscape(text, at) { params, final ->
|
|
||||||
if (final == 'm') sgr = sgr.apply(params, palette)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A bare carriage return rewrites the line. One before a newline is the other half of a
|
|
||||||
// Windows line ending: it rewrites nothing, and it is dropped rather than kept, since
|
|
||||||
// that pair is one line break.
|
|
||||||
c == '\r' && text.getOrNull(at + 1) != '\n' -> {
|
|
||||||
flush()
|
|
||||||
dropLine(runs)
|
|
||||||
at++
|
|
||||||
}
|
|
||||||
c == '\r' -> at++
|
|
||||||
// Everything printable, plus the two control characters that are layout rather than
|
|
||||||
// terminal commands. A stray bell or backspace goes for the same reason a cursor move
|
|
||||||
// does.
|
|
||||||
c >= ' ' || c == '\n' || c == '\t' -> {
|
|
||||||
plain.append(c)
|
|
||||||
at++
|
|
||||||
}
|
|
||||||
else -> at++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flush()
|
|
||||||
|
|
||||||
return buildAnnotatedString {
|
|
||||||
runs.forEach { run ->
|
|
||||||
if (run.style == null) {
|
|
||||||
append(run.text)
|
|
||||||
} else {
|
|
||||||
val pushed = pushStyle(run.style)
|
|
||||||
append(run.text)
|
|
||||||
pop(pushed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One stretch of text that shares a style. */
|
|
||||||
private class Run(val text: String, val style: SpanStyle?)
|
|
||||||
|
|
||||||
/** Throws away everything written since the last line break, as a carriage return does. */
|
|
||||||
private fun dropLine(runs: MutableList<Run>) {
|
|
||||||
while (runs.isNotEmpty()) {
|
|
||||||
val last = runs.removeAt(runs.size - 1)
|
|
||||||
val breakAt = last.text.lastIndexOf('\n')
|
|
||||||
if (breakAt >= 0) {
|
|
||||||
runs.add(Run(last.text.substring(0, breakAt + 1), last.style))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val ESC = '\u001B'
|
|
||||||
|
|
||||||
private const val BELL = '\u0007'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte.
|
|
||||||
*
|
|
||||||
* One reader for every kind, because the point is to *leave* them all behind: a sequence this did
|
|
||||||
* not recognise would otherwise have its body printed as ordinary text. Three shapes -- the CSI
|
|
||||||
* (`ESC [ … letter`), the string escapes which run to a terminator, and the two-character ones.
|
|
||||||
*/
|
|
||||||
private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int {
|
|
||||||
val next = text.getOrNull(at + 1) ?: return at + 1
|
|
||||||
return when (next) {
|
|
||||||
'[' -> {
|
|
||||||
var end = at + 2
|
|
||||||
while (end < text.length && text[end] !in CSI_FINAL) end++
|
|
||||||
if (end >= text.length) {
|
|
||||||
// Cut off mid-sequence, which is what a stream that has not finished arriving looks
|
|
||||||
// like: drop the fragment rather than printing it, and the whole sequence arrives
|
|
||||||
// with the next delta.
|
|
||||||
text.length
|
|
||||||
} else {
|
|
||||||
onCsi(text.substring(at + 2, end), text[end])
|
|
||||||
end + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
']',
|
|
||||||
'P',
|
|
||||||
'X',
|
|
||||||
'^',
|
|
||||||
'_' -> {
|
|
||||||
// Runs to a string terminator: `ESC \`, or the bell that xterm allows after an OSC.
|
|
||||||
var end = at + 2
|
|
||||||
while (end < text.length) {
|
|
||||||
if (text[end] == BELL) return end + 1
|
|
||||||
if (text[end] == ESC && text.getOrNull(end + 1) == '\\') return end + 2
|
|
||||||
end++
|
|
||||||
}
|
|
||||||
text.length
|
|
||||||
}
|
|
||||||
else -> at + 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The bytes that end a CSI sequence. */
|
|
||||||
private val CSI_FINAL = '@'..'~'
|
|
||||||
|
|
||||||
/** Everything an SGR sequence can turn on, as the terminal tracks it. */
|
|
||||||
private data class Sgr(
|
|
||||||
val fg: Color?,
|
|
||||||
val bg: Color?,
|
|
||||||
val bold: Boolean,
|
|
||||||
val dim: Boolean,
|
|
||||||
val italic: Boolean,
|
|
||||||
val underline: Boolean,
|
|
||||||
val strike: Boolean,
|
|
||||||
val reverse: Boolean,
|
|
||||||
) {
|
|
||||||
/** Null while nothing is set, so unstyled output costs no spans at all. */
|
|
||||||
fun span(palette: AnsiPalette): SpanStyle? {
|
|
||||||
if (this == PLAIN) return null
|
|
||||||
val front = if (reverse) bg ?: palette.background else fg
|
|
||||||
val back = if (reverse) fg ?: palette.foreground else bg
|
|
||||||
// Dim has to have a colour to dim, so where none was named it dims the ordinary one.
|
|
||||||
val stated = front ?: palette.foreground.takeIf { dim }
|
|
||||||
return SpanStyle(
|
|
||||||
color =
|
|
||||||
stated?.let { if (dim) it.copy(alpha = DIM_ALPHA) else it } ?: Color.Unspecified,
|
|
||||||
background = back ?: Color.Unspecified,
|
|
||||||
fontWeight = if (bold) FontWeight.Bold else null,
|
|
||||||
fontStyle = if (italic) FontStyle.Italic else null,
|
|
||||||
textDecoration =
|
|
||||||
when {
|
|
||||||
underline && strike ->
|
|
||||||
TextDecoration.combine(
|
|
||||||
listOf(TextDecoration.Underline, TextDecoration.LineThrough)
|
|
||||||
)
|
|
||||||
underline -> TextDecoration.Underline
|
|
||||||
strike -> TextDecoration.LineThrough
|
|
||||||
else -> null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This state with [params] applied -- one `ESC[…m`, which carries any number of them.
|
|
||||||
*
|
|
||||||
* A code this does not model is ignored rather than reset from: the program meant something by
|
|
||||||
* it, and starting again would also drop the codes beside it that are understood.
|
|
||||||
*/
|
|
||||||
fun apply(params: String, palette: AnsiPalette): Sgr {
|
|
||||||
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a zero too.
|
|
||||||
val codes = params.split(';').map { it.trim().toIntOrNull() ?: 0 }
|
|
||||||
var state = this
|
|
||||||
var at = 0
|
|
||||||
while (at < codes.size) {
|
|
||||||
val code = codes[at]
|
|
||||||
state =
|
|
||||||
when (code) {
|
|
||||||
0 -> PLAIN
|
|
||||||
1 -> state.copy(bold = true)
|
|
||||||
2 -> state.copy(dim = true)
|
|
||||||
3 -> state.copy(italic = true)
|
|
||||||
4 -> state.copy(underline = true)
|
|
||||||
7 -> state.copy(reverse = true)
|
|
||||||
9 -> state.copy(strike = true)
|
|
||||||
21,
|
|
||||||
22 -> state.copy(bold = false, dim = false)
|
|
||||||
23 -> state.copy(italic = false)
|
|
||||||
24 -> state.copy(underline = false)
|
|
||||||
27 -> state.copy(reverse = false)
|
|
||||||
29 -> state.copy(strike = false)
|
|
||||||
in 30..37 -> state.copy(fg = palette.colours[code - 30])
|
|
||||||
in 90..97 -> state.copy(fg = palette.colours[code - 90 + 8])
|
|
||||||
in 40..47 -> state.copy(bg = palette.colours[code - 40])
|
|
||||||
in 100..107 -> state.copy(bg = palette.colours[code - 100 + 8])
|
|
||||||
39 -> state.copy(fg = null)
|
|
||||||
49 -> state.copy(bg = null)
|
|
||||||
38,
|
|
||||||
48 -> {
|
|
||||||
val (colour, last) = extendedColour(codes, at, palette)
|
|
||||||
at = last
|
|
||||||
if (code == 38) state.copy(fg = colour) else state.copy(bg = colour)
|
|
||||||
}
|
|
||||||
else -> state
|
|
||||||
}
|
|
||||||
at++
|
|
||||||
}
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val PLAIN =
|
|
||||||
Sgr(
|
|
||||||
fg = null,
|
|
||||||
bg = null,
|
|
||||||
bold = false,
|
|
||||||
dim = false,
|
|
||||||
italic = false,
|
|
||||||
underline = false,
|
|
||||||
strike = false,
|
|
||||||
reverse = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How much of its colour dim text keeps: enough to read, little enough to recede. */
|
|
||||||
private const val DIM_ALPHA = 0.65f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The colour named by a `38`/`48` at [at], and the index of that colour's last parameter.
|
|
||||||
*
|
|
||||||
* Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal one. The first sixteen of
|
|
||||||
* that table are the palette's own, so a program asking for "colour 1" through either spelling gets
|
|
||||||
* the same red.
|
|
||||||
*/
|
|
||||||
private fun extendedColour(codes: List<Int>, at: Int, palette: AnsiPalette): Pair<Color?, Int> =
|
|
||||||
when (codes.getOrNull(at + 1)) {
|
|
||||||
5 -> {
|
|
||||||
val n = codes.getOrNull(at + 2)
|
|
||||||
if (n == null) null to at + 1 else indexedColour(n, palette) to at + 2
|
|
||||||
}
|
|
||||||
2 -> {
|
|
||||||
val r = codes.getOrNull(at + 2)
|
|
||||||
val g = codes.getOrNull(at + 3)
|
|
||||||
val b = codes.getOrNull(at + 4)
|
|
||||||
if (r == null || g == null || b == null) null to at + 1
|
|
||||||
else Color(r.coerceIn(0, 255), g.coerceIn(0, 255), b.coerceIn(0, 255)) to at + 4
|
|
||||||
}
|
|
||||||
else -> null to at + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a grey ramp. */
|
|
||||||
private fun indexedColour(n: Int, palette: AnsiPalette): Color =
|
|
||||||
when {
|
|
||||||
n < 0 -> palette.foreground
|
|
||||||
n < 16 -> palette.colours[n]
|
|
||||||
n < 232 -> {
|
|
||||||
val i = n - 16
|
|
||||||
Color(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
|
|
||||||
}
|
|
||||||
n < 256 -> {
|
|
||||||
val grey = 8 + (n - 232) * 10
|
|
||||||
Color(grey, grey, grey)
|
|
||||||
}
|
|
||||||
else -> palette.foreground
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The six levels of each channel in the 256-colour cube, as xterm defines them. */
|
|
||||||
private val CUBE = intArrayOf(0, 95, 135, 175, 215, 255)
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,365 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.key
|
|
||||||
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.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.example.wgapplink.localNetworkAllowed
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
|
|
||||||
* and the back button the only other way between them.
|
|
||||||
*
|
|
||||||
* Import, models and machines are tabs inside [MainScreen] -- four views of the same backend, none
|
|
||||||
* of them a step down from another -- and what is left here is only what genuinely is a step down:
|
|
||||||
* one session, spawning one, and settings.
|
|
||||||
*/
|
|
||||||
private sealed class Screen {
|
|
||||||
data object Main : Screen()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One session, with the file explorer or a subagent transcript over it when set.
|
|
||||||
*
|
|
||||||
* Both are layers on this screen rather than screens of their own, so the session under them
|
|
||||||
* stays composed: its event stream keeps flowing, its scroll position and draft stay put, and
|
|
||||||
* coming back costs nothing. As sibling `Screen`s they would dispose and recreate it on every
|
|
||||||
* return, refetching the transcript over the tunnel.
|
|
||||||
*/
|
|
||||||
data class Session(
|
|
||||||
val summary: SessionSummary,
|
|
||||||
val files: FilesTarget? = null,
|
|
||||||
val subagent: SubagentSummary? = null,
|
|
||||||
) : Screen()
|
|
||||||
|
|
||||||
data object Spawn : Screen()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One provider on one machine: its settings, and what its shared server is holding.
|
|
||||||
*
|
|
||||||
* A step down from the machines tab rather than a tab of its own, because it is about one
|
|
||||||
* machine rather than about the backend. Addressed by ids and names rather than by the
|
|
||||||
* [Provider] it was tapped from: what it shows is fetched, and a stale copy of a card would be
|
|
||||||
* a second version of the same truth.
|
|
||||||
*/
|
|
||||||
data class ProviderSettings(val machineId: String, val provider: String) : Screen()
|
|
||||||
|
|
||||||
data object Settings : Screen()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A session a notification tap asked to open, before it is a screen.
|
|
||||||
*
|
|
||||||
* The notification names an id and nothing else, so opening it means fetching the session first.
|
|
||||||
* [serial] tells two taps on the same session's notification apart, since they are two requests and
|
|
||||||
* would otherwise compare equal.
|
|
||||||
*/
|
|
||||||
data class SessionOpenRequest(val sessionId: String, val serial: Int)
|
|
||||||
|
|
||||||
/** A tap that could not be turned into a screen, kept with its request so Try again knows what. */
|
|
||||||
private data class FailedOpen(val request: SessionOpenRequest, val message: String)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), re-
|
|
||||||
* reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
|
|
||||||
*
|
|
||||||
* [openRequest] is the session a notification tap asked for, likewise from MainActivity.
|
|
||||||
*
|
|
||||||
* [shareRequest] is what another app shared in, likewise. It is held here until a session takes it,
|
|
||||||
* because the share arrives before anyone has said which session it is for.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun AppRoot(
|
|
||||||
settingsVersion: Int,
|
|
||||||
openRequest: SessionOpenRequest?,
|
|
||||||
shareRequest: ShareRequest? = null,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
|
||||||
var screen by remember { mutableStateOf<Screen>(Screen.Main) }
|
|
||||||
// A notification tap this could not follow, and why. Null both before one is asked for and
|
|
||||||
// after one succeeds, since success is a screen rather than a message.
|
|
||||||
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
|
||||||
// Bumped whenever another screen changes something the list shows, so returning to it
|
|
||||||
// refetches.
|
|
||||||
var reloadToken by remember { mutableIntStateOf(0) }
|
|
||||||
// Cleared by the session screen that attached it, not when a newer request arrives: a share
|
|
||||||
// must be attached exactly once, and only the screen that did it knows that it has.
|
|
||||||
var share by remember { mutableStateOf<ShareRequest?>(null) }
|
|
||||||
LaunchedEffect(shareRequest) {
|
|
||||||
if (shareRequest != null) {
|
|
||||||
share = shareRequest
|
|
||||||
// A session already open takes it. Otherwise the list is where the choice is made,
|
|
||||||
// whatever screen was showing: Spawn and Settings have nowhere to put a file.
|
|
||||||
if (screen !is Screen.Session) screen = Screen.Main
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A standing condition rather than a per-request failure, so it is stated once here instead of
|
|
||||||
// appended to every error it might cause. Without this the app is simply unreachable and every
|
|
||||||
// screen blames the server or the tunnel for it.
|
|
||||||
if (!localNetworkAllowed(context)) {
|
|
||||||
Text(
|
|
||||||
"This app is not allowed to reach local network addresses, so it cannot " +
|
|
||||||
"connect to the backend at all. Grant \"local network\" in Android's app " +
|
|
||||||
"settings; until then every screen here will look like the server is down.",
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val current = settings
|
|
||||||
if (current == null) {
|
|
||||||
// Not enrolled yet: settings is the only usable screen. The QR path lands in MainActivity
|
|
||||||
// and recomposes from the top.
|
|
||||||
Box(Modifier.imePadding()) {
|
|
||||||
SettingsScreen(
|
|
||||||
existing = null,
|
|
||||||
onSaved = { saved ->
|
|
||||||
settings = saved
|
|
||||||
screen = Screen.Main
|
|
||||||
},
|
|
||||||
onBack = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The one way back, whichever screen is showing and whether it was reached by the system back
|
|
||||||
// gesture or a screen's own Back button. Every leaf screen can have changed something the list
|
|
||||||
// shows, so it always refetches.
|
|
||||||
val goToMain = {
|
|
||||||
reloadToken++
|
|
||||||
screen = Screen.Main
|
|
||||||
}
|
|
||||||
if (screen !is Screen.Main) {
|
|
||||||
BackHandler(onBack = goToMain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Turning a notification into the screen it points at. The id has to be resolved to a session
|
|
||||||
// first, because that is what SessionScreen is given -- and unlike a list row, there is nothing
|
|
||||||
// here to seed it from.
|
|
||||||
//
|
|
||||||
// A failure is reported rather than swallowed: somebody deliberately tapped a notification, so
|
|
||||||
// an app that opens to the session list with no explanation looks like the tap missed.
|
|
||||||
val open: suspend (SessionOpenRequest) -> Unit = { request ->
|
|
||||||
failedOpen = null
|
|
||||||
try {
|
|
||||||
val session = withContext(Dispatchers.IO) { fetchSession(current, request.sessionId) }
|
|
||||||
screen = Screen.Session(session)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
failedOpen = FailedOpen(request, e.message ?: "Unknown error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(openRequest) { openRequest?.let { open(it) } }
|
|
||||||
|
|
||||||
val failed = failedOpen
|
|
||||||
if (failed != null) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { failedOpen = null },
|
|
||||||
title = { Text("Couldn't open that session") },
|
|
||||||
text = { Text(failed.message) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { scope.launch { open(failed.request) } }) {
|
|
||||||
Text("Try again")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = { TextButton(onClick = { failedOpen = null }) { Text("Cancel") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every screen but the session takes the keyboard as bottom padding here. The session screen
|
|
||||||
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is
|
|
||||||
// the cost that made it lag, so it moves only its composer and transcript.
|
|
||||||
when (val here = screen) {
|
|
||||||
Screen.Main ->
|
|
||||||
Box(Modifier.imePadding()) {
|
|
||||||
MainScreen(
|
|
||||||
settings = current,
|
|
||||||
reloadToken = reloadToken,
|
|
||||||
share = share,
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is Screen.Session ->
|
|
||||||
// Keyed on the id, because a different session is a different screen rather than this
|
|
||||||
// one showing other rows. SessionScreen remembers a transcript, an open stream, a draft
|
|
||||||
// and a scroll position, and without the key Compose keeps all of it across the change
|
|
||||||
// and merges two conversations -- which crashes the list on the first duplicate row
|
|
||||||
// key. Only reachable since a notification can move straight from one session to
|
|
||||||
// another.
|
|
||||||
key(here.summary.id) {
|
|
||||||
// A Box so the explorer can be drawn *over* the session rather than instead of it.
|
|
||||||
// No imePadding here, for the reason above -- the explorer adds its own.
|
|
||||||
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(
|
|
||||||
settings = current,
|
|
||||||
summary = here.summary,
|
|
||||||
onBack = goToMain,
|
|
||||||
onFiles = { screen = here.copy(files = it) },
|
|
||||||
share = share,
|
|
||||||
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
|
|
||||||
// platform asks first, and it steps back inside itself before closing.
|
|
||||||
here.files?.let { target ->
|
|
||||||
FilesScreen(
|
|
||||||
settings = current,
|
|
||||||
target = target,
|
|
||||||
onClose = { screen = here.copy(files = null) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is Screen.ProviderSettings ->
|
|
||||||
Box(Modifier.imePadding()) {
|
|
||||||
ProviderScreen(
|
|
||||||
settings = current,
|
|
||||||
machineId = here.machineId,
|
|
||||||
provider = here.provider,
|
|
||||||
onBack = goToMain,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is Screen.Spawn ->
|
|
||||||
Box(Modifier.imePadding()) {
|
|
||||||
SpawnScreen(
|
|
||||||
settings = current,
|
|
||||||
onSpawned = { spawned ->
|
|
||||||
reloadToken++
|
|
||||||
screen = Screen.Session(spawned)
|
|
||||||
},
|
|
||||||
onBack = goToMain,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is Screen.Settings ->
|
|
||||||
Box(Modifier.imePadding()) {
|
|
||||||
SettingsScreen(
|
|
||||||
existing = current,
|
|
||||||
onSaved = { saved ->
|
|
||||||
settings = saved
|
|
||||||
goToMain()
|
|
||||||
},
|
|
||||||
onBack = goToMain,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last, so it draws over the screen above rather than under it: these are stacked in the Box
|
|
||||||
// the activity puts around this, and that Box paints in the order it was given. A session
|
|
||||||
// wanting attention is not a fact about the page somebody happens to be on. Tapping one is the
|
|
||||||
// same act as tapping a notification, so it goes through the same `open`.
|
|
||||||
SessionAlerts(onOpen = { request -> scope.launch { open(request) } })
|
|
||||||
}
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
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.foundation.rememberScrollState
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.LocalContentColor
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedCard
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/** One question's answer on its way back, so a card can hand over several at once. */
|
|
||||||
data class QuestionAnswer(val questionId: String, val answers: List<String>)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the reader has settled on for one question, before any of it is sent.
|
|
||||||
*
|
|
||||||
* Held here rather than inferred from the transcript, which is what made picking an option feel
|
|
||||||
* broken: the mark used to appear only when the answer had crossed the tunnel and come back as an
|
|
||||||
* event, so the card sat unchanged for most of a second after a tap.
|
|
||||||
*
|
|
||||||
* Picked options and typed words are one field each because they are alternatives rather than
|
|
||||||
* parts: typing puts the picks away and picking puts the words away, so there is never a draft that
|
|
||||||
* means two things.
|
|
||||||
*/
|
|
||||||
data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
|
|
||||||
val settled: Boolean
|
|
||||||
get() = picked.isNotEmpty() || other.isNotBlank()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What goes back, in the order the options were offered rather than the order they were tapped:
|
|
||||||
* the reader is answering a list, and it should read back as that list.
|
|
||||||
*/
|
|
||||||
fun answers(options: List<QuestionOption>): List<String> =
|
|
||||||
if (other.isNotBlank()) listOf(other.trim())
|
|
||||||
else options.map { it.label }.filter { it in picked }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every question one tool call is waiting on, one at a time.
|
|
||||||
*
|
|
||||||
* All of it comes from the question events themselves. None of it is read out of the call's own
|
|
||||||
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
|
|
||||||
* app, where no other provider can reach it and where it drifts the first time the schema moves.
|
|
||||||
*
|
|
||||||
* One question on screen with arrows to the others, rather than all of them stacked. A card asking
|
|
||||||
* three questions with four options and a description each is several screens tall, so the reader
|
|
||||||
* scrolls past the question they are answering to reach the button that sends it. Paged, each
|
|
||||||
* question is a screen and the count says how many are left.
|
|
||||||
*
|
|
||||||
* Nothing is sent until Submit. Answering is one act even when it is several questions: the tool
|
|
||||||
* asked them together, and sending each as it was tapped meant the reader could not change their
|
|
||||||
* mind about the first after reading the third.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun AskUserQuestionBody(
|
|
||||||
asks: List<TranscriptItem.QuestionCard>,
|
|
||||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
|
||||||
) {
|
|
||||||
// Seeded from what was already answered, so a card the reader comes back to shows their answers
|
|
||||||
// rather than an empty draft over them.
|
|
||||||
var drafts by
|
|
||||||
remember(asks.map { it.id }) {
|
|
||||||
mutableStateOf(
|
|
||||||
asks.associate { ask ->
|
|
||||||
ask.id to
|
|
||||||
Draft(
|
|
||||||
picked =
|
|
||||||
ask.answers
|
|
||||||
.filter { a -> ask.options.any { it.label == a } }
|
|
||||||
.toSet(),
|
|
||||||
other =
|
|
||||||
ask.answers
|
|
||||||
.firstOrNull { a -> ask.options.none { it.label == a } }
|
|
||||||
.orEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
var at by remember(asks.map { it.id }) { mutableIntStateOf(0) }
|
|
||||||
var sending by remember(asks.map { it.id }) { mutableStateOf(false) }
|
|
||||||
if (asks.isEmpty()) return
|
|
||||||
val showing = asks[at.coerceIn(0, asks.size - 1)]
|
|
||||||
val outstanding = asks.filter { it.answers.isEmpty() }
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxWidth()) {
|
|
||||||
if (asks.size > 1) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Question ${at + 1} of ${asks.size}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
// Disabled at the ends rather than absent, so the pair keeps its place and the
|
|
||||||
// reader can see there is nothing further that way.
|
|
||||||
MarkButton("Previous question", { at-- }, enabled = at > 0) {
|
|
||||||
Chevron(Pointing.Left, colour = LocalContentColor.current)
|
|
||||||
}
|
|
||||||
MarkButton("Next question", { at++ }, enabled = at < asks.size - 1) {
|
|
||||||
Chevron(Pointing.Right, colour = LocalContentColor.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
AskedQuestion(
|
|
||||||
showing,
|
|
||||||
draft = drafts[showing.id] ?: Draft(),
|
|
||||||
onDraft = { drafts = drafts + (showing.id to it) },
|
|
||||||
)
|
|
||||||
if (outstanding.isNotEmpty()) {
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
// Greyed until every question has an answer, because the tool is waiting on all of
|
|
||||||
// them: a submit that sent two of three would leave the third asked and the card
|
|
||||||
// looking dealt with.
|
|
||||||
val ready = outstanding.all { drafts[it.id]?.settled == true }
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
sending = true
|
|
||||||
onAnswer(
|
|
||||||
outstanding.map { ask ->
|
|
||||||
QuestionAnswer(ask.id, (drafts[ask.id] ?: Draft()).answers(ask.options))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
// Back to a button whatever happened. A refusal is reported by the screen
|
|
||||||
// around this, and the draft is still here to send again -- a spinner that
|
|
||||||
// never stops would be the only sign of a failure this card cannot
|
|
||||||
// describe.
|
|
||||||
sending = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = ready && !sending,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
if (sending) {
|
|
||||||
// In the button rather than beside it, so the row does not change height at the
|
|
||||||
// moment it is pressed.
|
|
||||||
CircularProgressIndicator(
|
|
||||||
Modifier.height(18.dp).width(18.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = LocalContentColor.current,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
if (outstanding.size > 1) "Submit ${outstanding.size} answers" else "Submit"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One question: what is being asked, what can be answered, and what was.
|
|
||||||
*
|
|
||||||
* The same body wherever a question appears -- on the call that asked it, or as a card of its own
|
|
||||||
* when nothing did. Two renderings of it would be two places for an answer to go missing.
|
|
||||||
*
|
|
||||||
* [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here
|
|
||||||
* sends anything. An answered question ignores both and draws what was answered.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun AskedQuestion(
|
|
||||||
ask: TranscriptItem.QuestionCard,
|
|
||||||
draft: Draft,
|
|
||||||
onDraft: (Draft) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(Modifier.fillMaxWidth()) {
|
|
||||||
ask.header?.let { header ->
|
|
||||||
// Its own line rather than beside the question, because it is a label *for* the
|
|
||||||
// question and the question is the thing to read.
|
|
||||||
Text(
|
|
||||||
header.uppercase(),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(ask.prompt, style = MaterialTheme.typography.bodyLarge)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
// An answered question keeps its options and marks the one that was taken, rather than
|
|
||||||
// replacing them with a line repeating it. The options are what the question *was*, and
|
|
||||||
// dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says
|
|
||||||
// very little without the three it was chosen over. Marked in the same purple that says
|
|
||||||
// "picked" while the question is open, so it is one appearance learned once.
|
|
||||||
val answered = ask.answers.isNotEmpty()
|
|
||||||
// What is marked: what was answered once there is an answer, and what the finger has chosen
|
|
||||||
// until then.
|
|
||||||
val marked = if (answered) ask.answers.toSet() else draft.picked
|
|
||||||
// Null once the question is answered: the options stay and stop being pressable.
|
|
||||||
val onPick: ((String) -> Unit)? =
|
|
||||||
if (answered) null else { label -> onDraft(pick(draft, label, ask.multiSelect)) }
|
|
||||||
if (ask.options.all { it.description == null && it.preview == null }) {
|
|
||||||
// Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words
|
|
||||||
// do not need a card each.
|
|
||||||
AnswerOptions(ask.options, marked.toList(), onPick)
|
|
||||||
} else {
|
|
||||||
ask.options.forEach { option ->
|
|
||||||
OptionCard(option, selected = option.label in marked) {
|
|
||||||
onPick?.invoke(option.label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// What was answered in the reader's own words, which no option can mark. Only ever the
|
|
||||||
// answers that match nothing offered, so a question answered by picking says it by the
|
|
||||||
// mark.
|
|
||||||
val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } }
|
|
||||||
if (inWords.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
"Answered: ${inWords.joinToString(", ")}",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!answered) {
|
|
||||||
OtherAnswer(draft.other) { onDraft(Draft(other = it)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [label] added to, or taken out of, what [draft] has picked. A single-answer question replaces
|
|
||||||
* rather than accumulates, and either way picking puts any typed words away -- see [Draft].
|
|
||||||
*/
|
|
||||||
private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
|
|
||||||
when {
|
|
||||||
!multiSelect -> Draft(picked = setOf(label))
|
|
||||||
label in draft.picked -> Draft(picked = draft.picked - label)
|
|
||||||
else -> Draft(picked = draft.picked + label)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One option: what it is called, what it means, and what it would produce.
|
|
||||||
*
|
|
||||||
* Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was
|
|
||||||
* indistinguishable from the card behind it -- three paragraphs of text where three things to press
|
|
||||||
* should have been. A border is one cue and it is unambiguous.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) {
|
|
||||||
OutlinedCard(
|
|
||||||
onClick = onPick,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
|
||||||
colors =
|
|
||||||
CardDefaults.outlinedCardColors(
|
|
||||||
containerColor =
|
|
||||||
if (selected) MaterialTheme.colorScheme.primaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surface
|
|
||||||
),
|
|
||||||
// Picked shows in the border as well as the fill, because the fill alone is a colour
|
|
||||||
// difference somebody has to have seen the unpicked version to notice.
|
|
||||||
border =
|
|
||||||
BorderStroke(
|
|
||||||
if (selected) 2.dp else 1.dp,
|
|
||||||
if (selected) MaterialTheme.colorScheme.primary
|
|
||||||
else MaterialTheme.colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(option.label, style = MaterialTheme.typography.titleSmall)
|
|
||||||
option.description?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
option.preview?.let { Preview(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An option's worked example, shown as written.
|
|
||||||
*
|
|
||||||
* On its own surface, because it is a different kind of thing from the sentence above it: that
|
|
||||||
* describes the option, this is a sample of what the option produces, and monospace alone reads as
|
|
||||||
* a description that happens to be in code font.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun Preview(preview: String) {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
preview,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines of
|
|
||||||
// the thing being previewed.
|
|
||||||
softWrap = false,
|
|
||||||
modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The choice the asker always leaves open, and the app has to as well.
|
|
||||||
*
|
|
||||||
* Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words
|
|
||||||
* rather than pick. Leaving it out narrows a question that was never that narrow.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun OtherAnswer(text: String, onText: (String) -> Unit) {
|
|
||||||
// No Send of its own: this is one more way to answer the question, and the card's Submit is
|
|
||||||
// what sends it. A second send button beside the field made the shorter half of the card look
|
|
||||||
// like the one that finishes it.
|
|
||||||
OutlinedTextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = onText,
|
|
||||||
label = { Text("Other") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bare options, wrapped rather than in a row.
|
|
||||||
*
|
|
||||||
* A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question
|
|
||||||
* with four options showed the first one or two and dropped the rest off the side of the screen.
|
|
||||||
* That reads as those having been the only choices.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun AnswerOptions(
|
|
||||||
options: List<QuestionOption>,
|
|
||||||
/** What is chosen: the answer once there is one, and what the finger has marked until then. */
|
|
||||||
answers: List<String> = emptyList(),
|
|
||||||
/** Null once the question is answered -- the buttons stay, and stop being buttons. */
|
|
||||||
onPick: ((String) -> Unit)?,
|
|
||||||
) {
|
|
||||||
FlowRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
options.forEach { option ->
|
|
||||||
val taken = option.label in answers
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { onPick?.invoke(option.label) },
|
|
||||||
// Disabled rather than removed, so an answered question still shows what it
|
|
||||||
// offered. Material dims a disabled button's own border and label, which would take
|
|
||||||
// the mark with it -- both are stated here instead.
|
|
||||||
enabled = onPick != null,
|
|
||||||
border =
|
|
||||||
BorderStroke(
|
|
||||||
if (taken) 2.dp else 1.dp,
|
|
||||||
if (taken) MaterialTheme.colorScheme.primary
|
|
||||||
else MaterialTheme.colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
colors =
|
|
||||||
ButtonDefaults.outlinedButtonColors(
|
|
||||||
disabledContentColor =
|
|
||||||
if (taken) MaterialTheme.colorScheme.primary
|
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(option.label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether [ref] names an image the server stored as one -- `<hex>.<extension>`, with an extension
|
|
||||||
* from the list it writes -- rather than a file kept under its own name. Mirrors the server's
|
|
||||||
* `media` table, which is the one other place the list lives.
|
|
||||||
*/
|
|
||||||
fun isImageRef(ref: String): Boolean = ref.substringAfterLast('.', "") in IMAGE_EXTENSIONS
|
|
||||||
|
|
||||||
private val IMAGE_EXTENSIONS = setOf("png", "jpg", "gif", "webp")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The name a file was attached under: the ref less the hex the server put before it. The hex has no
|
|
||||||
* dash in it, so the first one is the boundary however many the name has.
|
|
||||||
*/
|
|
||||||
fun attachmentName(ref: String): String = ref.substringAfter('-', ref)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One attachment on a sent message, drawn as what it is: an image inline, a file as its name. A
|
|
||||||
* file is not fetched -- there is nothing on this phone to open a trace with -- so the name is all
|
|
||||||
* of it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Attachment(
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
ref: String,
|
|
||||||
onOpenImage: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
if (isImageRef(ref)) SessionImage(settings, sessionId, ref, onOpenImage)
|
|
||||||
else
|
|
||||||
FileName(
|
|
||||||
attachmentName(ref),
|
|
||||||
Modifier.clip(MaterialTheme.shapes.extraSmall)
|
|
||||||
.background(rawSurface)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A file's name, one line, in the face names are read in. Overlong names lose their middle: a name
|
|
||||||
* is identified by both ends -- what it is at the front, what kind at the back.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun FileName(name: String, modifier: Modifier = Modifier) {
|
|
||||||
Text(
|
|
||||||
name,
|
|
||||||
modifier = modifier,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.MiddleEllipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.graphics.Matrix
|
|
||||||
import android.net.Uri
|
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import androidx.exifinterface.media.ExifInterface
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import kotlin.math.max
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Getting a picked photo to a session, at a size the session can actually take.
|
|
||||||
*
|
|
||||||
* A phone camera produces twelve megapixels and several megabytes. The Claude API resizes anything
|
|
||||||
* larger than 1568px on its long edge before looking at it and refuses images past a much higher
|
|
||||||
* bound outright, so a photo sent straight off the camera roll was uploaded whole over the tunnel
|
|
||||||
* to be either thrown away or rejected -- which is what "sending an image is broken" was.
|
|
||||||
*
|
|
||||||
* Shrunk here rather than on the backend, so the bytes that never mattered are never sent: the
|
|
||||||
* expensive part on a phone is the upload, not the decode. What the limit *is* comes from the
|
|
||||||
* server, per session, because that is where a provider's requirements are known.
|
|
||||||
*/
|
|
||||||
suspend fun uploadPickedImage(
|
|
||||||
context: Context,
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
uri: Uri,
|
|
||||||
maxEdge: Int?,
|
|
||||||
): String {
|
|
||||||
val (bytes, mime) = readForUpload(context, uri, maxEdge)
|
|
||||||
return uploadAttachment(settings, sessionId, mime, "image") { it.write(bytes) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads whatever [uri] names, the way its kind needs. An image goes through [uploadPickedImage]
|
|
||||||
* and is shrunk; anything else goes whole, under the name the other app or the file chooser gave
|
|
||||||
* it, because the session is told that name rather than shown the bytes.
|
|
||||||
*/
|
|
||||||
suspend fun uploadPicked(
|
|
||||||
context: Context,
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
uri: Uri,
|
|
||||||
maxEdge: Int?,
|
|
||||||
): String {
|
|
||||||
val resolver = context.contentResolver
|
|
||||||
val mime = resolver.getType(uri)
|
|
||||||
if (mime != null && mime.startsWith("image/")) {
|
|
||||||
return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
|
|
||||||
}
|
|
||||||
// Opened before the request starts, so a provider that refuses says so here and not from inside
|
|
||||||
// the connection; then streamed, since a trace is bigger than this process should hold at once.
|
|
||||||
val source = openSource(resolver, uri)
|
|
||||||
val name = displayName(resolver, uri)
|
|
||||||
return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out ->
|
|
||||||
try {
|
|
||||||
source.use { it.copyTo(out, COPY_BUFFER) }
|
|
||||||
} catch (e: java.io.IOException) {
|
|
||||||
// Either side of the copy can fail; the message names the file, which is the part the
|
|
||||||
// reader can do something about.
|
|
||||||
throw ApiException("couldn't send $name: ${e.message}", cause = e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val COPY_BUFFER = 64 * 1024
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A stream of [uri], or the refusal as the kind the composer reports beside the message.
|
|
||||||
*
|
|
||||||
* A share arrives with whatever access the other app granted, and a provider that refuses says so
|
|
||||||
* with a `SecurityException`; a file gone between the pick and the read is an `IOException`. Both
|
|
||||||
* are things the reader can act on.
|
|
||||||
*/
|
|
||||||
private fun openSource(resolver: ContentResolver, uri: Uri): java.io.InputStream =
|
|
||||||
try {
|
|
||||||
resolver.openInputStream(uri)
|
|
||||||
?: throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: nothing there")
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: no access to it")
|
|
||||||
} catch (e: java.io.IOException) {
|
|
||||||
throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Everything at [uri]; an image is decoded whole anyway, so it is read whole. */
|
|
||||||
private fun readAll(resolver: ContentResolver, uri: Uri): ByteArray =
|
|
||||||
openSource(resolver, uri).use { it.readBytes() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The name a document provider shows for [uri]. The last path segment is the fallback because a
|
|
||||||
* provider's own id for a file is usually a number, which says nothing to the session.
|
|
||||||
*/
|
|
||||||
private fun displayName(resolver: ContentResolver, uri: Uri): String {
|
|
||||||
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
|
||||||
val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
|
||||||
if (column >= 0 && cursor.moveToFirst())
|
|
||||||
cursor.getString(column)?.let {
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return uri.lastPathSegment ?: "file"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The bytes to upload and what they are, scaled down only if they need to be.
|
|
||||||
*
|
|
||||||
* An image already inside the limit is uploaded exactly as it came, rather than decoded and re-
|
|
||||||
* encoded to the same size: a round trip through JPEG loses a little every time. This is also the
|
|
||||||
* path a provider with no limit always takes.
|
|
||||||
*/
|
|
||||||
private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteArray, String> {
|
|
||||||
val resolver = context.contentResolver
|
|
||||||
val mime = resolver.getType(uri) ?: "image/jpeg"
|
|
||||||
val original = readAll(resolver, uri)
|
|
||||||
if (maxEdge == null) return original to mime
|
|
||||||
|
|
||||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
||||||
BitmapFactory.decodeByteArray(original, 0, original.size, bounds)
|
|
||||||
val longest = max(bounds.outWidth, bounds.outHeight)
|
|
||||||
// outWidth is -1 when the bytes are not an image this device can decode. Sent on untouched:
|
|
||||||
// this function's job is the size, and refusing something the server might understand is a
|
|
||||||
// decision it has no business making.
|
|
||||||
if (longest <= 0 || longest <= maxEdge) return original to mime
|
|
||||||
|
|
||||||
// Powers of two first, which is all the decoder can do, and then the exact scale. Decoding the
|
|
||||||
// full twelve megapixels only to shrink it is how this runs out of memory on the images it most
|
|
||||||
// needs to handle.
|
|
||||||
val decode =
|
|
||||||
BitmapFactory.Options().apply {
|
|
||||||
inSampleSize = Integer.highestOneBit(max(1, longest / maxEdge))
|
|
||||||
}
|
|
||||||
val decoded =
|
|
||||||
BitmapFactory.decodeByteArray(original, 0, original.size, decode) ?: return original to mime
|
|
||||||
val scale = maxEdge.toFloat() / max(decoded.width, decoded.height)
|
|
||||||
val matrix = Matrix()
|
|
||||||
if (scale < 1f) matrix.postScale(scale, scale)
|
|
||||||
// The camera writes which way up the picture is into EXIF rather than rotating the pixels, and
|
|
||||||
// re-encoding drops the tag -- so a portrait photo would arrive at the model on its side.
|
|
||||||
// Applied to the same matrix as the scale, so it costs no second copy of the bitmap.
|
|
||||||
matrix.postRotate(exifRotation(original))
|
|
||||||
val scaled = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
|
|
||||||
val out = ByteArrayOutputStream()
|
|
||||||
// JPEG whatever came in: this is a photograph being made smaller, which is what JPEG is for,
|
|
||||||
// and a PNG of a resampled photo is several times the size for no visible difference.
|
|
||||||
scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
|
||||||
return out.toByteArray() to "image/jpeg"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How far to turn the picture so it is the way up it was taken. */
|
|
||||||
private fun exifRotation(bytes: ByteArray): Float =
|
|
||||||
try {
|
|
||||||
when (
|
|
||||||
ExifInterface(bytes.inputStream())
|
|
||||||
.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
|
||||||
) {
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
|
|
||||||
else -> 0f
|
|
||||||
}
|
|
||||||
} catch (_: java.io.IOException) {
|
|
||||||
// No EXIF, or none this can read. Upright is the assumption every image without the tag is
|
|
||||||
// displayed under anyway.
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
|
|
||||||
/** High enough that resampling is what the reader notices, not the encoder. */
|
|
||||||
private const val JPEG_QUALITY = 90
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
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,102 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
// The composer's row of settings and pickers, and the menus they open. One file because the outline
|
|
||||||
// and the corner are one appearance: a control shaped like this opens a surface shaped like this.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A bordered pill: a control that can be seen without being pressed.
|
|
||||||
*
|
|
||||||
* The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at
|
|
||||||
* all until they are touched. Three bare words under the message field read as a caption about the
|
|
||||||
* field rather than as three things to press. The outline says "control" without the weight of a
|
|
||||||
* filled button, which is reserved for the two that act on the session.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun BubbleButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onClick,
|
|
||||||
enabled = enabled,
|
|
||||||
shape = BubbleShape,
|
|
||||||
// A text button's padding rather than a filled button's 24dp: these sit three across under
|
|
||||||
// the message field, and the wider padding is what decides whether the row fits.
|
|
||||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fully round ends, so the control reads as a bubble rather than as a box. */
|
|
||||||
val BubbleShape: Shape = RoundedCornerShape(percent = 50)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The corner on a menu one of these opens.
|
|
||||||
*
|
|
||||||
* A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding
|
|
||||||
* ends that tall would bow its sides.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
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.draw.drawWithContent
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Rect
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.graphics.ColorMatrix
|
|
||||||
import androidx.compose.ui.graphics.Paint
|
|
||||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An item something is happening to: dimmed, drained of colour, inert, with a spinner and the name
|
|
||||||
* of the operation over it.
|
|
||||||
*
|
|
||||||
* One composable rather than a pattern each list repeats, because "this row is busy" has to look
|
|
||||||
* the same in the import list and the session list or the appearance becomes a per-screen dialect.
|
|
||||||
*
|
|
||||||
* [label] names the operation and `null` means none is running. One parameter rather than a boolean
|
|
||||||
* beside a string, which can disagree. It is a *word* because a spinner alone cannot say which
|
|
||||||
* operation this is -- deleting and importing are different in kind.
|
|
||||||
*
|
|
||||||
* It does **not** make the row inert; the caller disables its own click handling while it passes a
|
|
||||||
* label. That was the other way round at first -- an overlay consuming pointer events -- and it
|
|
||||||
* swallowed the drag along with the tap, so a list could not be scrolled while anything in it was
|
|
||||||
* busy.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun BusyItem(label: String?, content: @Composable () -> Unit) {
|
|
||||||
Box {
|
|
||||||
Box(Modifier.busy(label != null)) { content() }
|
|
||||||
if (label != null) {
|
|
||||||
Box(Modifier.matchParentSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.width(16.dp).height(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
// Full strength, over content that is not: the operation is the one thing on
|
|
||||||
// this row that is still current, and it has to read against a card whose own
|
|
||||||
// text is still visible behind it.
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How an item looks while it is being acted on: darker, and nearly grey.
|
|
||||||
*
|
|
||||||
* Both, rather than either alone. Dimming by itself is the same cue as a disabled control, so a
|
|
||||||
* busy row read as one more thing that could not be tapped. Draining the colour is what says the
|
|
||||||
* row is *suspended* -- the status word and everything else that means something by its colour stop
|
|
||||||
* meaning it for as long as the operation runs, which is exactly true.
|
|
||||||
*
|
|
||||||
* Not all the way to grey: a row with no colour left is hard to find again in a list.
|
|
||||||
*/
|
|
||||||
private fun Modifier.busy(busy: Boolean): Modifier =
|
|
||||||
if (!busy) this
|
|
||||||
else
|
|
||||||
this.graphicsLayer { alpha = 0.5f }
|
|
||||||
.drawWithContent {
|
|
||||||
drawIntoCanvas { canvas ->
|
|
||||||
canvas.saveLayer(
|
|
||||||
Rect(Offset.Zero, size),
|
|
||||||
Paint().apply {
|
|
||||||
colorFilter =
|
|
||||||
ColorFilter.colorMatrix(
|
|
||||||
ColorMatrix().apply { setToSaturation(0.2f) }
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
drawContent()
|
|
||||||
canvas.restore()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/** Which way a [Chevron] points. */
|
|
||||||
enum class Pointing {
|
|
||||||
Up,
|
|
||||||
Down,
|
|
||||||
Left,
|
|
||||||
Right,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A chevron, pointing whichever of the four ways is asked for.
|
|
||||||
*
|
|
||||||
* Drawn rather than set in a font: a chevron from an icon font is one of the glyphs a system font
|
|
||||||
* may simply not have, and the reader who gets an empty box instead is never the one who wrote it.
|
|
||||||
*
|
|
||||||
* One composable for all four directions rather than one per axis that differ by which coordinate
|
|
||||||
* gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one
|
|
||||||
* direction. The shape is written once in its own coordinates, and [Pointing] is only a table of
|
|
||||||
* how those map onto the box.
|
|
||||||
*
|
|
||||||
* It draws no label of its own, so every caller owes it a `contentDescription`.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Chevron(
|
|
||||||
pointing: Pointing,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
colour: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
) {
|
|
||||||
val sideways = pointing == Pointing.Left || pointing == Pointing.Right
|
|
||||||
Canvas(
|
|
||||||
modifier
|
|
||||||
.width(if (sideways) CHEVRON_DEPTH else CHEVRON_SPAN)
|
|
||||||
.height(if (sideways) CHEVRON_SPAN else CHEVRON_DEPTH)
|
|
||||||
) {
|
|
||||||
val inset = 2.dp.toPx()
|
|
||||||
val wide = size.width - inset
|
|
||||||
val tall = size.height - inset
|
|
||||||
fun at(across: Float, along: Float) =
|
|
||||||
when (pointing) {
|
|
||||||
Pointing.Up -> Offset(lerp(inset, wide, across), lerp(tall, inset, along))
|
|
||||||
Pointing.Down -> Offset(lerp(inset, wide, across), lerp(inset, tall, along))
|
|
||||||
Pointing.Left -> Offset(lerp(wide, inset, along), lerp(inset, tall, across))
|
|
||||||
Pointing.Right -> Offset(lerp(inset, wide, along), lerp(inset, tall, across))
|
|
||||||
}
|
|
||||||
val stroke = 2.dp.toPx()
|
|
||||||
drawLine(colour, at(0f, 0f), at(0.5f, 1f), strokeWidth = stroke, cap = StrokeCap.Round)
|
|
||||||
drawLine(colour, at(0.5f, 1f), at(1f, 0f), strokeWidth = stroke, cap = StrokeCap.Round)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun lerp(from: Float, to: Float, fraction: Float) = from + (to - from) * fraction
|
|
||||||
|
|
||||||
/** How far the chevron opens, across the direction it points. */
|
|
||||||
private val CHEVRON_SPAN = 20.dp
|
|
||||||
|
|
||||||
/** How far it reaches in the direction it points. */
|
|
||||||
private val CHEVRON_DEPTH = 10.dp
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.BasicText
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.semantics.isTraversalGroup
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownPadding
|
|
||||||
import com.mikepenz.markdown.model.State
|
|
||||||
import org.intellij.markdown.MarkdownElementTypes
|
|
||||||
import org.intellij.markdown.MarkdownTokenTypes
|
|
||||||
import org.intellij.markdown.ast.ASTNode
|
|
||||||
import org.intellij.markdown.ast.findChildOfType
|
|
||||||
import org.intellij.markdown.ast.getTextInNode
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fenced code block in a reply: the code highlighted, on the dark surface every verbatim thing
|
|
||||||
* sits on, scrolling sideways rather than wrapping.
|
|
||||||
*
|
|
||||||
* The renderer's own fence drew the same block in plain text. The scanner that colours a tool
|
|
||||||
* call's command colours a reply's code the same way, so a `kotlin` fence and the Kotlin a tool
|
|
||||||
* wrote are the same colours. A fence in a language [scan] has no rules for is plain rather than
|
|
||||||
* wrongly coloured.
|
|
||||||
*
|
|
||||||
* Finding the code is still the library's: which children of the node are the fence markers, the
|
|
||||||
* language word and the code between them is its knowledge of the parser.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun CodeFence(
|
|
||||||
content: String,
|
|
||||||
node: ASTNode,
|
|
||||||
style: TextStyle,
|
|
||||||
replies: ParsedReplies,
|
|
||||||
streaming: Boolean = false,
|
|
||||||
) {
|
|
||||||
val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return
|
|
||||||
CodeBlockText(code, language, style, replies, streaming)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An indented code block, which is a fence with no language word. */
|
|
||||||
@Composable
|
|
||||||
fun CodeBlock(
|
|
||||||
content: String,
|
|
||||||
node: ASTNode,
|
|
||||||
style: TextStyle,
|
|
||||||
replies: ParsedReplies,
|
|
||||||
streaming: Boolean = false,
|
|
||||||
) {
|
|
||||||
val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return
|
|
||||||
CodeBlockText(code, language, style, replies, streaming)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The code inside a fence or indented block, and the highlighter's language for its info word.
|
|
||||||
*
|
|
||||||
* Copied from the library's `MarkdownCodeFence` rather than called: that one is a composable, and
|
|
||||||
* the whole point here is that [warm] can run this on a background thread and highlight the same
|
|
||||||
* string the drawing will ask for. Two extractions would be two keys, and the warmed answer would
|
|
||||||
* be silently missed at every fence.
|
|
||||||
*
|
|
||||||
* Null for a fence too short to hold anything -- an unterminated one still arriving.
|
|
||||||
*/
|
|
||||||
fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
|
|
||||||
val word =
|
|
||||||
node.findChildOfType(MarkdownTokenTypes.FENCE_LANG)?.getTextInNode(content)?.toString()
|
|
||||||
val language = fenceLanguage(word)
|
|
||||||
if (node.type == MarkdownElementTypes.CODE_BLOCK) {
|
|
||||||
val start = node.children.firstOrNull()?.startOffset ?: return null
|
|
||||||
val end = node.children.lastOrNull()?.endOffset ?: return null
|
|
||||||
return content.substring(start, end).replaceIndent() to language
|
|
||||||
}
|
|
||||||
if (node.children.size < 3) return null
|
|
||||||
val start = node.children[2].startOffset
|
|
||||||
val fenceCount = if (word != null && node.children.size > 3) 3 else 2
|
|
||||||
val end = node.children[(node.children.size - 2).coerceAtLeast(fenceCount)].endOffset
|
|
||||||
return content.substring(start, end).replaceIndent() to language
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plain while [streaming], coloured once the block is finished; see [MarkdownRoot].
|
|
||||||
*
|
|
||||||
* The renderer's own block, less what nothing here needs: the same background, corner, padding and
|
|
||||||
* sideways scroll, without the shadow, the border and the empty pointer handler it also carried.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun CodeBlockText(
|
|
||||||
code: String,
|
|
||||||
language: Language?,
|
|
||||||
style: TextStyle,
|
|
||||||
replies: ParsedReplies,
|
|
||||||
streaming: Boolean,
|
|
||||||
) {
|
|
||||||
val colors = LocalMarkdownColors.current
|
|
||||||
val dimens = LocalMarkdownDimens.current
|
|
||||||
val padding = LocalMarkdownPadding.current
|
|
||||||
Box(
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp)
|
|
||||||
.background(colors.codeBackground, RoundedCornerShape(dimens.codeBackgroundCornerSize))
|
|
||||||
.semantics { isTraversalGroup = true }
|
|
||||||
) {
|
|
||||||
BasicText(
|
|
||||||
// No language while the block is still being written, which is what draws it plain.
|
|
||||||
replies.highlighted(code, language.takeUnless { streaming }),
|
|
||||||
style = style,
|
|
||||||
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The highlighter's language for a fence's info word, or null for one it has no rules for.
|
|
||||||
*
|
|
||||||
* The aliases are what people actually write after the backticks: the file extension as often as
|
|
||||||
* the name. A word not here gets no colour rather than the nearest language's, because a fence
|
|
||||||
* coloured by the wrong language's rules looks highlighted and is wrong in a way the reader cannot
|
|
||||||
* see.
|
|
||||||
*/
|
|
||||||
fun fenceLanguage(name: String?): Language? =
|
|
||||||
FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The highlighter's language for a *file*, from its name.
|
|
||||||
*
|
|
||||||
* The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people
|
|
||||||
* write after the backticks. One table rather than two, so a language added for fences is a
|
|
||||||
* language added for files and neither can be the one somebody forgot.
|
|
||||||
*
|
|
||||||
* The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin. A
|
|
||||||
* leading dot is not one: `.bashrc` has no extension, it has a name that starts with a dot. A name
|
|
||||||
* with no dot at all -- `Makefile` -- is likewise null, and null is drawn plain.
|
|
||||||
*/
|
|
||||||
fun fileLanguage(name: String): Language? {
|
|
||||||
val dot = name.lastIndexOf('.')
|
|
||||||
if (dot < 1) return null
|
|
||||||
return fenceLanguage(name.substring(dot + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
private val FENCE_LANGUAGES: Map<String, Language> =
|
|
||||||
mapOf(
|
|
||||||
"kotlin" to Language.KOTLIN,
|
|
||||||
"kt" to Language.KOTLIN,
|
|
||||||
"kts" to Language.KOTLIN,
|
|
||||||
"rust" to Language.RUST,
|
|
||||||
"rs" to Language.RUST,
|
|
||||||
"sh" to Language.SHELL,
|
|
||||||
"bash" to Language.SHELL,
|
|
||||||
"shell" to Language.SHELL,
|
|
||||||
"zsh" to Language.SHELL,
|
|
||||||
"console" to Language.SHELL,
|
|
||||||
"diff" to Language.DIFF,
|
|
||||||
"python" to Language.PYTHON,
|
|
||||||
"py" to Language.PYTHON,
|
|
||||||
"javascript" to Language.JAVASCRIPT,
|
|
||||||
"js" to Language.JAVASCRIPT,
|
|
||||||
"jsx" to Language.JAVASCRIPT,
|
|
||||||
"typescript" to Language.TYPESCRIPT,
|
|
||||||
"ts" to Language.TYPESCRIPT,
|
|
||||||
"tsx" to Language.TYPESCRIPT,
|
|
||||||
"java" to Language.JAVA,
|
|
||||||
"c" to Language.C,
|
|
||||||
"h" to Language.C,
|
|
||||||
"cpp" to Language.CPP,
|
|
||||||
"c++" to Language.CPP,
|
|
||||||
"cc" to Language.CPP,
|
|
||||||
"hpp" to Language.CPP,
|
|
||||||
"csharp" to Language.CSHARP,
|
|
||||||
"cs" to Language.CSHARP,
|
|
||||||
"c#" to Language.CSHARP,
|
|
||||||
"go" to Language.GO,
|
|
||||||
"golang" to Language.GO,
|
|
||||||
"swift" to Language.SWIFT,
|
|
||||||
"dart" to Language.DART,
|
|
||||||
"ruby" to Language.RUBY,
|
|
||||||
"rb" to Language.RUBY,
|
|
||||||
"php" to Language.PHP,
|
|
||||||
"perl" to Language.PERL,
|
|
||||||
"pl" to Language.PERL,
|
|
||||||
"coffeescript" to Language.COFFEESCRIPT,
|
|
||||||
"coffee" to Language.COFFEESCRIPT,
|
|
||||||
"ron" to Language.RON,
|
|
||||||
"toml" to Language.TOML,
|
|
||||||
"fish" to Language.FISH,
|
|
||||||
"json" to Language.JSON,
|
|
||||||
"markdown" to Language.MARKDOWN,
|
|
||||||
"md" to Language.MARKDOWN,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every fence in [parse], as the code and language [highlight] will be asked for. Walks the whole
|
|
||||||
* tree rather than the top level: a fence inside a list item or a quote is drawn the same way and
|
|
||||||
* costs the same to lex.
|
|
||||||
*/
|
|
||||||
fun fences(parse: State): List<Pair<String, Language?>> {
|
|
||||||
val success = parse as? State.Success ?: return emptyList()
|
|
||||||
val out = ArrayList<Pair<String, Language?>>()
|
|
||||||
fun walk(node: ASTNode) {
|
|
||||||
if (
|
|
||||||
node.type == MarkdownElementTypes.CODE_FENCE ||
|
|
||||||
node.type == MarkdownElementTypes.CODE_BLOCK
|
|
||||||
) {
|
|
||||||
fenceContent(success.content, node)?.let { if (it.second != null) out += it }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
node.children.forEach(::walk)
|
|
||||||
}
|
|
||||||
walk(success.node)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
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.CardDefaults
|
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Something a session can be asked to do to itself, rather than something to say to it.
|
|
||||||
*
|
|
||||||
* These are the two this app understands, and understanding them is what lets it show them: a
|
|
||||||
* suggestion while one is being typed, a name in the settings screen that sends one, and a bubble
|
|
||||||
* that stays up while the session is too busy to run it. Anything else beginning with "/" is passed
|
|
||||||
* through, because a dialect's own vocabulary grows without this list.
|
|
||||||
*/
|
|
||||||
data class SessionCommand(
|
|
||||||
/** With the slash, as it is typed and as it is sent. */
|
|
||||||
val name: String,
|
|
||||||
/** One line, in the suggestion list: what it does, not how. */
|
|
||||||
val summary: String,
|
|
||||||
/** What follows the name, named for the reader, or null when nothing does. */
|
|
||||||
val argument: String?,
|
|
||||||
) {
|
|
||||||
/** What to put in the box when this is picked: ready to send, or ready to be finished. */
|
|
||||||
fun typed(): String = if (argument == null) name else "$name "
|
|
||||||
}
|
|
||||||
|
|
||||||
val SESSION_COMMANDS =
|
|
||||||
listOf(
|
|
||||||
SessionCommand(
|
|
||||||
"/compact",
|
|
||||||
"Summarise the conversation so far and carry on from the summary",
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
SessionCommand(
|
|
||||||
"/clear",
|
|
||||||
"Start fresh: drop the conversation from the session's context, keeping it on screen",
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
SessionCommand("/rename", "Change what this session is called", "name"),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The commands worth offering for what has been typed so far.
|
|
||||||
*
|
|
||||||
* Only for a line that starts with a slash and has not yet become a whole command with an argument
|
|
||||||
* -- once there is something after "/rename ", the reader is writing the name and a list of
|
|
||||||
* commands underneath it is in the way.
|
|
||||||
*/
|
|
||||||
fun suggestedCommands(input: String): List<SessionCommand> {
|
|
||||||
if (!input.startsWith("/") || input.contains(' ')) return emptyList()
|
|
||||||
return SESSION_COMMANDS.filter { it.name.startsWith(input) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The commands matching what is being typed, above the box they are being typed into.
|
|
||||||
*
|
|
||||||
* Above rather than over: a list that covers the transcript hides what the command is about, and
|
|
||||||
* the reader is usually looking at the thing they mean to act on.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun CommandSuggestions(
|
|
||||||
commands: List<SessionCommand>,
|
|
||||||
onPick: (SessionCommand) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
if (commands.isEmpty()) return
|
|
||||||
Card(modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
|
|
||||||
Column(Modifier.padding(vertical = 4.dp)) {
|
|
||||||
commands.forEach { command ->
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.clickable { onPick(command) }
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
// The command in the colour commands are, so the suggestion and the bubble
|
|
||||||
// it becomes are visibly the same thing.
|
|
||||||
if (command.argument == null) command.name
|
|
||||||
else "${command.name} <${command.argument}>",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = commandColor,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
Text(
|
|
||||||
command.summary,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command, where the reader put it: at their end of the conversation.
|
|
||||||
*
|
|
||||||
* Blue rather than the colour of something they said, because they did not say it to the model --
|
|
||||||
* it is an instruction to the session, and the reply to it is the session changing rather than
|
|
||||||
* anything appearing here.
|
|
||||||
*
|
|
||||||
* [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a
|
|
||||||
* reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun CommandBubble(text: String, waiting: Boolean = false) {
|
|
||||||
Box(Modifier.fillMaxWidth()) {
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(containerColor = commandColor),
|
|
||||||
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
// Stated beside the fill rather than inherited: a semantic colour has to carry its
|
|
||||||
// own contrast, because the surface under it will not change to rescue it.
|
|
||||||
Text(text, color = MaterialTheme.colorScheme.inverseOnSurface)
|
|
||||||
if (waiting) {
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.width(12.dp).height(12.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
"waiting for this turn to end",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The mark a compaction leaves in the transcript.
|
|
||||||
*
|
|
||||||
* A divider rather than something anybody said: everything above it is out of the session's context
|
|
||||||
* now, and that is a fact about the conversation, not a turn in it. Drawn by [TranscriptDivider],
|
|
||||||
* which a clear also uses, so the two marks cannot drift apart.
|
|
||||||
*
|
|
||||||
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) {
|
|
||||||
TranscriptDivider(compactionSummary(item), commandColor, modifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What to say about a compaction: the two sizes, and nothing else.
|
|
||||||
*
|
|
||||||
* The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer
|
|
||||||
* to why the wait was worth it. When they were not reported this says only that a compaction
|
|
||||||
* happened, rather than filling in a plausible number.
|
|
||||||
*/
|
|
||||||
fun compactionSummary(item: TranscriptItem.CompactedNote): String {
|
|
||||||
val pre = item.preTokens
|
|
||||||
val post = item.postTokens
|
|
||||||
return if (pre != null && post != null) {
|
|
||||||
"Compacted • ${tokens(pre)} → ${tokens(post)} tok"
|
|
||||||
} else {
|
|
||||||
"Compacted"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A token count as a reader reads one.
|
|
||||||
*
|
|
||||||
* Shared with the status row rather than formatted at each: the divider and the row report the same
|
|
||||||
* quantity about the same moment, and one grouping its thousands while the other did not read as
|
|
||||||
* two different measurements.
|
|
||||||
*/
|
|
||||||
fun tokens(count: Long): String = "%,d".format(count)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the working indicator says while a compaction is running.
|
|
||||||
*
|
|
||||||
* Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a
|
|
||||||
* compaction has begun and then says nothing until it has finished, so any bar or estimate here
|
|
||||||
* would be this screen's guess wearing a measurement's clothes.
|
|
||||||
*
|
|
||||||
* [seconds] is null when this device did not see the compaction start, which is what opening a
|
|
||||||
* session that is already compacting looks like. That case says only "compacting": a number counted
|
|
||||||
* from the moment the screen opened would be wrong in the direction that matters.
|
|
||||||
*/
|
|
||||||
fun compactingLabel(seconds: Long?): String =
|
|
||||||
when {
|
|
||||||
seconds == null -> "compacting"
|
|
||||||
seconds < 60 -> "compacting ${seconds}s"
|
|
||||||
else -> "compacting ${seconds / 60}m ${seconds % 60}s"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How full the session is, as the status row says it.
|
|
||||||
*
|
|
||||||
* Three states, not two, and the third is the one that needed the words: a session whose occupancy
|
|
||||||
* is known and whose ceiling is not. That one keeps the bare figure, and a session with a ceiling
|
|
||||||
* gets both — the reader can see which they are looking at. What must not happen is a missing
|
|
||||||
* ceiling drawn as a number, or as a proportion of some assumed window, which would be this screen
|
|
||||||
* inventing the very fact it does not have.
|
|
||||||
*
|
|
||||||
* A llama.cpp session always has one, since the window is a flag its own server was started with. A
|
|
||||||
* coding CLI's is the vendor's business and neither control protocol states it, so those keep the
|
|
||||||
* bare figure they have always had.
|
|
||||||
*/
|
|
||||||
fun contextLabel(held: Long?, limit: Long?): String =
|
|
||||||
when {
|
|
||||||
held == null -> "context unknown"
|
|
||||||
limit == null -> "context ${tokens(held)}"
|
|
||||||
else -> "context ${tokens(held)} / ${tokens(limit)}"
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import java.io.File
|
|
||||||
import java.io.PrintWriter
|
|
||||||
import java.io.StringWriter
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The last crash, kept so the debug button can hand it over.
|
|
||||||
*
|
|
||||||
* The alternative is asking somebody to reproduce a crash with the phone plugged into a computer
|
|
||||||
* and `logcat` running, which is the one thing nobody has set up at the moment it happens. This
|
|
||||||
* costs one file write on a process that is already dying, and it turns "it crashes when I open
|
|
||||||
* that chat" into the frame it crashed in.
|
|
||||||
*
|
|
||||||
* Kept until it is read rather than cleared on the next launch: the app restarts before anybody can
|
|
||||||
* ask about it, so a log that lives for one session is a log that is never read.
|
|
||||||
*/
|
|
||||||
private const val CRASH_FILE = "last-crash.txt"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much of a stack is kept.
|
|
||||||
*
|
|
||||||
* This is pasted into a conversation, so it has a budget like any other output written for a
|
|
||||||
* reader. The top of a stack is what identifies a crash and the bottom is framework plumbing.
|
|
||||||
*/
|
|
||||||
private const val CRASH_LIMIT = 4000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records uncaught exceptions, then lets the platform do what it was going to do.
|
|
||||||
*
|
|
||||||
* Chained rather than replacing: the default handler is what shows the "app has stopped" dialog and
|
|
||||||
* ends the process, and an app that swallows that instead sits there in an unknown state.
|
|
||||||
*/
|
|
||||||
fun installCrashLog(context: Context) {
|
|
||||||
val app = context.applicationContext
|
|
||||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
|
|
||||||
runCatching { File(app.filesDir, CRASH_FILE).writeText(describe(thread, error)) }
|
|
||||||
previous?.uncaughtException(thread, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun describe(thread: Thread, error: Throwable): String {
|
|
||||||
val when_ = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
|
|
||||||
val stack = StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString()
|
|
||||||
val kept =
|
|
||||||
if (stack.length <= CRASH_LIMIT) stack
|
|
||||||
else stack.take(CRASH_LIMIT) + "\n ... ${stack.length - CRASH_LIMIT} more characters"
|
|
||||||
return "$when_ on thread ${thread.name}\n$kept"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The last crash, or null if there has not been one since it was last read. */
|
|
||||||
fun lastCrash(context: Context): String? =
|
|
||||||
File(context.applicationContext.filesDir, CRASH_FILE).takeIf { it.exists() }?.readText()
|
|
||||||
|
|
||||||
/** Forgets the last crash, once somebody has taken a copy of it. */
|
|
||||||
fun clearCrash(context: Context) {
|
|
||||||
File(context.applicationContext.filesDir, CRASH_FILE).delete()
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Counters and timers for the work the transcript does, for the readout behind the debug button.
|
|
||||||
*
|
|
||||||
* Here because the emulator cannot answer the question this is for. Its own scroll sits at the same
|
|
||||||
* frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost
|
|
||||||
* is under the floor of what it can measure. Counts do not have that problem: how many times a row
|
|
||||||
* was composed, or a reply parsed, is the same number on any machine, and it is the number that
|
|
||||||
* says whether the work is proportional to what is on screen or to everything ever loaded.
|
|
||||||
*
|
|
||||||
* Always on rather than behind a build flag. What is measured is an atomic increment on paths that
|
|
||||||
* already allocate lists and parse markdown, and a counter that is only compiled into the build
|
|
||||||
* nobody is holding when it is slow is not an instrument.
|
|
||||||
*/
|
|
||||||
object DebugStats {
|
|
||||||
private val counts = ConcurrentHashMap<String, AtomicLong>()
|
|
||||||
private val nanos = ConcurrentHashMap<String, AtomicLong>()
|
|
||||||
private val worst = ConcurrentHashMap<String, AtomicLong>()
|
|
||||||
|
|
||||||
private fun at(map: ConcurrentHashMap<String, AtomicLong>, name: String) =
|
|
||||||
map.computeIfAbsent(name) { AtomicLong() }
|
|
||||||
|
|
||||||
fun count(name: String, by: Long = 1) {
|
|
||||||
at(counts, name).addAndGet(by)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keeps [name] at the largest value it has been given, for a high-water mark. */
|
|
||||||
fun atLeast(name: String, value: Long) {
|
|
||||||
val slot = at(counts, name)
|
|
||||||
while (true) {
|
|
||||||
val had = slot.get()
|
|
||||||
if (value <= had || slot.compareAndSet(had, value)) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Records one occurrence of [name] that took [elapsed] nanoseconds. */
|
|
||||||
fun record(name: String, elapsed: Long) {
|
|
||||||
count(name)
|
|
||||||
at(nanos, name).addAndGet(elapsed)
|
|
||||||
val slot = at(worst, name)
|
|
||||||
while (true) {
|
|
||||||
val had = slot.get()
|
|
||||||
if (elapsed <= had || slot.compareAndSet(had, elapsed)) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T> timed(name: String, body: () -> T): T {
|
|
||||||
val started = System.nanoTime()
|
|
||||||
try {
|
|
||||||
return body()
|
|
||||||
} finally {
|
|
||||||
record(name, System.nanoTime() - started)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
counts.clear()
|
|
||||||
nanos.clear()
|
|
||||||
worst.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One line per counter: how many, how long in total, and the worst single one. */
|
|
||||||
fun lines(): List<String> =
|
|
||||||
counts.keys.sorted().map { name ->
|
|
||||||
val n = counts[name]?.get() ?: 0
|
|
||||||
val total = nanos[name]?.get() ?: 0
|
|
||||||
if (total == 0L) " $name: $n"
|
|
||||||
else
|
|
||||||
" $name: $n, ${ms(total)}ms total, ${ms(total / n.coerceAtLeast(1))}ms mean," +
|
|
||||||
" ${ms(worst[name]?.get() ?: 0)}ms worst"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How long everything named [name] took in total, or zero if it never happened. */
|
|
||||||
fun nanosOf(name: String): Long = nanos[name]?.get() ?: 0
|
|
||||||
|
|
||||||
private fun ms(nanos: Long) = "%.1f".format(nanos / 1_000_000.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much of the frame's draw phase is this app's own work, and how much is not.
|
|
||||||
*
|
|
||||||
* The draw phase is where Compose's measurement lands as well as its recording -- the platform
|
|
||||||
* calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three
|
|
||||||
* different things is high. The transcript times its own measure, placement and recording, and this
|
|
||||||
* is the subtraction. What is left over is the framework's per-frame bookkeeping after a layout,
|
|
||||||
* which grows with how many nodes are alive rather than how many are on screen.
|
|
||||||
*
|
|
||||||
* Per frame rather than in total, because the budget it has to fit in is per frame. The recordings
|
|
||||||
* are not themselves per-frame, so these are shares of an average frame.
|
|
||||||
*/
|
|
||||||
fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
|
|
||||||
if (frames == 0 || drawNanos == 0L) return emptyList()
|
|
||||||
val measure = DebugStats.nanosOf("measure: the whole transcript")
|
|
||||||
val place = DebugStats.nanosOf("place: the whole transcript")
|
|
||||||
// The rows and blocks record *inside* this one, so adding them too would count them twice.
|
|
||||||
val record = DebugStats.nanosOf("draw: the whole transcript")
|
|
||||||
val ours = measure + place + record
|
|
||||||
val rest = (drawNanos - ours).coerceAtLeast(0)
|
|
||||||
fun per(n: Long) = "%.2f".format(n / 1_000_000.0 / frames)
|
|
||||||
return listOf(
|
|
||||||
" draw phase ${per(drawNanos)}ms per frame, of which:",
|
|
||||||
" the transcript: ${per(ours)}ms" +
|
|
||||||
" (measure ${per(measure)}, place ${per(place)}, record ${per(record)})",
|
|
||||||
" everything else: ${per(rest)}ms" +
|
|
||||||
" (${if (drawNanos == 0L) "n/a" else "${rest * 100 / drawNanos}%"})",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Everything the debug button copies: what the device is, what the transcript is holding, where the
|
|
||||||
* frames went, and what the app did to produce them.
|
|
||||||
*
|
|
||||||
* Written for somebody to paste into a conversation, so it is plain text with the units on every
|
|
||||||
* number -- a report whose reader has to ask what the columns mean costs another round trip.
|
|
||||||
*/
|
|
||||||
fun debugReport(
|
|
||||||
device: String,
|
|
||||||
transcript: List<String>,
|
|
||||||
frames: List<String>,
|
|
||||||
accounting: List<String>,
|
|
||||||
crash: String?,
|
|
||||||
): String = buildString {
|
|
||||||
appendLine("ai-app render report")
|
|
||||||
appendLine(device)
|
|
||||||
appendLine()
|
|
||||||
// First, because a crash outranks every timing below it and the reader should not have to
|
|
||||||
// scroll past two screens of counters to find out the app fell over.
|
|
||||||
if (crash != null) {
|
|
||||||
appendLine("last crash:")
|
|
||||||
crash.trimEnd().lines().forEach { appendLine(" $it") }
|
|
||||||
appendLine()
|
|
||||||
}
|
|
||||||
appendLine("transcript:")
|
|
||||||
transcript.forEach { appendLine(it) }
|
|
||||||
appendLine()
|
|
||||||
appendLine("frames:")
|
|
||||||
frames.forEach { appendLine(it) }
|
|
||||||
appendLine()
|
|
||||||
if (accounting.isNotEmpty()) {
|
|
||||||
appendLine("where the draw phase went:")
|
|
||||||
accounting.forEach { appendLine(it) }
|
|
||||||
appendLine()
|
|
||||||
}
|
|
||||||
appendLine("work since this was last copied:")
|
|
||||||
val work = DebugStats.lines()
|
|
||||||
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
|
|
||||||
fun Context.copyToClipboard(label: String, text: String) {
|
|
||||||
getSystemService<ClipboardManager>()?.setPrimaryClip(ClipData.newPlainText(label, text))
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
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.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A line across the transcript saying what left the session's context.
|
|
||||||
*
|
|
||||||
* Centred between two rules, because it is a divider rather than something anybody said. Two things
|
|
||||||
* produce one -- a compaction and a clear -- and they are drawn the same way on purpose: to a
|
|
||||||
* reader scrolling back, both mean "the session no longer has what is above this", and which of the
|
|
||||||
* two it was is said by the words and the colour.
|
|
||||||
*
|
|
||||||
* The rules take [color] too, so the whole divider reads as one mark of one kind.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
HorizontalDivider(Modifier.weight(1f), color = color)
|
|
||||||
Text(text, style = MaterialTheme.typography.bodySmall, color = color)
|
|
||||||
HorizontalDivider(Modifier.weight(1f), color = color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a
|
|
||||||
* compaction it summarises nothing and measures nothing. Everything above stays on screen and stays
|
|
||||||
* scrollable -- the reader can see that, which is why this does not say it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ClearedRow(modifier: Modifier = Modifier) {
|
|
||||||
TranscriptDivider("Context cleared", clearedColor, modifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The mark running out of quota leaves.
|
|
||||||
*
|
|
||||||
* The same red the usage bar takes when a window is spent, because it is the same fact in a second
|
|
||||||
* place: colour by consequence, so "there is nothing left to spend" is learned once.
|
|
||||||
*
|
|
||||||
* A time rather than a countdown. The row is folded once and never re-measured, so a span would go
|
|
||||||
* stale on screen the moment it was drawn; and this is when the *account* said it would reset,
|
|
||||||
* which is not a promise about when the session picks back up. A limit the session was told no
|
|
||||||
* reset time for says nothing about one -- that state has its own words rather than a plausible
|
|
||||||
* number.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) {
|
|
||||||
TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the row says. Split out so the wording is testable without a screen, since the two states it
|
|
||||||
* has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair
|
|
||||||
* that reads the same when it goes wrong.
|
|
||||||
*
|
|
||||||
* [zone] is a parameter rather than read here so a test says the same thing wherever it runs.
|
|
||||||
*/
|
|
||||||
fun limitSummary(resetsAt: Double?, zone: ZoneId): String {
|
|
||||||
val at = resetsAt?.let {
|
|
||||||
try {
|
|
||||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
|
||||||
.withZone(zone)
|
|
||||||
.format(Instant.ofEpochSecond(it.toLong()))
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at"
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.core.content.edit
|
|
||||||
|
|
||||||
private const val DRAFTS = "session-drafts"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A message typed into a session and not sent yet.
|
|
||||||
*
|
|
||||||
* On this device rather than on the backend, which is where this app otherwise keeps state so that
|
|
||||||
* every device sees it. A draft is the case that rule is not about: it is the contents of a text
|
|
||||||
* box on the phone somebody is holding, and half a sentence surfacing on another device would be a
|
|
||||||
* surprise. What has been *sent* is the server's.
|
|
||||||
*
|
|
||||||
* Kept per session id: one shared box would hand a message meant for one session to whichever was
|
|
||||||
* opened next.
|
|
||||||
*/
|
|
||||||
fun loadDraft(context: Context, sessionId: String): String =
|
|
||||||
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).getString(sessionId, "").orEmpty()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records [text] as the draft for [sessionId], or forgets it when there is nothing left to keep.
|
|
||||||
*
|
|
||||||
* The path out is emptying the box, which is what sending does. A session *deleted* while it held a
|
|
||||||
* draft does leave its key behind: pruning those means a pass over the live session list, and the
|
|
||||||
* residue is a few bytes per session ever abandoned mid-sentence.
|
|
||||||
*/
|
|
||||||
fun saveDraft(context: Context, sessionId: String, text: String) {
|
|
||||||
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).edit {
|
|
||||||
if (text.isEmpty()) remove(sessionId) else putString(sessionId, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A span of milliseconds, written the way somebody reads it.
|
|
||||||
*
|
|
||||||
* A tool's timeout arrives as `480000`, which nobody reads as eight minutes. The rule has two
|
|
||||||
* halves, because a short span and a long one are read for different things. Under a minute the
|
|
||||||
* question is "roughly how long", so only the largest unit is shown and a fraction carries the rest
|
|
||||||
* -- `2.5s`. At a minute or more the question is "how long exactly", so every unit with something
|
|
||||||
* in it is written out -- `5d 12h 4m`. Empty units are left out rather than written as zero.
|
|
||||||
*
|
|
||||||
* Sub-second precision is dropped past a minute: nothing that takes days is measured in
|
|
||||||
* milliseconds.
|
|
||||||
*/
|
|
||||||
fun formatMillis(ms: Long): String {
|
|
||||||
if (ms < 0) return "-" + formatMillis(-ms)
|
|
||||||
if (ms < 1000) return "${ms}ms"
|
|
||||||
if (ms < 60_000) {
|
|
||||||
val tenths = (ms + 50) / 100
|
|
||||||
val whole = tenths / 10
|
|
||||||
val rest = tenths % 10
|
|
||||||
return if (rest == 0L) "${whole}s" else "$whole.${rest}s"
|
|
||||||
}
|
|
||||||
val seconds = ms / 1000
|
|
||||||
val parts =
|
|
||||||
listOf(
|
|
||||||
"d" to seconds / 86_400,
|
|
||||||
"h" to seconds / 3600 % 24,
|
|
||||||
"m" to seconds / 60 % 60,
|
|
||||||
"s" to seconds % 60,
|
|
||||||
)
|
|
||||||
return parts.filter { it.second > 0 }.joinToString(" ") { "${it.second}${it.first}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [text] as a span when it is a whole number of milliseconds, and unchanged when it is not. */
|
|
||||||
fun formatMillisText(text: String): String =
|
|
||||||
text.trim().toLongOrNull()?.let { formatMillis(it) } ?: text
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The frame name the server uses to say a cursor was too far behind to continue from. Must match
|
|
||||||
* `send_backlog` in the backend's routes.rs.
|
|
||||||
*/
|
|
||||||
private const val RESET_EVENT = "reset"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The SSE half of the API: one long-lived GET per open session screen, replaying the transcript
|
|
||||||
* after a cursor and then following it live.
|
|
||||||
*
|
|
||||||
* The connection and its framing belong to [Sse]; what stays here is what this stream's frames
|
|
||||||
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
|
|
||||||
* saw as the new cursor.
|
|
||||||
*/
|
|
||||||
class EventStream(settings: ServerSettings, private val address: TranscriptAddress) {
|
|
||||||
private val stream = Sse(settings)
|
|
||||||
|
|
||||||
fun close() = stream.close()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Streams events after [after] into [onEvent] until the stream drops.
|
|
||||||
*
|
|
||||||
* [onReset] fires when the server answers that the cursor is too far behind to continue from:
|
|
||||||
* everything already displayed is stale and the events that follow are a fresh window, so the
|
|
||||||
* caller drops what it holds and rebuilds. It arrives before those events, so a caller that
|
|
||||||
* clears on it stays in order.
|
|
||||||
*/
|
|
||||||
fun run(
|
|
||||||
after: Long,
|
|
||||||
onOpen: () -> Unit,
|
|
||||||
onReset: () -> Unit,
|
|
||||||
// The frame's own text as well as the event parsed from it: the transcript cache stores the
|
|
||||||
// one and the screen folds the other, and they have to be the same line.
|
|
||||||
onEvent: (raw: String, event: SeqEvent) -> Unit,
|
|
||||||
) {
|
|
||||||
stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
|
|
||||||
// A named frame carries no payload and a data frame has no name.
|
|
||||||
if (name == RESET_EVENT) onReset()
|
|
||||||
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
// The common event model, mirrored from server/src/session/driver.rs -- the app renders purely from
|
|
||||||
// this stream (replayed from the transcript by cursor, then live), so there is no separate "load
|
|
||||||
// history" shape to keep in sync with it.
|
|
||||||
|
|
||||||
/** One transcript line: the event plus its resume cursor and time. */
|
|
||||||
data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One choice offered in answer to a question.
|
|
||||||
*
|
|
||||||
* More than a label because the reader is deciding rather than confirming. Both are absent on a
|
|
||||||
* permission, whose Allow and Deny mean exactly what they say.
|
|
||||||
*/
|
|
||||||
data class QuestionOption(val label: String, val description: String?, val preview: String?)
|
|
||||||
|
|
||||||
sealed class SessionEvent {
|
|
||||||
data class UserMessage(
|
|
||||||
val text: String,
|
|
||||||
/**
|
|
||||||
* The [MessageQueued] this resolves, or null when it never waited.
|
|
||||||
*
|
|
||||||
* Matched on rather than the text, because the same message sent twice is two waiting
|
|
||||||
* bubbles and clearing whichever one matched first would leave the wrong one on screen.
|
|
||||||
*/
|
|
||||||
val id: String?,
|
|
||||||
/**
|
|
||||||
* What was attached to it, by the ref the files route serves: images, and any file, told
|
|
||||||
* apart by [isImageRef].
|
|
||||||
*
|
|
||||||
* On the message rather than beside it: these arrived as separate image events until
|
|
||||||
* 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent
|
|
||||||
* it, and left this app deciding from adjacency which message an image went with.
|
|
||||||
*/
|
|
||||||
val attachments: List<String>,
|
|
||||||
) : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A message the server has accepted and the session has not read yet.
|
|
||||||
*
|
|
||||||
* From the server, not from this app's memory of what it sent. The pending bubble used to be
|
|
||||||
* screen state, so leaving the session drew nothing waiting while the message was still queued
|
|
||||||
* -- and nothing waiting is what "there is nothing" looks like.
|
|
||||||
*
|
|
||||||
* Resolved by the [UserMessage] carrying the same id.
|
|
||||||
*/
|
|
||||||
data class MessageQueued(val id: String, val text: String, val attachments: List<String>) :
|
|
||||||
SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A queued message taken back before the session read it.
|
|
||||||
*
|
|
||||||
* Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects
|
|
||||||
* replays both, and without this one it would put back a bubble for a message that is never
|
|
||||||
* coming.
|
|
||||||
*/
|
|
||||||
data class MessageDropped(val id: 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 ToolUpdate(val id: String, val output: String) : SessionEvent()
|
|
||||||
|
|
||||||
data class ToolEnd(val id: String, val output: String) : SessionEvent()
|
|
||||||
|
|
||||||
data class Image(
|
|
||||||
val ref: String,
|
|
||||||
/** The tool call whose result carried it, or null for a person's own attachment. */
|
|
||||||
val about: String?,
|
|
||||||
) : SessionEvent()
|
|
||||||
|
|
||||||
data class Question(
|
|
||||||
val id: String,
|
|
||||||
val prompt: String,
|
|
||||||
/** A few words naming what the question is about, when the asker offered one. */
|
|
||||||
val header: String?,
|
|
||||||
val options: List<QuestionOption>,
|
|
||||||
/** Whether several options may be chosen at once. */
|
|
||||||
val multiSelect: Boolean,
|
|
||||||
/** The tool call this is permission for, or null when it is not about one. */
|
|
||||||
val about: String?,
|
|
||||||
) : SessionEvent()
|
|
||||||
|
|
||||||
/** Everything chosen for one question, in the order it was offered. */
|
|
||||||
data class Answered(val id: String, val answers: List<String>) : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A message another agent sent this session.
|
|
||||||
*
|
|
||||||
* Not a [UserMessage]: nobody holding the phone said it, and drawing it in their voice would
|
|
||||||
* claim they had. It is also the explanation for a session that starts working on something
|
|
||||||
* this device never asked for.
|
|
||||||
*/
|
|
||||||
data class PeerMessage(
|
|
||||||
val from: String,
|
|
||||||
val text: String,
|
|
||||||
/**
|
|
||||||
* Where the turn this started begins, when the server could say.
|
|
||||||
*
|
|
||||||
* The live Claude Code path only learns a turn was somebody else's when the turn ends, so
|
|
||||||
* the event arrives below everything it caused; this is what puts it back above it. Null
|
|
||||||
* for a message read out of a session file, and for one that started no turn.
|
|
||||||
*/
|
|
||||||
val turnStart: Long? = null,
|
|
||||||
) : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A line in the transcript this build cannot read: a kind a newer server wrote, or one an older
|
|
||||||
* server wrote that has since been dropped.
|
|
||||||
*
|
|
||||||
* [kind] is the word the line called itself, so the row can say what is missing rather than
|
|
||||||
* that something is. The server makes these when reading; no driver sends one.
|
|
||||||
*/
|
|
||||||
data class Unreadable(val kind: String) : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retired on 2026-09-06, hours after it was added: a background task finishing, which turned
|
|
||||||
* out to be a screenful of notices about work nobody was asking after.
|
|
||||||
*
|
|
||||||
* Kept because a transcript is append-only -- the sessions that ran a background task in that
|
|
||||||
* window have these lines for ever. It draws no row, which is the whole reason it is still
|
|
||||||
* named here rather than left to fall through to [Unknown]: that would draw a placeholder per
|
|
||||||
* background task, which is the same wall the row was removed for.
|
|
||||||
*/
|
|
||||||
object RetiredTaskNote : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command the session was asked to run on itself and cannot run yet. Resolved by
|
|
||||||
* [CommandSent] with the same id; a command that ran straight away has only that one.
|
|
||||||
*/
|
|
||||||
data class CommandQueued(val id: String, val text: String) : SessionEvent()
|
|
||||||
|
|
||||||
/** The same command, handed to the session. */
|
|
||||||
data class CommandSent(val id: String, val text: String) : SessionEvent()
|
|
||||||
|
|
||||||
/** Provider-reported number of background tasks alive now. */
|
|
||||||
data class BackgroundTasks(val count: Int) : SessionEvent()
|
|
||||||
|
|
||||||
data class Status(val state: String) : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the session is set to, as the session itself reports it.
|
|
||||||
*
|
|
||||||
* Either field alone: the two are confirmed separately. Asking for a change is not having one,
|
|
||||||
* so this -- not the request -- is what the pickers show.
|
|
||||||
*/
|
|
||||||
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a turn cost, and how much the model was holding when it ended.
|
|
||||||
*
|
|
||||||
* [context] is prompt plus both cache figures. Carried on the event rather than summed by the
|
|
||||||
* reader, because it is not a sum: a conversation's context drops at a compaction and a clear,
|
|
||||||
* so adding turns up would report a figure the session stopped being true of. Null where the
|
|
||||||
* dialect did not say, which leaves the context unmeasured rather than unchanged.
|
|
||||||
*/
|
|
||||||
data class UsageDelta(
|
|
||||||
val tokens: Long,
|
|
||||||
val context: Long?,
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* The counts are nullable because the server sends them only when it was told them: a zero here
|
|
||||||
* would read as "recovered nothing" and a made-up number would read as a measurement.
|
|
||||||
*/
|
|
||||||
data class Compacted(
|
|
||||||
val preTokens: Long?,
|
|
||||||
val postTokens: Long?,
|
|
||||||
/** What asked for it, in the CLI's own word; `auto` is the one worth naming. */
|
|
||||||
val trigger: String?,
|
|
||||||
) : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The conversation was cleared. Everything above this is still here to read and is no longer in
|
|
||||||
* the session's context. An object rather than a class because what it means is entirely its
|
|
||||||
* position in the transcript.
|
|
||||||
*/
|
|
||||||
data object Cleared : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The session stopped because its account's usage limit was reached.
|
|
||||||
*
|
|
||||||
* Its own event rather than an [Error] carrying the CLI's sentence, because it is a state
|
|
||||||
* rather than something that went wrong -- and because the raw sentence is `Claude AI usage
|
|
||||||
* limit reached|1788546972`, which is not readable by the person it is shown to.
|
|
||||||
*
|
|
||||||
* [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts
|
|
||||||
* on it; what this draws it as is a time, not a countdown, because nothing here re-measures it.
|
|
||||||
*/
|
|
||||||
data class LimitReached(val resetsAt: Double?) : SessionEvent()
|
|
||||||
|
|
||||||
data class AuthenticationRequired(val message: String) : SessionEvent()
|
|
||||||
|
|
||||||
data class Error(val message: String) : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An event type this app build doesn't know -- a newer server. Kept rather than thrown so one
|
|
||||||
* new event kind degrades to a placeholder row instead of killing the stream.
|
|
||||||
*/
|
|
||||||
data class Unknown(val type: String) : SessionEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A JSON array of strings under [name], empty when the field is absent -- the ordinary case, since
|
|
||||||
* the server omits the field rather than sending an empty list.
|
|
||||||
*/
|
|
||||||
private fun JSONObject.stringList(name: String): List<String> {
|
|
||||||
val array = optJSONArray(name) ?: return emptyList()
|
|
||||||
return (0 until array.length()).map { array.getString(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseSeqEvent(json: String): SeqEvent {
|
|
||||||
val body = JSONObject(json)
|
|
||||||
val event =
|
|
||||||
when (val type = body.getString("type")) {
|
|
||||||
"userMessage" ->
|
|
||||||
SessionEvent.UserMessage(
|
|
||||||
body.getString("text"),
|
|
||||||
body.optString("id").ifEmpty { null },
|
|
||||||
body.stringList("attachments"),
|
|
||||||
)
|
|
||||||
"messageQueued" ->
|
|
||||||
SessionEvent.MessageQueued(
|
|
||||||
body.getString("id"),
|
|
||||||
body.getString("text"),
|
|
||||||
body.stringList("attachments"),
|
|
||||||
)
|
|
||||||
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
|
|
||||||
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
|
|
||||||
"assistantTextFinal" -> SessionEvent.AssistantTextFinal(body.getString("text"))
|
|
||||||
"thinking" -> SessionEvent.Thinking(body.getString("delta"))
|
|
||||||
"thinkingDone" -> SessionEvent.ThinkingDone(body.getLong("ms"))
|
|
||||||
"toolStart" ->
|
|
||||||
SessionEvent.ToolStart(
|
|
||||||
id = body.getString("id"),
|
|
||||||
tool = body.getString("tool"),
|
|
||||||
// Kept as raw JSON text: the input shape is the tool's own business, and the UI
|
|
||||||
// only ever shows it verbatim.
|
|
||||||
input = body.get("input").toString(),
|
|
||||||
)
|
|
||||||
"toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output"))
|
|
||||||
"toolEnd" -> SessionEvent.ToolEnd(body.getString("id"), body.getString("output"))
|
|
||||||
"image" ->
|
|
||||||
SessionEvent.Image(
|
|
||||||
ref = body.getString("ref"),
|
|
||||||
about = body.optString("about").ifEmpty { null },
|
|
||||||
)
|
|
||||||
"question" ->
|
|
||||||
SessionEvent.Question(
|
|
||||||
id = body.getString("id"),
|
|
||||||
prompt = body.getString("prompt"),
|
|
||||||
header = body.optString("header").ifEmpty { null },
|
|
||||||
options =
|
|
||||||
body.getJSONArray("options").let { options ->
|
|
||||||
(0 until options.length()).map { at ->
|
|
||||||
val option = options.getJSONObject(at)
|
|
||||||
QuestionOption(
|
|
||||||
label = option.getString("label"),
|
|
||||||
description = option.optString("description").ifEmpty { null },
|
|
||||||
preview = option.optString("preview").ifEmpty { null },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
multiSelect = body.optBoolean("multiSelect", false),
|
|
||||||
about = body.optString("about").ifEmpty { null },
|
|
||||||
)
|
|
||||||
"answered" ->
|
|
||||||
SessionEvent.Answered(
|
|
||||||
body.getString("id"),
|
|
||||||
body.getJSONArray("answers").let { answers ->
|
|
||||||
(0 until answers.length()).map { answers.getString(it) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
"peerMessage" ->
|
|
||||||
SessionEvent.PeerMessage(
|
|
||||||
body.getString("from"),
|
|
||||||
body.getString("text"),
|
|
||||||
if (body.has("turnStart")) body.getLong("turnStart") else null,
|
|
||||||
)
|
|
||||||
"unreadable" -> SessionEvent.Unreadable(body.getString("kind"))
|
|
||||||
"taskNote" -> SessionEvent.RetiredTaskNote
|
|
||||||
"commandQueued" ->
|
|
||||||
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
|
|
||||||
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
|
|
||||||
"backgroundTasks" -> SessionEvent.BackgroundTasks(body.getInt("count"))
|
|
||||||
"status" -> SessionEvent.Status(body.getString("state"))
|
|
||||||
"settings" ->
|
|
||||||
SessionEvent.Settings(
|
|
||||||
model = body.optString("model").ifEmpty { null },
|
|
||||||
permissionMode = body.optString("permissionMode").ifEmpty { null },
|
|
||||||
)
|
|
||||||
"contextWindow" -> SessionEvent.ContextWindow(body.getLong("tokens"))
|
|
||||||
"usageDelta" ->
|
|
||||||
SessionEvent.UsageDelta(
|
|
||||||
body.getLong("tokens"),
|
|
||||||
if (body.has("context")) body.getLong("context") else null,
|
|
||||||
if (body.has("tokensPerSecond")) body.getDouble("tokensPerSecond") else null,
|
|
||||||
if (body.has("prefillMs")) body.getLong("prefillMs") else null,
|
|
||||||
)
|
|
||||||
"compacted" ->
|
|
||||||
SessionEvent.Compacted(
|
|
||||||
preTokens = if (body.has("preTokens")) body.getLong("preTokens") else null,
|
|
||||||
postTokens = if (body.has("postTokens")) body.getLong("postTokens") else null,
|
|
||||||
trigger = body.optString("trigger").ifEmpty { null },
|
|
||||||
)
|
|
||||||
"cleared" -> SessionEvent.Cleared
|
|
||||||
"limitReached" ->
|
|
||||||
SessionEvent.LimitReached(
|
|
||||||
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
|
|
||||||
)
|
|
||||||
"authenticationRequired" ->
|
|
||||||
SessionEvent.AuthenticationRequired(body.getString("message"))
|
|
||||||
"error" -> SessionEvent.Error(body.getString("message"))
|
|
||||||
else -> SessionEvent.Unknown(type)
|
|
||||||
}
|
|
||||||
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
|
|
||||||
*
|
|
||||||
* One predicate because two readers have to agree on the list: the session screen's working
|
|
||||||
* indicator, and the fold's decision that the newest reply is finished. Two copies would drift the
|
|
||||||
* first time the server grows a state, and the drift would be a reply that never splits or one
|
|
||||||
* split mid-stream.
|
|
||||||
*/
|
|
||||||
fun sessionWorking(state: String): Boolean =
|
|
||||||
state == "running" || state == "compacting" || 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 same rule the server folds with, because the screen has to keep up between page loads: the
|
|
||||||
* summary it opened with is a measurement from before this stream started.
|
|
||||||
*
|
|
||||||
* The two that lower it are the point. A clear takes the conversation away and a compaction
|
|
||||||
* replaces it with a summary, so a figure measured before either stopped being true at that moment
|
|
||||||
* -- and carrying it forward is how a session that had just been cleared went on reporting the
|
|
||||||
* context it no longer had.
|
|
||||||
*
|
|
||||||
* Null is "we don't know", which each of them can reach.
|
|
||||||
*/
|
|
||||||
fun contextAfter(current: Long?, event: SessionEvent): Long? =
|
|
||||||
when (event) {
|
|
||||||
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a turn
|
|
||||||
// -- which every context figure is -- rather than unknown.
|
|
||||||
is SessionEvent.UsageDelta -> event.context ?: current
|
|
||||||
is SessionEvent.Compacted -> event.postTokens
|
|
||||||
is SessionEvent.Cleared -> null
|
|
||||||
else -> current
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The context window after [event], mirroring the server's `context_limit_after` for the same
|
|
||||||
* reason [contextAfter] mirrors its neighbour: the screen has to keep up between page loads.
|
|
||||||
*
|
|
||||||
* A window belongs to the process, so a session whose process has exited has none — left standing,
|
|
||||||
* a session restarted on a different model would draw its occupancy against the old model's
|
|
||||||
* ceiling.
|
|
||||||
*/
|
|
||||||
fun contextLimitAfter(current: Long?, event: SessionEvent): Long? =
|
|
||||||
when (event) {
|
|
||||||
is SessionEvent.ContextWindow -> event.tokens
|
|
||||||
is SessionEvent.Status -> if (event.state == "exited") null else current
|
|
||||||
else -> current
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.text.input.OffsetMapping
|
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.text.input.TransformedText
|
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The largest file this app will open in the editor, in bytes.
|
|
||||||
*
|
|
||||||
* Measured on the emulator 2026-09-04, in a debug build, on generated Rust:
|
|
||||||
*
|
|
||||||
* | file | lines | scan per keystroke | worst frame record | typing |
|
|
||||||
* |--------|--------|--------------------|--------------------|-------------------|
|
|
||||||
* | 32 kB | 917 | 10ms | 183ms | sluggish, correct |
|
|
||||||
* | 128 kB | 3,633 | 40ms | 2,027ms | characters lost |
|
|
||||||
* | 1 MB | 28,660 | -- | -- | stops responding |
|
|
||||||
*
|
|
||||||
* The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file
|
|
||||||
* costs 40ms a keystroke, which is survivable, while laying the same text out in one
|
|
||||||
* `BasicTextField` costs two seconds. So switching highlighting off above a size -- what
|
|
||||||
* EXPLORER.md expected to have to decide -- would not have saved it; every arrangement of a single
|
|
||||||
* text field pays that cost. A line-by-line editor is the way past this.
|
|
||||||
*
|
|
||||||
* 32 kB because it is the largest size actually measured as usable. The viewer's own limit stays
|
|
||||||
* the server's `FILE_LIMIT` of 1 MiB: reading a big file is fine, and only editing one is not.
|
|
||||||
*/
|
|
||||||
const val EDIT_LIMIT = 32L * 1024
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The same file, editable, in the same face and colours it was being read in.
|
|
||||||
*
|
|
||||||
* `BasicTextField(TextFieldValue)` with a [VisualTransformation] is the one Compose arrangement
|
|
||||||
* that colours a field's own text rather than replacing the field with something that only looks
|
|
||||||
* like one: the transformation returns the text unchanged and the scanner's spans as styles, so
|
|
||||||
* [OffsetMapping.Identity] is correct by construction. The newer `TextFieldState` API has no hook
|
|
||||||
* for styles at all.
|
|
||||||
*
|
|
||||||
* The cost is that the whole file is re-scanned on every keystroke, which is what [EDIT_LIMIT] is
|
|
||||||
* sized against.
|
|
||||||
*
|
|
||||||
* The gutter is one `Text` of `1\n2\n…` beside the field rather than a number per row, because
|
|
||||||
* there are no rows here -- the field is one text object. It lines up for the same reason the
|
|
||||||
* viewer's does: nothing wraps, so a logical line is a visual line.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun FileEditor(
|
|
||||||
value: TextFieldValue,
|
|
||||||
onValueChange: (TextFieldValue) -> Unit,
|
|
||||||
language: Language?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val style = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface)
|
|
||||||
val scroll = rememberScrollState()
|
|
||||||
val count = value.text.removeSuffix("\n").count { it == '\n' } + 1
|
|
||||||
val gutter = gutterWidth(count, style)
|
|
||||||
val numbers = remember(count) { (1..count).joinToString("\n") }
|
|
||||||
val transformation =
|
|
||||||
remember(language) {
|
|
||||||
VisualTransformation { text ->
|
|
||||||
TransformedText(highlight(text.text, language), OffsetMapping.Identity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.Top, modifier = modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
numbers,
|
|
||||||
style = style,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.End,
|
|
||||||
softWrap = false,
|
|
||||||
modifier = Modifier.width(gutter),
|
|
||||||
)
|
|
||||||
// The same gap the viewer puts between its numbers and its code, so switching between
|
|
||||||
// reading and editing does not move the text sideways under the reader.
|
|
||||||
Spacer(Modifier.width(GUTTER_GAP))
|
|
||||||
Box(Modifier.horizontalScroll(scroll)) {
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
textStyle = style,
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
||||||
visualTransformation = transformation,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What to do about a file that changed on the machine while it was open here.
|
|
||||||
*
|
|
||||||
* Three ways out rather than one, and each says what it costs, because there is no answer this app
|
|
||||||
* can pick on somebody's behalf: an agent editing the same file is the ordinary case here, and both
|
|
||||||
* versions are somebody's work.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ConflictDialog(
|
|
||||||
message: String,
|
|
||||||
busy: Boolean,
|
|
||||||
onOverwrite: () -> Unit,
|
|
||||||
onReload: () -> Unit,
|
|
||||||
onCancel: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onCancel,
|
|
||||||
// The server's own sentence as the title, rather than a heading of this app's above it
|
|
||||||
// saying the same thing twice: there is one statement of what happened and it comes from
|
|
||||||
// the side that found out.
|
|
||||||
title = { Text(message.replaceFirstChar { it.uppercase() }) },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
"Overwrite keeps what you typed and loses the other change. " +
|
|
||||||
"Reload keeps the other change and loses what you typed. " +
|
|
||||||
"Cancel leaves both alone and keeps you here."
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onOverwrite, enabled = !busy) {
|
|
||||||
Text(if (busy) "Saving..." else "Overwrite")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
Row {
|
|
||||||
TextButton(onClick = onReload, enabled = !busy) { Text("Reload") }
|
|
||||||
TextButton(onClick = onCancel, enabled = !busy) { Text("Cancel") }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Leaving an editor with edits in it, which is the one way to lose them by accident. */
|
|
||||||
@Composable
|
|
||||||
fun UnsavedDialog(onDiscard: () -> Unit, onCancel: () -> Unit) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onCancel,
|
|
||||||
title = { Text("Leave without saving?") },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
"The edits you have made here will be lost. They have not been written to the machine."
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = { TextButton(onClick = onDiscard) { Text("Discard") } },
|
|
||||||
dismissButton = { TextButton(onClick = onCancel) { Text("Keep editing") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A file split into lines, with the highlighter's colours already worked out for each one.
|
|
||||||
*
|
|
||||||
* The pure half of the viewer, so it has a JVM unit test and so [of] can run off the main thread:
|
|
||||||
* scanning a megabyte is work, and doing it inside a composable would do it on the drawing thread
|
|
||||||
* and again on every recomposition.
|
|
||||||
*
|
|
||||||
* Why per line at all: the viewer is a `LazyColumn` of lines rather than one `Text`, because text
|
|
||||||
* layout is linear in the text. That means each row needs *its* colours, and the scanner answers in
|
|
||||||
* offsets into the whole file -- so the spans are bucketed here, once, in one pass.
|
|
||||||
*/
|
|
||||||
class FileLines
|
|
||||||
private constructor(
|
|
||||||
/** The text of each line, without its newline. */
|
|
||||||
val lines: List<String>,
|
|
||||||
/** Per line, the spans that fall in it, with offsets relative to that line's start. */
|
|
||||||
private val spans: List<List<Span>>,
|
|
||||||
/**
|
|
||||||
* The longest line, in character columns -- what the viewer sizes every row to.
|
|
||||||
*
|
|
||||||
* Every row has to be the *same* width or they scroll sideways by different amounts; see
|
|
||||||
* [FileViewer]. Columns rather than measured pixels because the face is monospace, so one
|
|
||||||
* number and one character's advance give the width of the widest line without measuring twenty
|
|
||||||
* thousand strings.
|
|
||||||
*/
|
|
||||||
val columns: Int,
|
|
||||||
) {
|
|
||||||
val size: Int
|
|
||||||
get() = lines.size
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One line, coloured. Built when the row is composed rather than up front: a file has far more
|
|
||||||
* lines than a screen shows, and an `AnnotatedString` per line for all of them is the cost the
|
|
||||||
* lazy list exists to avoid.
|
|
||||||
*/
|
|
||||||
fun line(index: Int): AnnotatedString {
|
|
||||||
val text = lines[index]
|
|
||||||
val here = spans[index]
|
|
||||||
if (here.isEmpty()) return AnnotatedString(text)
|
|
||||||
val palette = catppuccinSyntax()
|
|
||||||
return buildAnnotatedString {
|
|
||||||
append(text)
|
|
||||||
here.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* [text] scanned as [language] and cut into lines.
|
|
||||||
*
|
|
||||||
* Exactly one trailing newline is dropped before splitting, so a file that ends the way
|
|
||||||
* text files are supposed to end has the number of lines its author would count -- `wc -l`
|
|
||||||
* agrees. Without that, every well-formed file gained a phantom empty last line. An empty
|
|
||||||
* file is one empty line numbered 1, which is what it is.
|
|
||||||
*/
|
|
||||||
fun of(text: String, language: Language?): FileLines =
|
|
||||||
// Timed, and always, for the reason everything else here is: the cost of opening a
|
|
||||||
// large file is the number that decides whether the server's size limit is right, and
|
|
||||||
// an instrument that is only in the build nobody is running answers nothing.
|
|
||||||
DebugStats.timed("file scanned and cut into lines") {
|
|
||||||
val body = text.removeSuffix("\n")
|
|
||||||
val lines = body.split('\n')
|
|
||||||
val scanned = if (language == null) emptyList() else spansOf(body, language)
|
|
||||||
FileLines(lines, bucket(lines, scanned), lines.maxOf(::columnsOf))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many columns a line occupies.
|
|
||||||
*
|
|
||||||
* A tab counts as eight rather than one, and deliberately upwards: this decides how far the
|
|
||||||
* viewer can scroll, and over-estimating leaves a little empty space past the longest line
|
|
||||||
* where under-estimating makes the end of that line unreachable.
|
|
||||||
*/
|
|
||||||
private fun columnsOf(line: String): Int {
|
|
||||||
var count = 0
|
|
||||||
for (character in line) count += if (character == '\t') 8 else 1
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The scanner's spans, in file offsets, as spans per line in line offsets.
|
|
||||||
*
|
|
||||||
* One walk down both lists, which is what the scanner's guarantee buys: its spans come out
|
|
||||||
* ordered, non-overlapping and inside the text. A span crossing a line break is cut at each
|
|
||||||
* break and appears in each line it covers, because a row is drawn on its own and cannot
|
|
||||||
* inherit a colour from the row above.
|
|
||||||
*/
|
|
||||||
private fun bucket(lines: List<String>, spans: List<Span>): List<List<Span>> {
|
|
||||||
val out = ArrayList<List<Span>>(lines.size)
|
|
||||||
var lineStart = 0
|
|
||||||
var next = 0
|
|
||||||
for (line in lines) {
|
|
||||||
val lineEnd = lineStart + line.length
|
|
||||||
var here: ArrayList<Span>? = null
|
|
||||||
// Spans that ended before this line begins are behind the walk for good.
|
|
||||||
while (next < spans.size && spans[next].end <= lineStart) next++
|
|
||||||
var at = next
|
|
||||||
while (at < spans.size && spans[at].start < lineEnd) {
|
|
||||||
val span = spans[at]
|
|
||||||
val start = maxOf(span.start, lineStart) - lineStart
|
|
||||||
val end = minOf(span.end, lineEnd) - lineStart
|
|
||||||
if (end > start) {
|
|
||||||
(here ?: ArrayList<Span>().also { here = it }).add(
|
|
||||||
Span(start, end, span.kind)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
at++
|
|
||||||
}
|
|
||||||
out.add(here ?: emptyList())
|
|
||||||
// The newline itself, which is in the text and not in any line.
|
|
||||||
lineStart = lineEnd + 1
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.overscroll
|
|
||||||
import androidx.compose.foundation.rememberOverscrollEffect
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
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.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.layout.SubcomposeLayout
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.Constraints
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/** The face every verbatim thing in this app is drawn in, and the one the gutter has to match. */
|
|
||||||
@Composable
|
|
||||||
fun codeStyle(): TextStyle =
|
|
||||||
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [content] scanned off the main thread, then drawn.
|
|
||||||
*
|
|
||||||
* Measured on the emulator 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file (28,660
|
|
||||||
* lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was first
|
|
||||||
* written, that is 460ms of frozen screen at the size the server is willing to send -- long enough
|
|
||||||
* that the accessibility tree cannot be read, which is what "the app has stopped" looks like.
|
|
||||||
*
|
|
||||||
* Keyed on the text and the language, so re-reading the same file does not rescan it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) {
|
|
||||||
var lines by remember(content, language) { mutableStateOf<FileLines?>(null) }
|
|
||||||
LaunchedEffect(content, language) {
|
|
||||||
lines = withContext(Dispatchers.Default) { FileLines.of(content, language) }
|
|
||||||
}
|
|
||||||
when (val ready = lines) {
|
|
||||||
null -> CircularProgressIndicator(Modifier.padding(8.dp))
|
|
||||||
else -> FileViewer(ready, modifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A file, one line per row, coloured by the same scanner that colours a reply's code fences.
|
|
||||||
*
|
|
||||||
* A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a
|
|
||||||
* twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. The cost is
|
|
||||||
* that each row needs its own colours, which is what [FileLines] works out once and off this
|
|
||||||
* thread.
|
|
||||||
*
|
|
||||||
* Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a
|
|
||||||
* block and a long line does not silently become three -- which would put the gutter's numbers
|
|
||||||
* against the wrong text.
|
|
||||||
*
|
|
||||||
* **Every row is given the same content width**, and that is what makes the shared scroll state
|
|
||||||
* behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset
|
|
||||||
* into *its own* range -- `content width - viewport` -- so with rows of their natural widths a
|
|
||||||
* short line's range is zero and it never moves while a long one beside it does. Each row also
|
|
||||||
* writes `maxValue` as it measures, so how far the file could be dragged was decided by whichever
|
|
||||||
* row measured last. Both disappear once every row is [FileLines.columns] wide. Reported by Iris on
|
|
||||||
* 2026-09-04 as "it seems to affect different rows differently", which is what a per-row range
|
|
||||||
* looks like.
|
|
||||||
*
|
|
||||||
* The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box
|
|
||||||
* around the list rather than by each row -- `horizontalScroll` makes its own per node otherwise,
|
|
||||||
* so only the line under the finger stretched. Only possible because every row now has the same
|
|
||||||
* range.
|
|
||||||
*
|
|
||||||
* The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the
|
|
||||||
* numbers out of both effects. The rows leave a spacer and [LineGutter] draws them there; its width
|
|
||||||
* is measured from the digit count of the line count in the style it is drawn in.
|
|
||||||
*
|
|
||||||
* Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and
|
|
||||||
* copying it gives the code rather than the code with a number in front of every line.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
|
|
||||||
val style = codeStyle()
|
|
||||||
val scroll = rememberScrollState()
|
|
||||||
val overscroll = rememberOverscrollEffect()
|
|
||||||
val rows = rememberLazyListState()
|
|
||||||
val gutter = gutterWidth(lines.size, style)
|
|
||||||
val content = contentWidth(lines.columns, style)
|
|
||||||
Box(modifier.fillMaxSize()) {
|
|
||||||
// One container around the whole file rather than one per line, so a selection can run
|
|
||||||
// across lines -- the same arrangement the transcript uses.
|
|
||||||
SelectionContainer {
|
|
||||||
// The stretch is drawn here, once, over everything this box holds; the rows below only
|
|
||||||
// feed it. `clipToBounds` because a stretch draws outside the box it came from.
|
|
||||||
Box(Modifier.fillMaxSize().clipToBounds().overscroll(overscroll)) {
|
|
||||||
LazyColumn(state = rows, modifier = Modifier.fillMaxSize()) {
|
|
||||||
items(lines.size) { index ->
|
|
||||||
Row(verticalAlignment = Alignment.Top) {
|
|
||||||
// Where the numbers go, drawn from outside this box.
|
|
||||||
Spacer(Modifier.width(gutter + GUTTER_GAP))
|
|
||||||
Text(
|
|
||||||
lines.line(index),
|
|
||||||
style = style,
|
|
||||||
softWrap = false,
|
|
||||||
// The scroll outside the width: the scrolling node's viewport is
|
|
||||||
// what the row has room for, and its content is the whole file's
|
|
||||||
// widest line. The shared effect is given to every row and rendered
|
|
||||||
// by none of them -- see the box above.
|
|
||||||
modifier =
|
|
||||||
Modifier.horizontalScroll(scroll, overscroll).width(content),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LineGutter(rows, gutter, style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The line numbers, drawn beside the file rather than in it.
|
|
||||||
*
|
|
||||||
* They have to be outside the box the stretch is rendered on, or they bend with the text; and they
|
|
||||||
* have to stay exactly level with the lines they number. Those two pull in opposite directions.
|
|
||||||
*
|
|
||||||
* A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come
|
|
||||||
* from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during
|
|
||||||
* measurement, so this composes from the answer the list has just produced rather than one it read
|
|
||||||
* a frame ago. A `Column` translated by the scroll position could not: the translation would be
|
|
||||||
* current while the set of numbers was a composition behind, so during a fling the numbers would
|
|
||||||
* slide against their lines.
|
|
||||||
*
|
|
||||||
* The list is measured before this is -- they are siblings in a `Box` and it is declared first.
|
|
||||||
*
|
|
||||||
* `onSurfaceVariant`, because a number is not part of the file. The background is painted because
|
|
||||||
* the stretch can carry the text sideways under this column, and a digit with a smear of code
|
|
||||||
* behind it reads as a rendering fault.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) {
|
|
||||||
val colour = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
val surface = rawSurface
|
|
||||||
SubcomposeLayout(Modifier.fillMaxHeight().width(width).background(surface).clipToBounds()) {
|
|
||||||
constraints ->
|
|
||||||
val visible = rows.layoutInfo.visibleItemsInfo
|
|
||||||
val numbers = visible.map { item ->
|
|
||||||
subcompose(item.index) {
|
|
||||||
Text(
|
|
||||||
(item.index + 1).toString(),
|
|
||||||
style = style,
|
|
||||||
color = colour,
|
|
||||||
textAlign = TextAlign.End,
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.first()
|
|
||||||
.measure(Constraints.fixedWidth(constraints.maxWidth))
|
|
||||||
}
|
|
||||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
|
||||||
numbers.forEachIndexed { index, number -> number.place(0, visible[index].offset) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How wide the widest line number is, measured rather than guessed.
|
|
||||||
*
|
|
||||||
* `9` repeated, because digits in a monospace face are all one width -- what matters is how many
|
|
||||||
* there are. Measuring in the style the numbers are drawn in is what makes this survive a font
|
|
||||||
* size, a density or a display scale nobody here chose.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun gutterWidth(lineCount: Int, style: TextStyle): Dp {
|
|
||||||
val measurer = rememberTextMeasurer()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val digits = maxOf(1, lineCount.toString().length)
|
|
||||||
return remember(digits, style, density) {
|
|
||||||
with(density) {
|
|
||||||
measurer.measure(AnnotatedString("9".repeat(digits)), style).size.width.toDp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How wide to make every row: the widest line in the file, in this style.
|
|
||||||
*
|
|
||||||
* One character measured rather than the line itself, because the face is monospace and measuring
|
|
||||||
* the actual widest line of a twenty-thousand-line file is work for an answer arithmetic already
|
|
||||||
* has. Sixty-four of them, divided, so the answer does not carry a whole character's worth of
|
|
||||||
* rounding.
|
|
||||||
*
|
|
||||||
* Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary
|
|
||||||
* one: a minified file is a single line of a hundred thousand characters, and laying that out as
|
|
||||||
* one row is a crash rather than a slow scroll. Past the cap the far end of such a line cannot be
|
|
||||||
* reached, which is the tolerable half of that trade.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun contentWidth(columns: Int, style: TextStyle): Dp {
|
|
||||||
val measurer = rememberTextMeasurer()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
return remember(columns, style, density) {
|
|
||||||
val advance = measurer.measure(AnnotatedString("0".repeat(64)), style).size.width / 64f
|
|
||||||
with(density) { (columns * advance).coerceAtMost(MAX_CONTENT_PX).toDp() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The widest a row may be laid out, in pixels. Well under what `Constraints` can carry, and far
|
|
||||||
* past any line anybody reads.
|
|
||||||
*/
|
|
||||||
private const val MAX_CONTENT_PX = 100_000f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The space between the numbers and the code. A gap, not an alignment: the two are already aligned
|
|
||||||
* by the row, and this is only so the digits and the first character are not touching.
|
|
||||||
*/
|
|
||||||
val GUTTER_GAP = 8.dp
|
|
||||||
@@ -1,787 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which machine's files to show, and where to start.
|
|
||||||
*
|
|
||||||
* A **machine**, not a session: a filesystem is a property of a machine, and a session only says
|
|
||||||
* where it was working. That is what makes a second way in -- from the machines tab -- one more
|
|
||||||
* caller rather than any new code here.
|
|
||||||
*/
|
|
||||||
data class FilesTarget(
|
|
||||||
val 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. */
|
|
||||||
private sealed class Spot(val path: String) {
|
|
||||||
class Dir(path: String) : Spot(path)
|
|
||||||
|
|
||||||
class Doc(path: String, val directory: Dir) : Spot(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum class UnsavedDestination {
|
|
||||||
Directory,
|
|
||||||
Session,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The files on the machine a session runs on: browse them, read one, change one.
|
|
||||||
*
|
|
||||||
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
|
|
||||||
* flowing and coming back from a file costs nothing. Both back controls return from a file to its
|
|
||||||
* directory. In a directory, Android back walks toward the session's project directory and closes
|
|
||||||
* the explorer once it gets there; the header's back button closes it immediately.
|
|
||||||
*
|
|
||||||
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is
|
|
||||||
* how one gets asked again on purpose, and creating something refetches the directory it was
|
|
||||||
* created in.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val initialDirectory =
|
|
||||||
target.file?.let(::parentOf)?.let { Spot.Dir(it) } ?: Spot.Dir(target.start)
|
|
||||||
var here by
|
|
||||||
remember(target) {
|
|
||||||
mutableStateOf<Spot>(
|
|
||||||
target.file?.let { Spot.Doc(it, initialDirectory) } ?: initialDirectory
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
|
||||||
var creating by remember { mutableStateOf(false) }
|
|
||||||
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
|
||||||
// because both ways out have to ask before discarding it.
|
|
||||||
var editing by remember { mutableStateOf(false) }
|
|
||||||
var dirty by remember { mutableStateOf(false) }
|
|
||||||
var unsavedDestination by remember { mutableStateOf<UnsavedDestination?>(null) }
|
|
||||||
|
|
||||||
fun go(spot: Spot) {
|
|
||||||
editing = false
|
|
||||||
dirty = false
|
|
||||||
here = spot
|
|
||||||
}
|
|
||||||
|
|
||||||
fun leave(destination: UnsavedDestination) {
|
|
||||||
if (editing && dirty) {
|
|
||||||
unsavedDestination = destination
|
|
||||||
} else if (destination == UnsavedDestination.Directory) {
|
|
||||||
go((here as Spot.Doc).directory)
|
|
||||||
} else {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun load(path: String, again: Boolean) {
|
|
||||||
val existing = listings[path]
|
|
||||||
if (!again && (existing is LoadState.Loaded || existing is LoadState.Loading)) return
|
|
||||||
listings[path] = LoadState.Loading
|
|
||||||
listings[path] =
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
LoadState.Loaded(fetchDir(settings, target.machine, path))
|
|
||||||
}
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val projectDirectory = (listings[target.start] as? LoadState.Loaded)?.value?.path
|
|
||||||
val homeDirectory =
|
|
||||||
if (target.start == "~") projectDirectory
|
|
||||||
else (listings["~"] as? LoadState.Loaded)?.value?.path
|
|
||||||
|
|
||||||
fun systemBack() {
|
|
||||||
when (val spot = here) {
|
|
||||||
is Spot.Doc -> leave(UnsavedDestination.Directory)
|
|
||||||
is Spot.Dir -> {
|
|
||||||
val path = (listings[spot.path] as? LoadState.Loaded)?.value?.path ?: spot.path
|
|
||||||
when {
|
|
||||||
path == projectDirectory || path == target.start -> onClose()
|
|
||||||
projectDirectory != null ->
|
|
||||||
nextDirectoryToward(path, projectDirectory)?.let { go(Spot.Dir(it)) }
|
|
||||||
?: onClose()
|
|
||||||
else -> parentOf(path)?.let { go(Spot.Dir(it)) } ?: onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A file link can open without visiting the project first, but Back still needs to know where
|
|
||||||
// the project is. Home is likewise resolved by the machine rather than guessed on the phone;
|
|
||||||
// it is what lets every path beneath it be displayed with `~`, including over ssh.
|
|
||||||
LaunchedEffect(target.machine, target.start) {
|
|
||||||
if (target.file != null) load(target.start, again = false)
|
|
||||||
if (target.start != "~") load("~", again = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
BackHandler(onBack = ::systemBack)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
Modifier.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.background)
|
|
||||||
// The session under this deliberately takes no keyboard inset, so the explorer adds its
|
|
||||||
// own -- otherwise the editor types under the keyboard.
|
|
||||||
.imePadding()
|
|
||||||
) {
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
|
||||||
when (val spot = here) {
|
|
||||||
is Spot.Dir -> {
|
|
||||||
val state = listings[spot.path] ?: LoadState.Loading
|
|
||||||
// Navigate with the resolved path, but name anything under the machine's home
|
|
||||||
// the way somebody working there would write it.
|
|
||||||
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
|
|
||||||
val shownAt = tildePath(at, homeDirectory)
|
|
||||||
FilesHeader(
|
|
||||||
title = baseName(shownAt),
|
|
||||||
path = shownAt,
|
|
||||||
machine = target.machineName,
|
|
||||||
onBack = { leave(UnsavedDestination.Session) },
|
|
||||||
) {
|
|
||||||
GlyphButton(
|
|
||||||
REFRESH_GLYPH,
|
|
||||||
"Refresh this directory",
|
|
||||||
{ scope.launch { load(spot.path, again = true) } },
|
|
||||||
enabled = state !is LoadState.Loading,
|
|
||||||
)
|
|
||||||
GlyphButton(
|
|
||||||
PLUS_GLYPH,
|
|
||||||
"Create here",
|
|
||||||
{ creating = true },
|
|
||||||
enabled = state is LoadState.Loaded,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LaunchedEffect(spot.path) { load(spot.path, again = false) }
|
|
||||||
DirectoryBody(state, directory = spot, onOpen = ::go)
|
|
||||||
}
|
|
||||||
is Spot.Doc ->
|
|
||||||
DocPane(
|
|
||||||
settings = settings,
|
|
||||||
target = target,
|
|
||||||
path = spot.path,
|
|
||||||
name = baseName(spot.path),
|
|
||||||
editing = editing,
|
|
||||||
homeDirectory = homeDirectory,
|
|
||||||
onEditing = { editing = it },
|
|
||||||
onDirty = { dirty = it },
|
|
||||||
onBack = { leave(UnsavedDestination.Directory) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unsavedDestination?.let { destination ->
|
|
||||||
UnsavedDialog(
|
|
||||||
onDiscard = {
|
|
||||||
unsavedDestination = null
|
|
||||||
if (destination == UnsavedDestination.Directory) {
|
|
||||||
go((here as Spot.Doc).directory)
|
|
||||||
} else {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCancel = { unsavedDestination = null },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val dir = here as? Spot.Dir
|
|
||||||
val listing = (listings[dir?.path] as? LoadState.Loaded)?.value
|
|
||||||
if (creating && dir != null && listing != null) {
|
|
||||||
CreateDialog(
|
|
||||||
settings = settings,
|
|
||||||
machine = target.machine,
|
|
||||||
directory = listing.path,
|
|
||||||
onDismiss = { creating = false },
|
|
||||||
onCreated = { path, isDirectory ->
|
|
||||||
creating = false
|
|
||||||
scope.launch {
|
|
||||||
// The directory it was created in is the one thing that changed, so that is
|
|
||||||
// what gets asked again -- not the whole stack.
|
|
||||||
load(dir.path, again = true)
|
|
||||||
// A new file has nothing to look at, so it opens where it can be filled in.
|
|
||||||
if (!isDirectory) {
|
|
||||||
go(Spot.Doc(path, dir))
|
|
||||||
editing = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The row every view in here has at the top: back, what this is, and what acts on it.
|
|
||||||
*
|
|
||||||
* The path is truncated in the middle when it will not fit, because both ends carry something the
|
|
||||||
* reader needs -- the machine and the top of the tree at one end, the file at the other -- and it
|
|
||||||
* is the longest paths, the ones being read most closely, that get cut.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun FilesHeader(
|
|
||||||
title: String,
|
|
||||||
path: String,
|
|
||||||
machine: String,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
actions: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
|
||||||
) {
|
|
||||||
GlyphButton(BACK_GLYPH, "Back", onBack)
|
|
||||||
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
|
|
||||||
Text(
|
|
||||||
"$machine · $path",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.MiddleEllipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row { actions() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What is in a directory.
|
|
||||||
*
|
|
||||||
* A listing that failed says why, in the machine's own words, where the rows would be -- never an
|
|
||||||
* empty list, which is what "there is nothing here" looks like and is the one wrong answer that
|
|
||||||
* looks like a right one.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ColumnScope.DirectoryBody(
|
|
||||||
state: LoadState<Listing>,
|
|
||||||
directory: Spot.Dir,
|
|
||||||
onOpen: (Spot) -> Unit,
|
|
||||||
) {
|
|
||||||
when (state) {
|
|
||||||
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
|
|
||||||
is LoadState.Error ->
|
|
||||||
Text(
|
|
||||||
state.message,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
)
|
|
||||||
is LoadState.Loaded -> {
|
|
||||||
val listing = state.value
|
|
||||||
val sorted = remember(listing) { sortForDisplay(listing.entries) }
|
|
||||||
LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
|
|
||||||
parentOf(listing.path)?.let { parent ->
|
|
||||||
item("..") {
|
|
||||||
EntryRow(
|
|
||||||
glyph = FOLDER_GLYPH,
|
|
||||||
name = "..",
|
|
||||||
trailing = null,
|
|
||||||
onClick = { onOpen(Spot.Dir(parent)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sorted.isEmpty()) {
|
|
||||||
item("empty") {
|
|
||||||
Text(
|
|
||||||
"Nothing here",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
uniqueItems(sorted, key = { it.name }) { entry ->
|
|
||||||
val path = join(listing.path, entry.name)
|
|
||||||
EntryRow(
|
|
||||||
glyph = if (entry.isDirectory) FOLDER_GLYPH else FILE_GLYPH,
|
|
||||||
name = entry.name,
|
|
||||||
trailing = trailingOf(entry),
|
|
||||||
onClick = {
|
|
||||||
onOpen(
|
|
||||||
if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path, directory)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a row says after the name, or nothing.
|
|
||||||
*
|
|
||||||
* A symlink says so instead of giving a size, because the size a listing reports for one is the
|
|
||||||
* length of the path it points at -- a number that looks exactly like a file size and is about
|
|
||||||
* something else. `other` covers a fifo, a device, and a link whose target is gone: the row still
|
|
||||||
* appears, because a directory that hid what it held would be lying about being empty.
|
|
||||||
*/
|
|
||||||
private fun trailingOf(entry: DirEntry): String? =
|
|
||||||
when {
|
|
||||||
entry.link -> "link"
|
|
||||||
entry.isDirectory -> null
|
|
||||||
entry.kind == "file" -> humanSize(entry.size) ?: "0 B"
|
|
||||||
else -> "other"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: () -> Unit) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
|
||||||
) {
|
|
||||||
Glyph(glyph, colour = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
Text(
|
|
||||||
name,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.MiddleEllipsis,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
trailing?.let {
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One file: read, and edited behind the pencil.
|
|
||||||
*
|
|
||||||
* Its own composable so that everything about one file -- what came back, what has been typed, and
|
|
||||||
* whether a save is out -- is remembered under that file's path and thrown away when the reader
|
|
||||||
* moves to another. What is *not* here is edit mode itself: back has to know about it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ColumnScope.DocPane(
|
|
||||||
settings: ServerSettings,
|
|
||||||
target: FilesTarget,
|
|
||||||
path: String,
|
|
||||||
name: String,
|
|
||||||
editing: Boolean,
|
|
||||||
homeDirectory: String?,
|
|
||||||
onEditing: (Boolean) -> Unit,
|
|
||||||
onDirty: (Boolean) -> Unit,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var state by remember(path) { mutableStateOf<LoadState<FileContent>>(LoadState.Loading) }
|
|
||||||
var draft by remember(path) { mutableStateOf(TextFieldValue()) }
|
|
||||||
var saving by remember(path) { mutableStateOf(false) }
|
|
||||||
var saveError by remember(path) { mutableStateOf<String?>(null) }
|
|
||||||
var conflict by remember(path) { mutableStateOf<String?>(null) }
|
|
||||||
// The editor's own vertical scroll, hoisted so the gutter and the text move together: they are
|
|
||||||
// two composables in one row, and a scroll inside either would leave the other behind.
|
|
||||||
val editScroll = rememberScrollState()
|
|
||||||
val language = remember(name) { fileLanguage(name) }
|
|
||||||
val loaded = (state as? LoadState.Loaded)?.value as? FileContent.Text
|
|
||||||
// Readable but not editable: see [EDIT_LIMIT]. The size is the one the machine reported, so
|
|
||||||
// this is decided before anything is typed rather than discovered by a keyboard that stops
|
|
||||||
// answering.
|
|
||||||
val editable = loaded != null && loaded.size <= EDIT_LIMIT
|
|
||||||
|
|
||||||
suspend fun fetch() {
|
|
||||||
state = LoadState.Loading
|
|
||||||
state =
|
|
||||||
try {
|
|
||||||
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.machine, path) }
|
|
||||||
if (got is FileContent.Text) draft = TextFieldValue(got.content)
|
|
||||||
LoadState.Loaded(got)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
onDirty(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(path) { fetch() }
|
|
||||||
|
|
||||||
val changed = loaded != null && draft.text != loaded.content
|
|
||||||
LaunchedEffect(changed) { onDirty(changed) }
|
|
||||||
|
|
||||||
/** Writes the draft back, [against] being the digest it is allowed to replace. */
|
|
||||||
fun save(against: String) {
|
|
||||||
if (saving) return
|
|
||||||
saving = true
|
|
||||||
saveError = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val written =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
writeFile(settings, target.machine, path, draft.text, against)
|
|
||||||
}
|
|
||||||
state =
|
|
||||||
LoadState.Loaded(
|
|
||||||
FileContent.Text(
|
|
||||||
path,
|
|
||||||
written.size,
|
|
||||||
written.modified,
|
|
||||||
written.sha256,
|
|
||||||
draft.text,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conflict = null
|
|
||||||
onDirty(false)
|
|
||||||
onEditing(false)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// The one refusal that is a question rather than a message: somebody else's edit is
|
|
||||||
// on the machine, and which of the two survives is not this app's to decide.
|
|
||||||
if (e.status == 409) conflict = e.message ?: "It changed on the machine."
|
|
||||||
else saveError = e.message
|
|
||||||
} finally {
|
|
||||||
saving = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FilesHeader(
|
|
||||||
title = name,
|
|
||||||
path = tildePath(path, homeDirectory),
|
|
||||||
machine = target.machineName,
|
|
||||||
onBack = onBack,
|
|
||||||
) {
|
|
||||||
if (editing) {
|
|
||||||
if (saving) {
|
|
||||||
GlyphSpinner("Saving")
|
|
||||||
} else {
|
|
||||||
GlyphButton(
|
|
||||||
SAVE_GLYPH,
|
|
||||||
"Save",
|
|
||||||
{ loaded?.let { save(it.sha256) } },
|
|
||||||
// Disabled rather than hidden while there is nothing to write: a button that
|
|
||||||
// comes and goes makes its own absence the signal.
|
|
||||||
enabled = changed,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
GlyphButton(
|
|
||||||
REFRESH_GLYPH,
|
|
||||||
"Read this file again",
|
|
||||||
{ scope.launch { fetch() } },
|
|
||||||
enabled = state !is LoadState.Loading,
|
|
||||||
)
|
|
||||||
GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = editable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveError?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it
|
|
||||||
// is disabled -- and a reader who cannot edit a file they can plainly read will otherwise
|
|
||||||
// conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button
|
|
||||||
// never gets.
|
|
||||||
if (loaded != null && !editable) {
|
|
||||||
Text(
|
|
||||||
"Too big to edit here (${humanSize(loaded.size)}; the limit is " +
|
|
||||||
"${humanSize(EDIT_LIMIT)}). A text field this large stops answering the keyboard.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(Modifier.weight(1f).fillMaxWidth().background(rawSurface).padding(horizontal = 8.dp)) {
|
|
||||||
when (val current = state) {
|
|
||||||
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp))
|
|
||||||
is LoadState.Error ->
|
|
||||||
Text(
|
|
||||||
current.message,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.padding(8.dp),
|
|
||||||
)
|
|
||||||
is LoadState.Loaded ->
|
|
||||||
when (val file = current.value) {
|
|
||||||
is FileContent.Text ->
|
|
||||||
if (editing) {
|
|
||||||
FileEditor(
|
|
||||||
draft,
|
|
||||||
{ draft = it },
|
|
||||||
language,
|
|
||||||
Modifier.verticalScroll(editScroll),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ScannedFile(file.content, language)
|
|
||||||
}
|
|
||||||
// Said in words, with the measurement that makes it make sense. Neither of
|
|
||||||
// these is an empty file and neither is an error, so neither may look like one.
|
|
||||||
is FileContent.Binary ->
|
|
||||||
Note(
|
|
||||||
"This is not text (${humanSize(file.size) ?: "0 B"}), so there is nothing to show."
|
|
||||||
)
|
|
||||||
is FileContent.TooBig ->
|
|
||||||
Note(
|
|
||||||
"This file is ${humanSize(file.size)}, which is more than the server will " +
|
|
||||||
"send. Nothing was read, so nothing here is a sample of it."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
conflict?.let { message ->
|
|
||||||
ConflictDialog(
|
|
||||||
message = message,
|
|
||||||
busy = saving,
|
|
||||||
onOverwrite = {
|
|
||||||
// Re-read only to learn what it hashes to *now*, which is the digest an overwrite
|
|
||||||
// has to be allowed against. The content is deliberately thrown away: overwriting
|
|
||||||
// is the choice to lose it.
|
|
||||||
scope.launch {
|
|
||||||
val fresh =
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
fetchFile(settings, target.machine, path)
|
|
||||||
}
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
saveError = e.message
|
|
||||||
conflict = null
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
if (fresh is FileContent.Text) save(fresh.sha256)
|
|
||||||
else {
|
|
||||||
saveError =
|
|
||||||
"It is no longer a text file, so this app will not write over it."
|
|
||||||
conflict = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onReload = {
|
|
||||||
conflict = null
|
|
||||||
scope.launch { fetch() }
|
|
||||||
},
|
|
||||||
onCancel = { conflict = null },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A sentence where the file's content would be, for the two states that have no content. */
|
|
||||||
@Composable
|
|
||||||
private fun Note(text: String) {
|
|
||||||
Text(
|
|
||||||
text,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Naming one thing in the directory that is open.
|
|
||||||
*
|
|
||||||
* A name and a switch, not a name and a body: the editor is where content is typed, and a modal
|
|
||||||
* with a text area in it is a second editor to keep in step with the first. A created file opens
|
|
||||||
* straight into edit mode, because an empty file is not something to look at.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun CreateDialog(
|
|
||||||
settings: ServerSettings,
|
|
||||||
machine: String,
|
|
||||||
directory: String,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onCreated: (String, Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var name by remember { mutableStateOf("") }
|
|
||||||
var isDirectory by remember { mutableStateOf(false) }
|
|
||||||
var busy by remember { mutableStateOf(false) }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
fun create() {
|
|
||||||
val chosen = name.trim()
|
|
||||||
if (busy || chosen.isEmpty()) return
|
|
||||||
busy = true
|
|
||||||
error = null
|
|
||||||
val path = join(directory, chosen)
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
if (isDirectory) createDir(settings, machine, path)
|
|
||||||
else createFile(settings, machine, path)
|
|
||||||
}
|
|
||||||
onCreated(path, isDirectory)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// Beside the button that caused it: this dialog is the only thing on screen that
|
|
||||||
// knows something was being created, and the reason is usually the name itself.
|
|
||||||
error = e.message
|
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Create in ${baseName(directory)}") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
label = { Text("Name") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = !busy,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text("Directory", modifier = Modifier.weight(1f))
|
|
||||||
Switch(
|
|
||||||
checked = isDirectory,
|
|
||||||
onCheckedChange = { isDirectory = it },
|
|
||||||
enabled = !busy,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
"A name that is already taken is refused rather than replaced.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
error?.let {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { create() }, enabled = !busy && name.isNotBlank()) {
|
|
||||||
Text(if (busy) "Creating..." else "Create")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Directories first, then by name ignoring case, and stably.
|
|
||||||
*
|
|
||||||
* Sorted here rather than by the machine: presentation order is a display decision, and `find`
|
|
||||||
* answers in whatever order the directory happens to be stored in. Dotfiles are not hidden -- in a
|
|
||||||
* repository they are half of what matters.
|
|
||||||
*/
|
|
||||||
internal fun sortForDisplay(entries: List<DirEntry>): List<DirEntry> =
|
|
||||||
entries.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The directory above [path], or null at the root.
|
|
||||||
*
|
|
||||||
* A string operation on a path the *machine* resolved, which is what makes it safe: every listing
|
|
||||||
* answers with its own `pwd -P`, so there is never a `..` or a symlink left in here to reason
|
|
||||||
* about, and this app never has to resolve one.
|
|
||||||
*/
|
|
||||||
internal fun parentOf(path: String): String? {
|
|
||||||
val trimmed = path.trimEnd('/')
|
|
||||||
if (trimmed.isEmpty()) return null
|
|
||||||
val cut = trimmed.lastIndexOf('/')
|
|
||||||
return when {
|
|
||||||
cut < 0 -> null
|
|
||||||
cut == 0 -> "/"
|
|
||||||
else -> trimmed.substring(0, cut)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The next directory on the filesystem path from [current] to [destination], or null when there.
|
|
||||||
*
|
|
||||||
* Moving between two branches first walks upward to their common ancestor. Once [current] is that
|
|
||||||
* ancestor, the next press walks one segment down toward [destination]. Both paths are answers from
|
|
||||||
* the machine, so they are absolute and have no symlinks or `..` left to resolve here.
|
|
||||||
*/
|
|
||||||
internal fun nextDirectoryToward(current: String, destination: String): String? {
|
|
||||||
val here = current.trimEnd('/').ifEmpty { "/" }
|
|
||||||
val there = destination.trimEnd('/').ifEmpty { "/" }
|
|
||||||
if (here == there) return null
|
|
||||||
val beneathHere = if (here == "/") there.startsWith('/') else there.startsWith("$here/")
|
|
||||||
if (!beneathHere) return parentOf(here)
|
|
||||||
val next = there.removePrefix(here).trimStart('/').substringBefore('/')
|
|
||||||
return join(here, next)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A path as somebody on [home] writes it, leaving paths outside that home unchanged. */
|
|
||||||
internal fun tildePath(path: String, home: String?): String {
|
|
||||||
val at = path.trimEnd('/').ifEmpty { "/" }
|
|
||||||
val resolvedHome = home?.trimEnd('/')?.ifEmpty { "/" } ?: return at
|
|
||||||
return when {
|
|
||||||
at == resolvedHome -> "~"
|
|
||||||
resolvedHome != "/" && at.startsWith("$resolvedHome/") ->
|
|
||||||
"~${at.removePrefix(resolvedHome)}"
|
|
||||||
else -> at
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a path names: its last segment, with `/` naming itself. */
|
|
||||||
internal fun baseName(path: String): String {
|
|
||||||
val trimmed = path.trimEnd('/')
|
|
||||||
return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun join(directory: String, name: String): String =
|
|
||||||
if (directory.endsWith("/")) "$directory$name" else "$directory/$name"
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContextWrapper
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.HandlerThread
|
|
||||||
import android.view.FrameMetrics
|
|
||||||
import android.view.Window
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long each frame took, and which phase of it, taken from the platform rather than from a frame
|
|
||||||
* counter of our own.
|
|
||||||
*
|
|
||||||
* The point of splitting it up is that "the scroll is laggy" has two completely different causes
|
|
||||||
* and one appearance. If the layout-and-measure and draw figures are small and the total is large,
|
|
||||||
* the time is going into rasterising and compositing, and no amount of doing less work per row will
|
|
||||||
* move it. If they are large, the work per row is the problem and it is ours to fix.
|
|
||||||
*
|
|
||||||
* The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds,
|
|
||||||
* broken into the parts the UI thread is responsible for and the parts after it.
|
|
||||||
*
|
|
||||||
* One of these for the app, like [DebugStats], because the two are read as one report and
|
|
||||||
* [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session
|
|
||||||
* and the counters were not, so a report copied after visiting two sessions divided every session's
|
|
||||||
* work by the newest one's frame count -- 36.8 seconds of placement inside a 13.5 second window.
|
|
||||||
*/
|
|
||||||
object FrameStats {
|
|
||||||
private val total = ArrayList<Long>()
|
|
||||||
private val waited = ArrayList<Long>()
|
|
||||||
private val input = ArrayList<Long>()
|
|
||||||
private val animation = ArrayList<Long>()
|
|
||||||
private val layout = ArrayList<Long>()
|
|
||||||
private val draw = ArrayList<Long>()
|
|
||||||
private val sync = ArrayList<Long>()
|
|
||||||
private val issue = ArrayList<Long>()
|
|
||||||
private val swap = ArrayList<Long>()
|
|
||||||
private val gpu = ArrayList<Long>()
|
|
||||||
private var since = System.currentTimeMillis()
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun add(metrics: FrameMetrics) {
|
|
||||||
// The first frame after a window opens includes inflating it and is nobody's scroll.
|
|
||||||
if (metrics.getMetric(FrameMetrics.FIRST_DRAW_FRAME) == 1L) return
|
|
||||||
if (total.size >= CAP) return
|
|
||||||
total += metrics.getMetric(FrameMetrics.TOTAL_DURATION)
|
|
||||||
// How long the frame waited for the UI thread to be free before it could start. Reported
|
|
||||||
// because the phases otherwise do not add up to the total, and the gap is the interesting
|
|
||||||
// part: the frame being held up by work that is not the frame's.
|
|
||||||
waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)
|
|
||||||
input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION)
|
|
||||||
animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION)
|
|
||||||
layout += metrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION)
|
|
||||||
draw += metrics.getMetric(FrameMetrics.DRAW_DURATION)
|
|
||||||
sync += metrics.getMetric(FrameMetrics.SYNC_DURATION)
|
|
||||||
issue += metrics.getMetric(FrameMetrics.COMMAND_ISSUE_DURATION)
|
|
||||||
swap += metrics.getMetric(FrameMetrics.SWAP_BUFFERS_DURATION)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
gpu += metrics.getMetric(FrameMetrics.GPU_DURATION)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun reset() {
|
|
||||||
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
|
||||||
it.clear()
|
|
||||||
}
|
|
||||||
since = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun lines(refreshHz: Float): List<String> {
|
|
||||||
if (total.isEmpty()) return listOf(" no frames recorded -- scroll first, then press this")
|
|
||||||
val seconds = (System.currentTimeMillis() - since) / 1000.0
|
|
||||||
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
|
|
||||||
val late = total.count { it / 1_000_000.0 > budget }
|
|
||||||
return listOf(
|
|
||||||
" ${total.size} frames over ${"%.1f".format(seconds)}s" +
|
|
||||||
" at ${"%.0f".format(refreshHz)}Hz (${"%.1f".format(budget)}ms budget)",
|
|
||||||
" late: $late (${percent(late, total.size)})" +
|
|
||||||
if (total.size >= CAP) " [capped]" else "",
|
|
||||||
phase("total ", total),
|
|
||||||
phase("waited", waited),
|
|
||||||
phase("input ", input),
|
|
||||||
phase("anim ", animation),
|
|
||||||
phase("layout", layout),
|
|
||||||
phase("draw ", draw),
|
|
||||||
phase("sync ", sync),
|
|
||||||
phase("issue ", issue),
|
|
||||||
phase("swap ", swap),
|
|
||||||
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How long the frames recorded here spent in their draw phase, and how many there were. */
|
|
||||||
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
|
|
||||||
|
|
||||||
private fun phase(name: String, samples: List<Long>): String {
|
|
||||||
val sorted = samples.sorted()
|
|
||||||
return " $name p50 ${at(sorted, 50)} p90 ${at(sorted, 90)} p99 ${at(sorted, 99)}"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun at(sorted: List<Long>, percentile: Int): String {
|
|
||||||
if (sorted.isEmpty()) return "-"
|
|
||||||
val index = (sorted.size - 1) * percentile / 100
|
|
||||||
return "%.1fms".format(sorted[index] / 1_000_000.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun percent(part: Int, whole: Int) = "%.1f%%".format(100.0 * part / whole)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enough for a couple of minutes of scrolling; this is a diagnostic, not a log. */
|
|
||||||
private const val CAP = 20_000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records into [FrameStats] for as long as this screen is on it.
|
|
||||||
*
|
|
||||||
* The listener is what comes and goes; what it writes into does not, so a report covers the same
|
|
||||||
* stretch of time as the counters beside it.
|
|
||||||
*
|
|
||||||
* The listener is handed its own thread because the platform calls it for every frame and the
|
|
||||||
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun RecordFrames() {
|
|
||||||
val window = LocalContext.current.activity()?.window
|
|
||||||
DisposableEffect(window) {
|
|
||||||
if (window == null) return@DisposableEffect onDispose {}
|
|
||||||
val thread = HandlerThread("frame-stats").apply { start() }
|
|
||||||
val listener = Window.OnFrameMetricsAvailableListener { _, metrics, _ ->
|
|
||||||
FrameStats.add(metrics)
|
|
||||||
}
|
|
||||||
window.addOnFrameMetricsAvailableListener(listener, Handler(thread.looper))
|
|
||||||
onDispose {
|
|
||||||
window.removeOnFrameMetricsAvailableListener(listener)
|
|
||||||
thread.quitSafely()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The activity behind a composable's context, which is what owns the window. */
|
|
||||||
fun Context.activity(): Activity? {
|
|
||||||
var context: Context? = this
|
|
||||||
while (context is ContextWrapper) {
|
|
||||||
if (context is Activity) return context
|
|
||||||
context = context.baseContext
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the display is actually refreshing at, so "late" is measured against the real budget. */
|
|
||||||
fun Context.refreshHz(): Float =
|
|
||||||
@Suppress("DEPRECATION") (activity()?.windowManager?.defaultDisplay?.refreshRate ?: 60f)
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
|
|
||||||
/** What a span of code is, in the terms the palette has a colour for. */
|
|
||||||
enum class Kind {
|
|
||||||
ADDITION,
|
|
||||||
DELETION,
|
|
||||||
KEYWORD,
|
|
||||||
STRING,
|
|
||||||
LITERAL,
|
|
||||||
COMMENT,
|
|
||||||
METADATA,
|
|
||||||
PUNCTUATION,
|
|
||||||
MARK,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A run of [Kind] in the code, as a half-open range. */
|
|
||||||
data class Span(val start: Int, val end: Int, val kind: Kind)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The colours the highlighter draws with, ours rather than a library's; [catppuccinSyntax] is the
|
|
||||||
* one instance and lives with the rest of the palette.
|
|
||||||
*/
|
|
||||||
data class SyntaxPalette(
|
|
||||||
val addition: Color,
|
|
||||||
val deletion: Color,
|
|
||||||
val keyword: Color,
|
|
||||||
val string: Color,
|
|
||||||
val literal: Color,
|
|
||||||
val comment: Color,
|
|
||||||
val metadata: Color,
|
|
||||||
val punctuation: Color,
|
|
||||||
val mark: Color,
|
|
||||||
) {
|
|
||||||
fun of(kind: Kind): Color =
|
|
||||||
when (kind) {
|
|
||||||
Kind.ADDITION -> addition
|
|
||||||
Kind.DELETION -> deletion
|
|
||||||
Kind.KEYWORD -> keyword
|
|
||||||
Kind.STRING -> string
|
|
||||||
Kind.LITERAL -> literal
|
|
||||||
Kind.COMMENT -> comment
|
|
||||||
Kind.METADATA -> metadata
|
|
||||||
Kind.PUNCTUATION -> punctuation
|
|
||||||
Kind.MARK -> mark
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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.
|
|
||||||
*
|
|
||||||
* Shared by a tool call's input and a reply's fences, so the same code is the same colours wherever
|
|
||||||
* it appears.
|
|
||||||
*
|
|
||||||
* Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it
|
|
||||||
* off the drawing thread.
|
|
||||||
*
|
|
||||||
* The timing is the number the highlighter is judged by: the library this replaced took **174ms**
|
|
||||||
* on the emulator for a two-hundred-line Kotlin fence, which is why [ParsedReplies.highlighted]
|
|
||||||
* caches the answer rather than a `remember` inside the fence recomputing it on every scroll back.
|
|
||||||
*/
|
|
||||||
fun highlight(code: String, language: Language?): AnnotatedString {
|
|
||||||
if (language == null) return AnnotatedString(code)
|
|
||||||
val spans = DebugStats.timed("code highlighted") { spansOf(code, language) }
|
|
||||||
val palette = catppuccinSyntax()
|
|
||||||
return buildAnnotatedString {
|
|
||||||
append(code)
|
|
||||||
spans.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [code] read once, left to right, into the spans that carry a colour.
|
|
||||||
*
|
|
||||||
* One pass with a small state -- in a comment, in a string, or in ordinary code -- rather than a
|
|
||||||
* locator per token kind over the whole text, which is what the library did and is why it found
|
|
||||||
* comments before it knew the language: a `#` inside a shell string, a `//` inside a URL and a
|
|
||||||
* block-comment opener inside a shell glob each commented out the rest of a line that was nothing
|
|
||||||
* of the sort.
|
|
||||||
*
|
|
||||||
* Every span is produced by advancing an index forward, so the result is ordered, non-overlapping
|
|
||||||
* and inside the code by construction. Nothing here throws: an unterminated string or comment runs
|
|
||||||
* to the end of the code, which is also what it looks like while a fence is still being written.
|
|
||||||
*
|
|
||||||
* In ordinary code the order of recognition is comment, string, attribute, number, word, and
|
|
||||||
* finally a single punctuation or mark character, which are coloured only in ordinary code.
|
|
||||||
*/
|
|
||||||
fun scan(code: String, rules: Rules): List<Span> = Scanner(code, rules).run()
|
|
||||||
|
|
||||||
/** Characters coloured as punctuation, and as marks. Both sets are the ones the library used. */
|
|
||||||
private const val PUNCTUATION = ",.:;"
|
|
||||||
private const val MARKS = "()={}<>-+[]|&"
|
|
||||||
|
|
||||||
private class Scanner(private val code: String, private val rules: Rules) {
|
|
||||||
private val spans = ArrayList<Span>()
|
|
||||||
private var at = 0
|
|
||||||
|
|
||||||
fun run(): List<Span> {
|
|
||||||
while (at < code.length) {
|
|
||||||
// Every branch that answers true has advanced `at`, so this terminates.
|
|
||||||
val consumed =
|
|
||||||
blockComment() ||
|
|
||||||
lineComment() ||
|
|
||||||
rawString() ||
|
|
||||||
characterOrLifetime() ||
|
|
||||||
string() ||
|
|
||||||
attribute() ||
|
|
||||||
number() ||
|
|
||||||
word() ||
|
|
||||||
singleCharacter()
|
|
||||||
if (!consumed) at++
|
|
||||||
}
|
|
||||||
return spans
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun emit(start: Int, kind: Kind) {
|
|
||||||
if (at > start) spans.add(Span(start, at, kind))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun starts(token: String) = code.startsWith(token, at)
|
|
||||||
|
|
||||||
/** Whether a line comment token here opens one; see [Rules.lineCommentsAtWordStart]. */
|
|
||||||
private fun atWordStart() = at == 0 || code[at - 1].isWhitespace() || code[at - 1] in ";|&("
|
|
||||||
|
|
||||||
/** Whether only whitespace stands between the start of this line and here. */
|
|
||||||
private fun atLineStart(): Boolean {
|
|
||||||
var back = at - 1
|
|
||||||
while (back >= 0 && code[back] != '\n') {
|
|
||||||
if (!code[back].isWhitespace()) return false
|
|
||||||
back--
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toEndOfLine() {
|
|
||||||
while (at < code.length && code[at] != '\n') at++
|
|
||||||
}
|
|
||||||
|
|
||||||
/** From an open bracket through the one that matches it, or to the end if none does. */
|
|
||||||
private fun toMatchingBracket() {
|
|
||||||
var depth = 0
|
|
||||||
while (at < code.length) {
|
|
||||||
when (code[at]) {
|
|
||||||
'[' -> depth++
|
|
||||||
']' -> depth--
|
|
||||||
}
|
|
||||||
at++
|
|
||||||
if (depth == 0) return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun blockComment(): Boolean {
|
|
||||||
val comment = rules.blockComment ?: return false
|
|
||||||
if (!starts(comment.open)) return false
|
|
||||||
val start = at
|
|
||||||
at += comment.open.length
|
|
||||||
var depth = 1
|
|
||||||
while (at < code.length && depth > 0) {
|
|
||||||
// The closer is tried first so that a language whose two delimiters are the same string
|
|
||||||
// -- CoffeeScript's `###` -- closes rather than nesting forever.
|
|
||||||
if (starts(comment.close)) {
|
|
||||||
depth--
|
|
||||||
at += comment.close.length
|
|
||||||
} else if (comment.nests && starts(comment.open)) {
|
|
||||||
depth++
|
|
||||||
at += comment.open.length
|
|
||||||
} else {
|
|
||||||
at++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emit(start, Kind.COMMENT)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun lineComment(): Boolean {
|
|
||||||
if (rules.lineComments.none { starts(it) }) return false
|
|
||||||
if (rules.lineCommentsAtWordStart && !atWordStart()) return false
|
|
||||||
val start = at
|
|
||||||
toEndOfLine()
|
|
||||||
emit(start, Kind.COMMENT)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rust and RON: `b`? `r` `#`* `"` … `"` `#`*, with no escapes inside. */
|
|
||||||
private fun rawString(): Boolean {
|
|
||||||
if (!rules.rawStrings) return false
|
|
||||||
var ahead = at
|
|
||||||
if (code.getOrNull(ahead) == 'b') ahead++
|
|
||||||
if (code.getOrNull(ahead) != 'r') return false
|
|
||||||
ahead++
|
|
||||||
var hashes = 0
|
|
||||||
while (code.getOrNull(ahead) == '#') {
|
|
||||||
ahead++
|
|
||||||
hashes++
|
|
||||||
}
|
|
||||||
if (code.getOrNull(ahead) != '"') return false
|
|
||||||
val start = at
|
|
||||||
val closer = "\"" + "#".repeat(hashes)
|
|
||||||
val closed = code.indexOf(closer, ahead + 1)
|
|
||||||
at = if (closed < 0) code.length else closed + closer.length
|
|
||||||
emit(start, Kind.STRING)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** See [Rules.lifetimes]: an apostrophe that is not a character literal opens nothing. */
|
|
||||||
private fun characterOrLifetime(): Boolean {
|
|
||||||
if (!rules.lifetimes || code[at] != '\'') return false
|
|
||||||
val next = code.getOrNull(at + 1) ?: return false
|
|
||||||
if (next == '\\' || code.getOrNull(at + 2) == '\'') {
|
|
||||||
quoted(Quote("'", "'", escapes = true))
|
|
||||||
} else {
|
|
||||||
at++
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun string(): Boolean {
|
|
||||||
// Longest opener wins, so Kotlin's `"""` is one delimiter rather than an empty string
|
|
||||||
// followed by a quote. A loop rather than filter/maxBy: this runs at every character of
|
|
||||||
// ordinary code, and the pair of lists that would allocate is the whole cost of the scan.
|
|
||||||
var quote: Quote? = null
|
|
||||||
for (candidate in rules.quotes) {
|
|
||||||
if (starts(candidate.open) && candidate.open.length > (quote?.open?.length ?: 0)) {
|
|
||||||
quote = candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
quoted(quote ?: return false)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun quoted(quote: Quote) {
|
|
||||||
val start = at
|
|
||||||
at += quote.open.length
|
|
||||||
while (at < code.length) {
|
|
||||||
if (quote.escapes && code[at] == '\\' && at + 1 < code.length) {
|
|
||||||
at += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (starts(quote.close)) {
|
|
||||||
at += quote.close.length
|
|
||||||
break
|
|
||||||
}
|
|
||||||
at++
|
|
||||||
}
|
|
||||||
at = at.coerceAtMost(code.length)
|
|
||||||
emit(start, Kind.STRING)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun attribute(): Boolean {
|
|
||||||
val start = at
|
|
||||||
when (rules.attributes) {
|
|
||||||
Attributes.NONE -> return false
|
|
||||||
Attributes.AT_WORD -> {
|
|
||||||
if (code[at] != '@' || !isWordStart(code.getOrNull(at + 1))) return false
|
|
||||||
at++
|
|
||||||
while (at < code.length && isWordPart(code[at])) at++
|
|
||||||
}
|
|
||||||
Attributes.HASH_BRACKET -> {
|
|
||||||
if (code[at] != '#') return false
|
|
||||||
var ahead = at + 1
|
|
||||||
if (code.getOrNull(ahead) == '!') ahead++
|
|
||||||
if (code.getOrNull(ahead) != '[') return false
|
|
||||||
at = ahead
|
|
||||||
toMatchingBracket()
|
|
||||||
}
|
|
||||||
Attributes.HASH_LINE -> {
|
|
||||||
if (code[at] != '#' || !atLineStart()) return false
|
|
||||||
toEndOfLine()
|
|
||||||
}
|
|
||||||
Attributes.LINE_BRACKET -> {
|
|
||||||
if (code[at] != '[' || !atLineStart()) return false
|
|
||||||
toMatchingBracket()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emit(start, Kind.METADATA)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A number is a run starting with a digit and carrying on through letters, digits, `_` and `.`
|
|
||||||
* -- which covers `0xFF`, `1_000`, `1u32` and `3.14` without a grammar for any of them.
|
|
||||||
*/
|
|
||||||
private fun number(): Boolean {
|
|
||||||
if (!code[at].isDigit()) return false
|
|
||||||
val start = at
|
|
||||||
while (
|
|
||||||
at < code.length && (code[at].isLetterOrDigit() || code[at] == '_' || code[at] == '.')
|
|
||||||
) {
|
|
||||||
at++
|
|
||||||
}
|
|
||||||
emit(start, Kind.LITERAL)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun word(): Boolean {
|
|
||||||
if (!isWordStart(code[at])) return false
|
|
||||||
val start = at
|
|
||||||
while (at < code.length && isWordPart(code[at])) at++
|
|
||||||
if (code.substring(start, at) in rules.keywords) emit(start, Kind.KEYWORD)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun singleCharacter(): Boolean {
|
|
||||||
val kind =
|
|
||||||
when (code[at]) {
|
|
||||||
in PUNCTUATION -> Kind.PUNCTUATION
|
|
||||||
in MARKS -> Kind.MARK
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
at++
|
|
||||||
emit(at - 1, kind)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isWordStart(c: Char?) = c != null && (c.isLetter() || c == '_')
|
|
||||||
|
|
||||||
private fun isWordPart(c: Char) = c.isLetterOrDigit() || c == '_'
|
|
||||||
@@ -1,669 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
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.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
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.layout.onSizeChanged
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
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.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/** What a row says about itself while an operation is running on it. See [BusyItem]. */
|
|
||||||
private const val IMPORTING = "importing"
|
|
||||||
private const val DELETING = "deleting"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the rows further down a batch say while they wait their turn.
|
|
||||||
*
|
|
||||||
* Its own word rather than the operation's, because nothing has been done to this session yet, so a
|
|
||||||
* batch stopped here leaves it exactly as it was. Marked from the moment the batch is handed over
|
|
||||||
* all the same -- a queued row that still looked ordinary was still tappable.
|
|
||||||
*/
|
|
||||||
private const val WAITING = "waiting"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long a row that has just moved ignores being touched.
|
|
||||||
*
|
|
||||||
* A batch takes rows out of the list as each one lands, so everything below the one that went
|
|
||||||
* slides up -- and a tap already on its way then arrives at whichever row moved into that place. On
|
|
||||||
* this screen that means importing a session nobody chose.
|
|
||||||
*
|
|
||||||
* Swallowed silently rather than shown, because anything drawn on every row a batch passes would be
|
|
||||||
* a flicker running down the list.
|
|
||||||
*/
|
|
||||||
private const val SETTLE_MS = 500L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Continuing a Claude Code session the machine already has.
|
|
||||||
*
|
|
||||||
* The list is the machine's answer, not this app's. Choosing one sends its **id**, never a path, so
|
|
||||||
* an enrolled phone cannot turn this screen into a file reader.
|
|
||||||
*
|
|
||||||
* Holding a row selects it and puts the screen in selection mode, where the options that act on a
|
|
||||||
* selection appear along the bottom. That exists because these arrive in bulk -- a machine
|
|
||||||
* accumulates dozens of abandoned sessions -- and one confirmation dialog per row is the reason
|
|
||||||
* clearing them out was not worth doing.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var machines by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
|
|
||||||
var chosen by remember { mutableStateOf<Machine?>(null) }
|
|
||||||
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
|
|
||||||
|
|
||||||
// What is happening to each row right now, as the word the row shows. A map keyed by id rather
|
|
||||||
// than a flag per row, because the rows are rebuilt from whatever the server last said and this
|
|
||||||
// belongs to the request rather than to the session.
|
|
||||||
var running by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
|
||||||
// Which rows the reader has picked out. Empty means selection mode is off: a selection mode
|
|
||||||
// with nothing selected is a state with no controls in it and no way to leave except Back.
|
|
||||||
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
|
|
||||||
// Failures that belong to one row rather than to the screen, shown on that row. A batch is
|
|
||||||
// exactly where a single banner fails: nine deletes succeeded and one did not, and the banner
|
|
||||||
// cannot say which.
|
|
||||||
var rowErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
|
||||||
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
|
|
||||||
// themselves, not a flag, so the dialog can say what it is about.
|
|
||||||
var confirming by remember { mutableStateOf<List<Importable>?>(null) }
|
|
||||||
// Set from the selected Claude provider rather than repeated in the app.
|
|
||||||
var permissionMode by remember { mutableStateOf("") }
|
|
||||||
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
|
|
||||||
// so a tap reading it needs no recomposition.
|
|
||||||
val movedAt = remember { mutableMapOf<String, Long>() }
|
|
||||||
fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the list and takes the row states from it.
|
|
||||||
*
|
|
||||||
* Taken from the answer rather than kept across the load: the server is what knows what is
|
|
||||||
* running, and this screen may be opening on work another phone started.
|
|
||||||
*/
|
|
||||||
suspend fun fetchInto(machine: Machine): LoadState<List<Importable>> =
|
|
||||||
try {
|
|
||||||
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, machine.id) }
|
|
||||||
running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
|
|
||||||
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
|
|
||||||
LoadState.Loaded(rows)
|
|
||||||
} catch (err: Exception) {
|
|
||||||
LoadState.Error(err.message ?: "Couldn't list sessions")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadSessions(machine: Machine) {
|
|
||||||
sessions = LoadState.Loading
|
|
||||||
selected = emptySet()
|
|
||||||
scope.launch { sessions = fetchInto(machine) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Takes a row out of the list, once the machine no longer has it to offer. */
|
|
||||||
fun forget(id: String) {
|
|
||||||
val loaded = sessions
|
|
||||||
if (loaded is LoadState.Loaded) {
|
|
||||||
// Only this row, and only what changed -- refetching instead put every other row back
|
|
||||||
// through a loading spinner to report a change that was never in doubt.
|
|
||||||
sessions = LoadState.Loaded(loaded.value.filterNot { it.id == id })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(reloadToken) {
|
|
||||||
machines =
|
|
||||||
try {
|
|
||||||
val found = withContext(Dispatchers.IO) { fetchMachines(settings) }
|
|
||||||
found.firstOrNull()?.let {
|
|
||||||
chosen = it
|
|
||||||
loadSessions(it)
|
|
||||||
}
|
|
||||||
LoadState.Loaded(found)
|
|
||||||
} catch (err: Exception) {
|
|
||||||
LoadState.Error(err.message ?: "Couldn't list machines")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hands [targets] to the server in one request, marking every row it covers.
|
|
||||||
*
|
|
||||||
* The request only *starts* the work -- the server runs it and says how each row went on the
|
|
||||||
* change stream, which is what lets this screen be left while a batch is still going.
|
|
||||||
*
|
|
||||||
* Marked [WAITING] rather than with the operation's own word until the server confirms. Between
|
|
||||||
* the request leaving and the `started` event coming back, "we have asked" is the truth and "it
|
|
||||||
* is importing" is a guess.
|
|
||||||
*
|
|
||||||
* The selection is dropped as the work is handed over, not when it finishes: the screen goes
|
|
||||||
* back to how it started, and what says the work is happening is the rows it is happening to.
|
|
||||||
*/
|
|
||||||
fun handOver(targets: List<Importable>, send: suspend (List<String>) -> Unit) {
|
|
||||||
selected = emptySet()
|
|
||||||
running = running + targets.associate { it.id to WAITING }
|
|
||||||
rowErrors = rowErrors - targets.map { it.id }.toSet()
|
|
||||||
val machine = chosen
|
|
||||||
val ids = targets.map { it.id }
|
|
||||||
scope.launch {
|
|
||||||
// One request for the whole batch, not one per row. Sent row by row, a handover was
|
|
||||||
// only as atomic as the network, and what came back was some rows running and some
|
|
||||||
// untouched -- indistinguishable, on the list, from rows nobody had picked.
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { send(ids) }
|
|
||||||
} catch (err: Exception) {
|
|
||||||
// The server never took it, so nothing is running and no event will arrive to say
|
|
||||||
// so. This is the one failure the screen must report itself -- and it is the whole
|
|
||||||
// batch's failure, which is the point: no row was singled out.
|
|
||||||
running = running - ids.toSet()
|
|
||||||
rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" }
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then ask what actually happened, if anything still looks outstanding.
|
|
||||||
//
|
|
||||||
// The change stream is a broadcast with no memory, so an operation that started and
|
|
||||||
// finished while it was still connecting is one nothing will ever be said about -- and
|
|
||||||
// the row sits marked for ever. That is not hypothetical: with responses held back far
|
|
||||||
// enough, one row of a pair of deletes cleared and the other stayed on "waiting".
|
|
||||||
//
|
|
||||||
// The listing is the repair, because it carries the same state the events do. Only when
|
|
||||||
// something still looks outstanding, so the ordinary case does not pay for a second
|
|
||||||
// listing, which is the most expensive call this screen makes.
|
|
||||||
if (machine != null && targets.any { running.containsKey(it.id) }) {
|
|
||||||
// Quietly: no Loading, because blanking the list to report on rows that are already
|
|
||||||
// saying what is happening to them is the flicker this screen avoids everywhere
|
|
||||||
// else.
|
|
||||||
sessions = fetchInto(machine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
|
|
||||||
LaunchedEffect(chosen?.id, provider?.name) {
|
|
||||||
permissionMode = provider?.defaultPermissionMode.orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Continues [targets] in the background, leaving the screen where it is. */
|
|
||||||
fun importAll(targets: List<Importable>) {
|
|
||||||
val machine = chosen ?: return
|
|
||||||
val useProvider = provider ?: return
|
|
||||||
handOver(targets) { ids ->
|
|
||||||
startImport(
|
|
||||||
settings,
|
|
||||||
machine = machine.id,
|
|
||||||
sessionIds = ids,
|
|
||||||
provider = useProvider.name,
|
|
||||||
permissionMode = permissionMode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Continues one session and goes to it.
|
|
||||||
*
|
|
||||||
* The tap keeps waiting, because "take me there" needs the session it made and the server's
|
|
||||||
* accepted-and-running answer does not carry one. It is one session and somebody is watching
|
|
||||||
* it, which is the case where waiting is the right thing anyway.
|
|
||||||
*/
|
|
||||||
fun importAndOpen(target: Importable) {
|
|
||||||
val machine = chosen ?: return
|
|
||||||
val useProvider = provider ?: return
|
|
||||||
running = running + (target.id to IMPORTING)
|
|
||||||
rowErrors = rowErrors - target.id
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val spawned =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
spawnSession(
|
|
||||||
settings,
|
|
||||||
machine = machine.id,
|
|
||||||
provider = useProvider.name,
|
|
||||||
// Nothing to say: the server titles it from the session it continues.
|
|
||||||
title = "",
|
|
||||||
permissionMode = permissionMode,
|
|
||||||
import = target.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
forget(target.id)
|
|
||||||
onImported(spawned)
|
|
||||||
} catch (err: Exception) {
|
|
||||||
rowErrors = rowErrors + (target.id to (err.message ?: "Couldn't import that one"))
|
|
||||||
} finally {
|
|
||||||
running = running - target.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live changes to what the server is doing to these sessions, for as long as this screen is up.
|
|
||||||
// The listing already carried the same state when the screen opened -- this is what keeps it
|
|
||||||
// current afterwards, including for work another phone started.
|
|
||||||
//
|
|
||||||
// Failures here are deliberately quiet. There is nothing for a reader to do about a dropped
|
|
||||||
// event stream, and every state it would have carried is in the next listing.
|
|
||||||
val liveChanges = remember {
|
|
||||||
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
|
|
||||||
}
|
|
||||||
LaunchedEffect(chosen?.id) {
|
|
||||||
val machine = chosen?.id ?: return@LaunchedEffect
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
val stream = ImportableStream(settings, machine)
|
|
||||||
liveChanges.set(stream)
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
stream.run(onOpen = {}) { change ->
|
|
||||||
when (change.state) {
|
|
||||||
"started" ->
|
|
||||||
running =
|
|
||||||
running + (change.session to (change.operation ?: WAITING))
|
|
||||||
// Gone from the machine either way: a delete removed the
|
|
||||||
// transcript, an import made it a session.
|
|
||||||
"finished" -> {
|
|
||||||
running = running - change.session
|
|
||||||
forget(change.session)
|
|
||||||
}
|
|
||||||
"failed" -> {
|
|
||||||
running = running - change.session
|
|
||||||
rowErrors =
|
|
||||||
rowErrors +
|
|
||||||
(change.session to (change.message ?: "Didn't work"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
||||||
// The screen leaving, not a failure -- and swallowing it would leave this loop
|
|
||||||
// reconnecting to a stream nobody is watching.
|
|
||||||
throw e
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Retried below; the listing is the truth in the meantime. Any failure, not
|
|
||||||
// only an [ApiException]: a stream is an optimisation over the listing here,
|
|
||||||
// and catching only the expected failure means an unexpected one closes the app
|
|
||||||
// from a screen that is merely loading a list.
|
|
||||||
} finally {
|
|
||||||
stream.close()
|
|
||||||
}
|
|
||||||
delay(RECONNECT_DELAY_MS)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Cancellation cannot interrupt a blocking socket read; closing is what unblocks it.
|
|
||||||
liveChanges.getAndSet(null)?.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The screen leaving the composition entirely, which the effect above does not cover.
|
|
||||||
DisposableEffect(chosen?.id) { onDispose { liveChanges.get()?.close() } }
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
|
||||||
// No heading: the tab that selected this one already says "Import". The sentence below
|
|
||||||
// stays, because it says what importing *does*, which the tab label cannot.
|
|
||||||
Text(
|
|
||||||
"Sessions Claude Code already has on the machine. Importing continues one where " +
|
|
||||||
"it left off; the transcript here shows its recent history. Hold one to " +
|
|
||||||
"select it, and several at a time.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
|
|
||||||
when (val loaded = machines) {
|
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
|
||||||
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
|
|
||||||
is LoadState.Loaded -> {
|
|
||||||
// Only worth choosing when there is a choice.
|
|
||||||
if (loaded.value.size > 1) {
|
|
||||||
Row(Modifier.fillMaxWidth()) {
|
|
||||||
loaded.value.forEach { machine ->
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
chosen = machine
|
|
||||||
loadSessions(machine)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
machine.name,
|
|
||||||
color =
|
|
||||||
if (machine.id == chosen?.id)
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (chosen != null && provider == null) {
|
|
||||||
Text(
|
|
||||||
"${chosen?.name} has no Claude CLI, so there is nothing here to " +
|
|
||||||
"continue.",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ChipGroup(
|
|
||||||
label = "Permissions",
|
|
||||||
options = provider?.permissionModes.orEmpty(),
|
|
||||||
selected = permissionMode,
|
|
||||||
onSelect = { permissionMode = it },
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
ImportableList(
|
|
||||||
state = sessions,
|
|
||||||
running = running,
|
|
||||||
settling = ::settling,
|
|
||||||
selected = selected,
|
|
||||||
errors = rowErrors,
|
|
||||||
bottomInset = barHeight,
|
|
||||||
onToggle = { session ->
|
|
||||||
selected =
|
|
||||||
if (session.id in selected) selected - session.id
|
|
||||||
else selected + session.id
|
|
||||||
},
|
|
||||||
onOpen = { session -> importAndOpen(session) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beside nothing in particular, because a selection is not one row: the options that act on
|
|
||||||
// it belong to the screen, and the bottom is where a thumb already is.
|
|
||||||
if (selected.isNotEmpty()) {
|
|
||||||
val picked =
|
|
||||||
(sessions as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
|
|
||||||
SelectionBar(
|
|
||||||
count = picked.size,
|
|
||||||
modifier =
|
|
||||||
Modifier.align(Alignment.BottomCenter).onSizeChanged {
|
|
||||||
barHeight = with(density) { it.height.toDp() }
|
|
||||||
},
|
|
||||||
onDelete = { confirming = picked },
|
|
||||||
onImport = { importAll(picked) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
confirming?.let { targets ->
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { confirming = null },
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
if (targets.size == 1) "Delete this session?"
|
|
||||||
else "Delete ${targets.size} sessions?"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
// One name is worth showing and twelve are not, so the count stands in for
|
|
||||||
// them. The sentence after it is the same either way, because what deleting
|
|
||||||
// costs does not change with how many.
|
|
||||||
(if (targets.size == 1) "\"${targets.first().title}\"\n\n" else "") +
|
|
||||||
"Claude Code keeps no copy: its transcript is the session, so this ends " +
|
|
||||||
"any chance of resuming that conversation. Sessions already imported " +
|
|
||||||
"here keep the history they replayed, but cannot be continued."
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
val machine = chosen ?: return@TextButton
|
|
||||||
confirming = null
|
|
||||||
handOver(targets) { ids -> deleteImportable(settings, machine.id, ids) }
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
// Coloured by consequence: this takes something away, wherever it appears.
|
|
||||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What can be done to the rows that are selected.
|
|
||||||
*
|
|
||||||
* Delete and Import only, for now: they are the two things this screen has ever done to a session,
|
|
||||||
* and an option that appears here has to work on every row in a selection.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun SelectionBar(
|
|
||||||
count: Int,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
onImport: () -> 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)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
TextButton(onClick = onImport) { Text("Import") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun ImportableList(
|
|
||||||
state: LoadState<List<Importable>>,
|
|
||||||
/** Rows an operation is running on, as the word each one shows. */
|
|
||||||
running: Map<String, String>,
|
|
||||||
/** Whether this row has just moved and should ignore being touched -- see [SETTLE_MS]. */
|
|
||||||
settling: (String) -> Boolean,
|
|
||||||
selected: Set<String>,
|
|
||||||
errors: Map<String, String>,
|
|
||||||
/** What the selection bar covers, so the last row can still be reached under it. */
|
|
||||||
bottomInset: Dp,
|
|
||||||
onToggle: (Importable) -> Unit,
|
|
||||||
onOpen: (Importable) -> Unit,
|
|
||||||
) {
|
|
||||||
when (state) {
|
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
|
||||||
is LoadState.Error -> Text(state.message, color = MaterialTheme.colorScheme.error)
|
|
||||||
is LoadState.Loaded ->
|
|
||||||
if (state.value.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
"No Claude Code sessions on that machine.",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val selecting = selected.isNotEmpty()
|
|
||||||
LazyColumn(
|
|
||||||
Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(bottom = bottomInset),
|
|
||||||
) {
|
|
||||||
uniqueItems(state.value, key = { it.id }) { session ->
|
|
||||||
val picked = session.id in selected
|
|
||||||
BusyItem(label = running[session.id]) {
|
|
||||||
Card(
|
|
||||||
colors =
|
|
||||||
if (picked)
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor =
|
|
||||||
MaterialTheme.colorScheme.secondaryContainer,
|
|
||||||
contentColor =
|
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
else CardDefaults.cardColors(),
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
.combinedClickable(
|
|
||||||
// Off while something is happening to this row -- see
|
|
||||||
// [BusyItem], which draws that but leaves the gestures
|
|
||||||
// alone so the list still scrolls.
|
|
||||||
enabled = running[session.id] == null,
|
|
||||||
onClick = {
|
|
||||||
if (settling(session.id)) return@combinedClickable
|
|
||||||
// In selection mode a tap is a selection, so the
|
|
||||||
// reader is never one mis-tap away from starting a
|
|
||||||
// CLI they were only picking rows for.
|
|
||||||
//
|
|
||||||
// Outside it, a tap continues the session -- except
|
|
||||||
// on a row that cannot be continued, where it
|
|
||||||
// selects instead. That row's only remaining action
|
|
||||||
// is Delete, and a tap that did nothing at all
|
|
||||||
// would be a worse answer. Two `--resume` processes
|
|
||||||
// on one transcript each replay the other's writes,
|
|
||||||
// which is why this must not simply try.
|
|
||||||
if (selecting || session.inUse == "yes")
|
|
||||||
onToggle(session)
|
|
||||||
else onOpen(session)
|
|
||||||
},
|
|
||||||
onLongClick = {
|
|
||||||
if (!settling(session.id)) onToggle(session)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Row(verticalAlignment = Alignment.Top) {
|
|
||||||
Text(
|
|
||||||
session.title,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
// Beside the title, because "which one was I just in" is
|
|
||||||
// the question this list answers and the order already
|
|
||||||
// reflects it.
|
|
||||||
Text(
|
|
||||||
relativeTime(session.modified),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
// The path first, and the only thing here that is cut: it is
|
|
||||||
// one long value with no natural break. Cut at the head,
|
|
||||||
// because a path is identified by its tail and these all share
|
|
||||||
// a long prefix. By the row's real width rather than a
|
|
||||||
// character count, which was one guess for every font size and
|
|
||||||
// screen.
|
|
||||||
session.cwd
|
|
||||||
.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { cwd ->
|
|
||||||
Text(
|
|
||||||
cwd,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.StartEllipsis,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
statsOf(session),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
// Its own line and its own colour, because it differs in kind
|
|
||||||
// from the stats above rather than in degree: those describe
|
|
||||||
// the session, this says whether taking it is safe at all.
|
|
||||||
warningOf(session)?.let { warning ->
|
|
||||||
Text(
|
|
||||||
warning,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = warningColor,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Reported where it happened, in the server's own words.
|
|
||||||
errors[session.id]?.let { message ->
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What this session is: the measurements, in the order they are worth knowing. */
|
|
||||||
private fun statsOf(session: Importable): String =
|
|
||||||
listOfNotNull(
|
|
||||||
// Said, because a name and a last message are different claims: one describes the
|
|
||||||
// session, the other is only what happened last in it.
|
|
||||||
if (session.named) "named" else null,
|
|
||||||
// What continuing it costs, which is the question this list is really asked. Absent
|
|
||||||
// rather than zero when nothing has been measured -- a session with no turns yet has no
|
|
||||||
// figure, not a figure of none.
|
|
||||||
session.contextTokens?.let { "${it / 1000}k context" },
|
|
||||||
"${session.lines} lines",
|
|
||||||
// Kept beside the context figure because the two disagree usefully: most of a large
|
|
||||||
// transcript is history from before a compaction, so a big file can be cheap to
|
|
||||||
// continue.
|
|
||||||
humanSize(session.bytes),
|
|
||||||
)
|
|
||||||
.joinToString(" · ")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Why this session might not be safe to take, if it isn't.
|
|
||||||
*
|
|
||||||
* Words rather than only a colour: "open somewhere else" and "we could not check" differ in kind,
|
|
||||||
* and no shade distinguishes them.
|
|
||||||
*/
|
|
||||||
private fun warningOf(session: Importable): String? =
|
|
||||||
when (session.inUse) {
|
|
||||||
// What was measured is that a live process on that machine holds this session open. Which
|
|
||||||
// process is not measured, so it isn't claimed: "a terminal -- close it there first" sent
|
|
||||||
// people looking for a window that need not exist. Naming a place the reader then can't
|
|
||||||
// find turns a correct refusal into a wrong instruction.
|
|
||||||
"yes" -> "something on that machine is running it"
|
|
||||||
"unknown" -> "can't tell if it's open"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a machine's Claude Code sessions are having done to them, live.
|
|
||||||
*
|
|
||||||
* The import screen starts and then leaves work behind: the server runs it, so the phone that asked
|
|
||||||
* is free to go elsewhere and the answer arrives here rather than as a reply. What the screen shows
|
|
||||||
* on arrival comes from the listing, which carries the same state for whoever was not connected
|
|
||||||
* when it changed; this is only what keeps a screen somebody is watching current.
|
|
||||||
*
|
|
||||||
* The connection and its framing belong to [Sse]. Closing is the caller's cancellation path, and
|
|
||||||
* the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in
|
|
||||||
* the next listing.
|
|
||||||
*/
|
|
||||||
class ImportableStream(settings: ServerSettings, private val machine: String) {
|
|
||||||
private val stream = Sse(settings)
|
|
||||||
|
|
||||||
fun close() = stream.close()
|
|
||||||
|
|
||||||
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
|
|
||||||
stream.run("/machines/$machine/importable/events", onOpen) { _, data ->
|
|
||||||
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A language the highlighter can colour.
|
|
||||||
*
|
|
||||||
* The names the reader writes after the backticks are aliases onto these; [fenceLanguage] holds
|
|
||||||
* that table. A word with no entry there is null, and null is drawn plain, because a fence coloured
|
|
||||||
* by another language's rules looks highlighted and is wrong in a way the reader cannot see.
|
|
||||||
*
|
|
||||||
* Nearly all of them are a row of [RULES], read by one shared scanner. [MARKDOWN] is the one that
|
|
||||||
* is not; see [spansOf].
|
|
||||||
*/
|
|
||||||
enum class Language {
|
|
||||||
C,
|
|
||||||
COFFEESCRIPT,
|
|
||||||
CPP,
|
|
||||||
CSHARP,
|
|
||||||
DART,
|
|
||||||
DIFF,
|
|
||||||
FISH,
|
|
||||||
GO,
|
|
||||||
JAVA,
|
|
||||||
JAVASCRIPT,
|
|
||||||
JSON,
|
|
||||||
KOTLIN,
|
|
||||||
MARKDOWN,
|
|
||||||
PERL,
|
|
||||||
PHP,
|
|
||||||
PYTHON,
|
|
||||||
RON,
|
|
||||||
RUBY,
|
|
||||||
RUST,
|
|
||||||
SHELL,
|
|
||||||
SWIFT,
|
|
||||||
TOML,
|
|
||||||
TYPESCRIPT,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What [scan] needs to know about one language -- data, not code, so that adding a language is a
|
|
||||||
* row in [RULES] rather than a branch anywhere.
|
|
||||||
*
|
|
||||||
* The two forms that could not be expressed as data are flags here and a few lines in the scanner:
|
|
||||||
* [rawStrings], because the closing delimiter depends on how many hashes the opener had, and
|
|
||||||
* [lifetimes], because whether `'` opens anything at all depends on what follows it.
|
|
||||||
*/
|
|
||||||
data class Rules(
|
|
||||||
/** Words drawn as keywords. Only plain words; the scanner cannot reach anything else. */
|
|
||||||
val keywords: Set<String>,
|
|
||||||
/** Tokens that open a comment running to the end of the line. */
|
|
||||||
val lineComments: List<String> = emptyList(),
|
|
||||||
/**
|
|
||||||
* Whether [lineComments] count only at the start of a word. The shells need it: `$#`, `${#x}`
|
|
||||||
* and `a#b` are not comments, and greying the rest of those lines is one of the mistakes this
|
|
||||||
* scanner exists to stop.
|
|
||||||
*/
|
|
||||||
val lineCommentsAtWordStart: Boolean = false,
|
|
||||||
val blockComment: BlockComment? = null,
|
|
||||||
/** The string forms. The longest opener that matches wins, so `"""` is tried before `"`. */
|
|
||||||
val quotes: List<Quote> = emptyList(),
|
|
||||||
val attributes: Attributes = Attributes.NONE,
|
|
||||||
/** Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes. */
|
|
||||||
val rawStrings: Boolean = false,
|
|
||||||
/**
|
|
||||||
* Rust: `'` opens a character literal only when a backslash or one character and a `'` follow.
|
|
||||||
* Otherwise it is a lifetime or a label -- without this, `'a` opens a string that runs to the
|
|
||||||
* next apostrophe in the block.
|
|
||||||
*/
|
|
||||||
val lifetimes: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class BlockComment(val open: String, val close: String, val nests: Boolean)
|
|
||||||
|
|
||||||
/** One string form. [escapes] is whether a backslash escapes the closer (and itself). */
|
|
||||||
data class Quote(val open: String, val close: String, val escapes: Boolean)
|
|
||||||
|
|
||||||
/** What opens a metadata span, of the shapes that exist across these languages. */
|
|
||||||
enum class Attributes {
|
|
||||||
NONE,
|
|
||||||
/** `@` and a word: Kotlin and Java annotations, Python decorators. */
|
|
||||||
AT_WORD,
|
|
||||||
/** `#[` or `#![` through the matching `]`: Rust and RON attributes. */
|
|
||||||
HASH_BRACKET,
|
|
||||||
/** `#` at the start of a line, to the end of it: the C preprocessor. */
|
|
||||||
HASH_LINE,
|
|
||||||
/** `[` at the start of a line through the matching `]`: a TOML table header. */
|
|
||||||
LINE_BRACKET,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The spans [language] colours in [code] -- the one way to ask, whatever the language turns out to
|
|
||||||
* be made of.
|
|
||||||
*
|
|
||||||
* Nearly every language here is tokens, which is a row of [RULES] and the one shared scanner.
|
|
||||||
* Markdown has none of those, and what a character means there depends on where on the line it
|
|
||||||
* sits, so it brings a scanner of its own. That is the whole extension point -- a new language is a
|
|
||||||
* row of rules or an entry in [SCANNERS], and no caller learns which one it got.
|
|
||||||
*/
|
|
||||||
fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(language)(code)
|
|
||||||
|
|
||||||
// Lazy for the same reason [RULES] is, since it reads it.
|
|
||||||
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
|
|
||||||
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
|
|
||||||
mapOf(Language.DIFF to ::scanDiff, Language.MARKDOWN to ::scanMarkdown)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val C_STYLE = BlockComment("/*", "*/", nests = false)
|
|
||||||
private val NESTING = BlockComment("/*", "*/", nests = true)
|
|
||||||
|
|
||||||
private val DOUBLE = Quote("\"", "\"", escapes = true)
|
|
||||||
private val SINGLE = Quote("'", "'", escapes = true)
|
|
||||||
private val TRIPLE_DOUBLE = Quote("\"\"\"", "\"\"\"", escapes = true)
|
|
||||||
private val TRIPLE_SINGLE = Quote("'''", "'''", escapes = true)
|
|
||||||
|
|
||||||
// Lazy because the keyword sets below are top-level properties too, and a file's properties
|
|
||||||
// initialize in the order they are written: read eagerly here, every set would be null.
|
|
||||||
private val RULES: Map<Language, Rules> by lazy {
|
|
||||||
mapOf(
|
|
||||||
Language.C to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_C,
|
|
||||||
lineComments = listOf("//"),
|
|
||||||
blockComment = C_STYLE,
|
|
||||||
quotes = listOf(DOUBLE, SINGLE),
|
|
||||||
attributes = Attributes.HASH_LINE,
|
|
||||||
),
|
|
||||||
Language.CPP to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_CPP,
|
|
||||||
lineComments = listOf("//"),
|
|
||||||
blockComment = C_STYLE,
|
|
||||||
quotes = listOf(DOUBLE, SINGLE),
|
|
||||||
attributes = Attributes.HASH_LINE,
|
|
||||||
),
|
|
||||||
Language.CSHARP to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_CSHARP,
|
|
||||||
lineComments = listOf("//"),
|
|
||||||
blockComment = C_STYLE,
|
|
||||||
quotes = listOf(DOUBLE, SINGLE),
|
|
||||||
),
|
|
||||||
// `###` opens and closes a block comment and `#` opens a line one, which is why the scanner
|
|
||||||
// tries the block opener first.
|
|
||||||
Language.COFFEESCRIPT to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_COFFEESCRIPT,
|
|
||||||
lineComments = listOf("#"),
|
|
||||||
blockComment = BlockComment("###", "###", nests = false),
|
|
||||||
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
|
|
||||||
),
|
|
||||||
Language.DART to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_DART,
|
|
||||||
lineComments = listOf("//"),
|
|
||||||
blockComment = NESTING,
|
|
||||||
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
|
|
||||||
attributes = Attributes.AT_WORD,
|
|
||||||
),
|
|
||||||
Language.FISH to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_FISH,
|
|
||||||
lineComments = listOf("#"),
|
|
||||||
lineCommentsAtWordStart = true,
|
|
||||||
// fish's single quotes escape only `\'` and `\\`, which is what "skip the character
|
|
||||||
// after a backslash" already does.
|
|
||||||
quotes = listOf(DOUBLE, SINGLE),
|
|
||||||
),
|
|
||||||
Language.GO to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_GO,
|
|
||||||
lineComments = listOf("//"),
|
|
||||||
blockComment = C_STYLE,
|
|
||||||
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = false)),
|
|
||||||
),
|
|
||||||
Language.JAVA to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_JAVA,
|
|
||||||
lineComments = listOf("//"),
|
|
||||||
blockComment = C_STYLE,
|
|
||||||
quotes = listOf(DOUBLE, SINGLE),
|
|
||||||
attributes = Attributes.AT_WORD,
|
|
||||||
),
|
|
||||||
Language.JAVASCRIPT to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_JAVASCRIPT,
|
|
||||||
lineComments = listOf("//"),
|
|
||||||
blockComment = C_STYLE,
|
|
||||||
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = true)),
|
|
||||||
),
|
|
||||||
Language.JSON to Rules(keywords = KEYWORDS_JSON, quotes = listOf(DOUBLE)),
|
|
||||||
Language.KOTLIN to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_KOTLIN,
|
|
||||||
lineComments = listOf("//"),
|
|
||||||
blockComment = NESTING,
|
|
||||||
quotes = listOf(Quote("\"\"\"", "\"\"\"", escapes = false), DOUBLE, SINGLE),
|
|
||||||
attributes = Attributes.AT_WORD,
|
|
||||||
),
|
|
||||||
Language.PERL to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_PERL,
|
|
||||||
lineComments = listOf("#"),
|
|
||||||
quotes = listOf(DOUBLE, SINGLE),
|
|
||||||
),
|
|
||||||
Language.PHP to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_PHP,
|
|
||||||
lineComments = listOf("//", "#"),
|
|
||||||
blockComment = C_STYLE,
|
|
||||||
quotes = listOf(DOUBLE, SINGLE),
|
|
||||||
attributes = Attributes.AT_WORD,
|
|
||||||
),
|
|
||||||
Language.PYTHON to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_PYTHON,
|
|
||||||
lineComments = listOf("#"),
|
|
||||||
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
|
|
||||||
attributes = Attributes.AT_WORD,
|
|
||||||
),
|
|
||||||
Language.RON to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_RON,
|
|
||||||
lineComments = listOf("//"),
|
|
||||||
blockComment = NESTING,
|
|
||||||
quotes = listOf(DOUBLE, SINGLE),
|
|
||||||
attributes = Attributes.HASH_BRACKET,
|
|
||||||
rawStrings = true,
|
|
||||||
),
|
|
||||||
Language.RUBY to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_RUBY,
|
|
||||||
lineComments = listOf("#"),
|
|
||||||
quotes = listOf(DOUBLE, SINGLE),
|
|
||||||
),
|
|
||||||
Language.RUST to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_RUST,
|
|
||||||
lineComments = listOf("//"),
|
|
||||||
blockComment = NESTING,
|
|
||||||
// No `'` here: [Rules.lifetimes] decides when one opens a character literal.
|
|
||||||
quotes = listOf(DOUBLE),
|
|
||||||
attributes = Attributes.HASH_BRACKET,
|
|
||||||
rawStrings = true,
|
|
||||||
lifetimes = true,
|
|
||||||
),
|
|
||||||
Language.SHELL to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_SHELL,
|
|
||||||
lineComments = listOf("#"),
|
|
||||||
lineCommentsAtWordStart = true,
|
|
||||||
// A shell's single quotes are literal: `'a\'` is not one string.
|
|
||||||
quotes = listOf(DOUBLE, Quote("'", "'", escapes = false)),
|
|
||||||
),
|
|
||||||
Language.SWIFT to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_SWIFT,
|
|
||||||
lineComments = listOf("//"),
|
|
||||||
blockComment = NESTING,
|
|
||||||
quotes = listOf(TRIPLE_DOUBLE, DOUBLE),
|
|
||||||
attributes = Attributes.AT_WORD,
|
|
||||||
),
|
|
||||||
Language.TOML to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_TOML,
|
|
||||||
lineComments = listOf("#"),
|
|
||||||
quotes =
|
|
||||||
listOf(
|
|
||||||
TRIPLE_DOUBLE,
|
|
||||||
Quote("'''", "'''", escapes = false),
|
|
||||||
DOUBLE,
|
|
||||||
Quote("'", "'", escapes = false),
|
|
||||||
),
|
|
||||||
attributes = Attributes.LINE_BRACKET,
|
|
||||||
),
|
|
||||||
Language.TYPESCRIPT to
|
|
||||||
Rules(
|
|
||||||
keywords = KEYWORDS_TYPESCRIPT,
|
|
||||||
lineComments = listOf("//"),
|
|
||||||
blockComment = C_STYLE,
|
|
||||||
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = true)),
|
|
||||||
attributes = Attributes.AT_WORD,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The keyword sets.
|
|
||||||
*
|
|
||||||
* Every list below other than RON, TOML, fish and JSON came from dev.snipme:highlights 1.1.0
|
|
||||||
* (Apache-2.0), the library this scanner replaced, so that no fence which is coloured today turns
|
|
||||||
* plain. Entries that are not plain words were dropped -- Kotlin's `as?`, Swift's `#if` family,
|
|
||||||
* Ruby's `defined?` -- because the word scanner cannot reach them.
|
|
||||||
*/
|
|
||||||
private fun words(list: String): Set<String> =
|
|
||||||
list.split(Regex("\\s+")).filterNot(String::isEmpty).toSet()
|
|
||||||
|
|
||||||
private val KEYWORDS_C =
|
|
||||||
words(
|
|
||||||
"""auto break case char const continue default do double else enum extern float for goto if
|
|
||||||
int long register return short signed sizeof static struct switch typedef union unsigned
|
|
||||||
void volatile while"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_CPP =
|
|
||||||
words(
|
|
||||||
"""asm auto bool break case catch char class const const_cast continue default delete do
|
|
||||||
double dynamic_cast else enum explicit export extern false float for friend goto if inline
|
|
||||||
int long mutable namespace new operator private protected public register reinterpret_cast
|
|
||||||
return short signed sizeof static static_cast struct switch template this throw true try
|
|
||||||
typedef typeid typename union unsigned using virtual void volatile wchar_t while"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_CSHARP =
|
|
||||||
words(
|
|
||||||
"""abstract as base bool break byte case catch char checked class const continue decimal
|
|
||||||
default delegate do double else enum event explicit extern false finally fixed float for
|
|
||||||
foreach goto if implicit in int interface internal is lock long namespace new null object
|
|
||||||
operator out override params private protected public readonly ref return sbyte sealed short
|
|
||||||
sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked
|
|
||||||
unsafe ushort using virtual void volatile while"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_COFFEESCRIPT =
|
|
||||||
words(
|
|
||||||
"""Infinity NaN and arguments await break by case catch class continue debugger delete defer
|
|
||||||
default do else export extends false finally for function if import in instanceof is isnt
|
|
||||||
let loop new no not null of on or package return super switch this throw true try typeof
|
|
||||||
unless undefined var wait when with yield"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_DART =
|
|
||||||
words(
|
|
||||||
"""abstract as assert async await base break case catch class const continue covariant
|
|
||||||
default deferred do dynamic else enum export extends external factory false final finally
|
|
||||||
for get if implements import in interface is late library mixin new null on operator part
|
|
||||||
required rethrow return sealed set show static super switch this throw true try var void
|
|
||||||
when with while yield"""
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* fish is not in the library at all, so its fences are drawn plain today. The list is the shell's
|
|
||||||
* own words, which is what a fish fence is mostly made of.
|
|
||||||
*/
|
|
||||||
private val KEYWORDS_FISH =
|
|
||||||
words(
|
|
||||||
"""and begin break builtin case command continue else end exec for function if in not or
|
|
||||||
return switch while set echo test string math read source"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_GO =
|
|
||||||
words(
|
|
||||||
"""break case chan const continue default defer else fallthrough false for func go goto if
|
|
||||||
import interface map package range return select struct switch true type var"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_JAVA =
|
|
||||||
words(
|
|
||||||
"""abstract assert boolean break byte case catch char class const continue default do double
|
|
||||||
else enum extends final finally float for goto if implements import instanceof int interface
|
|
||||||
long native new null package private protected public return short static strictfp super
|
|
||||||
switch synchronized this throw throws transient try void volatile while"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_JAVASCRIPT =
|
|
||||||
words(
|
|
||||||
"""async await boolean break case catch class const continue debugger default delete do else
|
|
||||||
enum export extends false finally for function if implements import in instanceof interface
|
|
||||||
let new null package private protected public return super switch this throw true try typeof
|
|
||||||
var void while with yield"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_JSON = words("true false null")
|
|
||||||
|
|
||||||
private val KEYWORDS_KOTLIN =
|
|
||||||
words(
|
|
||||||
"""actual abstract annotation as break by catch class companion const constructor continue
|
|
||||||
coroutine crossinline data delegate dynamic do else enum expect external false final finally
|
|
||||||
for fun get if import in infix inline interface internal is lazy lateinit native null object
|
|
||||||
open operator out override package private protected public reified return sealed set super
|
|
||||||
suspend tailrec this throw true try typealias typeof val var vararg when while yield"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_PERL =
|
|
||||||
words(
|
|
||||||
"""__DATA__ __END__ __FILE__ __LINE__ __PACKAGE__ and cmp continue do else elsif eq eval for
|
|
||||||
foreach goto gt if last le lt my ne next no not or package redo ref return sub unless until
|
|
||||||
use while xor"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_PHP =
|
|
||||||
words(
|
|
||||||
"""__halt_compiler abstract and array as break callable case catch class clone const continue
|
|
||||||
declare default die do echo else elseif empty enddeclare endfor endforeach endif endswitch
|
|
||||||
endwhile eval exit extends final finally fn for foreach function global goto if implements
|
|
||||||
include include_once instanceof insteadof interface isset list match new or print private
|
|
||||||
protected public require require_once return static switch throw trait try unset use var
|
|
||||||
while xor yield"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_PYTHON =
|
|
||||||
words(
|
|
||||||
"""False True and as assert async await break class continue def del elif else except finally
|
|
||||||
for from global if import in is lambda nonlocal not or pass raise return try while with
|
|
||||||
yield"""
|
|
||||||
)
|
|
||||||
|
|
||||||
/** RON is not in the library either; these are the words a RON file can hold. */
|
|
||||||
private val KEYWORDS_RON = words("true false Some None inf NaN")
|
|
||||||
|
|
||||||
private val KEYWORDS_RUBY =
|
|
||||||
words(
|
|
||||||
"""__ENCODING__ __END__ __FILE__ __LINE__ BEGIN END alias and begin break case class def do
|
|
||||||
else elsif end ensure false for if in module next nil not or redo rescue retry return self
|
|
||||||
super then true undef unless until when while yield"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_RUST =
|
|
||||||
words(
|
|
||||||
"""as async await break const continue crate dyn else enum extern false fn for if impl in
|
|
||||||
let loop match mod move mut pub ref return Self self static struct super trait true type
|
|
||||||
union unsafe use where while abstract become box do final macro override priv try typeof
|
|
||||||
unsized virtual yield"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_SHELL =
|
|
||||||
words(
|
|
||||||
"""alias bg bind break builtin caller cd command compgen complete compopt continue declare
|
|
||||||
dirs disown echo enable eval exec exit export fc fg getopts hash help history jobs kill let
|
|
||||||
local logout popd printf pushd pwd read readonly return set shift shopt source suspend
|
|
||||||
test"""
|
|
||||||
)
|
|
||||||
|
|
||||||
private val KEYWORDS_SWIFT =
|
|
||||||
words(
|
|
||||||
"""_ associatedtype class deinit enum extension fileprivate func import init inout internal
|
|
||||||
let open operator private precedencegroup protocol public rethrows static struct subscript
|
|
||||||
typealias var break case catch continue default defer do else fallthrough for guard if in
|
|
||||||
repeat return throw switch where while Any as await false is nil self Self super throws true
|
|
||||||
try associativity convenience didSet dynamic final get indirect infix lazy left mutating none
|
|
||||||
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
|
||||||
unowned weak willSet"""
|
|
||||||
)
|
|
||||||
|
|
||||||
/** TOML is not in the library; `inf` and `nan` are values rather than names, like the booleans. */
|
|
||||||
private val KEYWORDS_TOML = words("true false inf nan")
|
|
||||||
|
|
||||||
private val KEYWORDS_TYPESCRIPT =
|
|
||||||
words(
|
|
||||||
"""abstract as asserts await break case catch class const constructor continue debugger
|
|
||||||
default delete do else enum export extends false finally for from function get if implements
|
|
||||||
import in infer instanceof interface is keyof let module namespace new null number object
|
|
||||||
package private protected public readonly require global return set static string super
|
|
||||||
switch this throw true try type typeof undefined unique unknown var void while with yield"""
|
|
||||||
)
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a screen knows about something it had to fetch: still finding out, got it, or couldn't.
|
|
||||||
*
|
|
||||||
* Three states rather than a value alongside a nullable error, because "we couldn't find out" must
|
|
||||||
* not share a representation with "there is nothing" -- a failed fetch would otherwise render as an
|
|
||||||
* empty list, which is the one wrong answer that looks like a right one.
|
|
||||||
*
|
|
||||||
* [Loading] and [Error] carry no payload, so they are `LoadState<Nothing>` and this is covariant in
|
|
||||||
* [T]: one `LoadState.Loading` serves every screen.
|
|
||||||
*/
|
|
||||||
sealed class LoadState<out T> {
|
|
||||||
data object Loading : LoadState<Nothing>()
|
|
||||||
|
|
||||||
data class Loaded<out T>(val value: T) : LoadState<T>()
|
|
||||||
|
|
||||||
data class Error(val message: String) : LoadState<Nothing>()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* The failure a fetch produces. Api.kt writes its messages to be read on this screen, so
|
|
||||||
* this passes one through rather than replacing it; the fallback covers only a throwable
|
|
||||||
* with no message at all, which [ApiException] never is.
|
|
||||||
*/
|
|
||||||
fun failed(e: ApiException): Error = Error(e.message ?: "Unknown error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.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)
|
|
||||||
}
|
|
||||||
@@ -1,493 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
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.AlertDialog
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
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.graphics.Color
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The machines this backend can run things on.
|
|
||||||
*
|
|
||||||
* Note what this screen cannot do: name a program. Providers are what the server found when it
|
|
||||||
* asked the machine, so adding one is "here is how to reach it" and never "here is what to run" --
|
|
||||||
* which is what keeps the enrolled token from being able to introduce commands.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun MachinesScreen(
|
|
||||||
settings: ServerSettings,
|
|
||||||
reloadToken: Int,
|
|
||||||
/** Opens one provider on one machine -- its settings, and what its server is holding. */
|
|
||||||
onProvider: (String, String) -> Unit,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var state by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
|
|
||||||
var adding by remember { mutableStateOf(false) }
|
|
||||||
var renaming by remember { mutableStateOf<Machine?>(null) }
|
|
||||||
var confirmingDelete by remember { mutableStateOf<Machine?>(null) }
|
|
||||||
var signingIn by remember { mutableStateOf<Pair<Machine, Provider>?>(null) }
|
|
||||||
var busy by remember { mutableStateOf<String?>(null) }
|
|
||||||
var actionError by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
suspend fun reload() {
|
|
||||||
state =
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchMachines(settings)) }
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(reloadToken) { reload() }
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
|
||||||
// The heading and Back are the tab row's now; adding a machine is this tab's own work and
|
|
||||||
// stays with the list it adds to.
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
TextButton(onClick = { adding = true }) { Text("Add machine") }
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.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))
|
|
||||||
}
|
|
||||||
|
|
||||||
when (val current = state) {
|
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
|
||||||
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
|
||||||
is LoadState.Loaded ->
|
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
|
||||||
uniqueItems(current.value, key = { it.id }) { machine ->
|
|
||||||
MachineCard(
|
|
||||||
machine = machine,
|
|
||||||
onRename = { renaming = machine },
|
|
||||||
onRediscover = {
|
|
||||||
scope.launch {
|
|
||||||
busy = "Asking ${machine.name} what it has…"
|
|
||||||
actionError =
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
updateMachine(
|
|
||||||
settings,
|
|
||||||
machine.id,
|
|
||||||
rediscover = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
busy = null
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDelete = { confirmingDelete = machine },
|
|
||||||
onSignIn = { provider -> signingIn = machine to provider },
|
|
||||||
onProvider = { provider -> onProvider(machine.id, provider.name) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adding) {
|
|
||||||
AddMachineDialog(
|
|
||||||
onDismiss = { adding = false },
|
|
||||||
onAdd = { name, ssh ->
|
|
||||||
adding = false
|
|
||||||
scope.launch {
|
|
||||||
busy = "Asking $name what it has…"
|
|
||||||
actionError =
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) { addMachine(settings, name, ssh) }
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
busy = null
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTest = { ssh -> withContext(Dispatchers.IO) { probeMachine(settings, ssh) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
renaming?.let { machine ->
|
|
||||||
RenameDialog(
|
|
||||||
machine = machine,
|
|
||||||
onDismiss = { renaming = null },
|
|
||||||
onRename = { name ->
|
|
||||||
renaming = null
|
|
||||||
scope.launch {
|
|
||||||
actionError =
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
updateMachine(settings, machine.id, name = name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmingDelete?.let { machine ->
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { confirmingDelete = null },
|
|
||||||
title = { Text("Remove \"${machine.name}\"?") },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
"The machine is left alone -- this only stops this app offering it. " +
|
|
||||||
"Sessions still running on it must be deleted first."
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
confirmingDelete = null
|
|
||||||
scope.launch {
|
|
||||||
actionError =
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
deleteMachine(settings, machine.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text("Remove")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
signingIn?.let { (machine, provider) ->
|
|
||||||
ProviderLoginDialog(
|
|
||||||
settings = settings,
|
|
||||||
machineId = machine.id,
|
|
||||||
machineName = machine.name,
|
|
||||||
provider = provider.name,
|
|
||||||
onDismiss = { signingIn = null },
|
|
||||||
onSignedIn = {
|
|
||||||
signingIn = null
|
|
||||||
scope.launch { reload() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MachineCard(
|
|
||||||
machine: Machine,
|
|
||||||
onRename: () -> Unit,
|
|
||||||
onRediscover: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
onSignIn: (Provider) -> Unit,
|
|
||||||
onProvider: (Provider) -> Unit,
|
|
||||||
) {
|
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(machine.name, style = MaterialTheme.typography.titleSmall)
|
|
||||||
Text(
|
|
||||||
// Not "this machine": the seeded machine is *called* that, and the card read "this
|
|
||||||
// machine / this machine".
|
|
||||||
machine.address ?: "runs where the backend does",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
if (machine.providers.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
"Nothing found on it. Install something and rediscover.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
machine.providers.forEach { provider ->
|
|
||||||
// A card of its own rather than a line of text: a provider is where the
|
|
||||||
// settings that belong to *this machine* live -- how each of its models is
|
|
||||||
// loaded, the models themselves, and the server holding them -- and those had
|
|
||||||
// nowhere to be until one llama-server came to serve every session on a
|
|
||||||
// machine. Sized by its own padding rather than by whatever control happened
|
|
||||||
// to be on its row, like the tool call cards it is built after.
|
|
||||||
Card(
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
.clickable { onProvider(provider) }
|
|
||||||
.semantics { contentDescription = "Open ${provider.name}" },
|
|
||||||
// A border, and the machine card's own surface kept underneath it.
|
|
||||||
// The tint that was here before is one step along the surface ladder
|
|
||||||
// from the card it sits in, and two adjacent surfaces render as one flat
|
|
||||||
// block: these read as lines of text in a box rather than as things to
|
|
||||||
// open. One cue, and a visible one.
|
|
||||||
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(provider.name, style = MaterialTheme.typography.titleSmall)
|
|
||||||
// What was actually found, which is the honest second line and
|
|
||||||
// the one thing here nobody can change. No arrow: a card that
|
|
||||||
// lifts off the one behind it already reads as something to open,
|
|
||||||
// and the chevron was the only thing making these look like rows
|
|
||||||
// of a list.
|
|
||||||
provider.command?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
// A program is identified by its name, which is the tail
|
|
||||||
// of its path.
|
|
||||||
overflow = TextOverflow.StartEllipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (provider.kind == "claude_cli") {
|
|
||||||
TextButton(onClick = { onSignIn(provider) }) { Text("Sign in") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
TextButton(onClick = onRename) { Text("Rename") }
|
|
||||||
TextButton(onClick = onRediscover) { Text("Rediscover") }
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
TextButton(onClick = onDelete) { Text("Remove") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AddMachineDialog(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onAdd: (String, SshDetails?) -> Unit,
|
|
||||||
onTest: suspend (SshDetails?) -> List<Provider>,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var name by remember { mutableStateOf("") }
|
|
||||||
var address by remember { mutableStateOf("") }
|
|
||||||
var identity by remember { mutableStateOf("") }
|
|
||||||
var attachmentsDir by remember { mutableStateOf("") }
|
|
||||||
var modelsDir by remember { mutableStateOf("") }
|
|
||||||
var tested by remember { mutableStateOf<String?>(null) }
|
|
||||||
var testing by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
fun details(): SshDetails? =
|
|
||||||
address
|
|
||||||
.trim()
|
|
||||||
.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { typed ->
|
|
||||||
val (host, typedPort) = splitHostAndPort(typed)
|
|
||||||
SshDetails(
|
|
||||||
address = host,
|
|
||||||
port = typedPort,
|
|
||||||
identityFile = identity.trim().ifEmpty { null },
|
|
||||||
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
|
|
||||||
modelsDir = modelsDir.trim().ifEmpty { null },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Add a machine") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
"Leave the address blank for the machine the backend runs on. " +
|
|
||||||
"What it can run is discovered, not typed.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
label = { Text("Name") },
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = address,
|
|
||||||
onValueChange = { address = it },
|
|
||||||
// Just the shape. What a blank one means is said once, in the text above this
|
|
||||||
// form -- repeating it here wrapped the label onto a second line.
|
|
||||||
label = { Text("user@host[:port]") },
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = identity,
|
|
||||||
onValueChange = { identity = it },
|
|
||||||
label = { Text("Key path on the backend") },
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
// Where a file attached from the phone lands on that machine. Blank means the
|
|
||||||
// session's own directory, which is what most people want.
|
|
||||||
OutlinedTextField(
|
|
||||||
value = attachmentsDir,
|
|
||||||
onValueChange = { attachmentsDir = it },
|
|
||||||
label = { Text("Folder for attached files (optional)") },
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
// Where that machine's GGUFs are, for a llama.cpp session on it. Blank means
|
|
||||||
// the same place this backend keeps its own downloads, read on that machine.
|
|
||||||
OutlinedTextField(
|
|
||||||
value = modelsDir,
|
|
||||||
onValueChange = { modelsDir = it },
|
|
||||||
label = { Text("Folder for models (optional)") },
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
tested?.let {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(enabled = name.isNotBlank(), onClick = { onAdd(name.trim(), details()) }) {
|
|
||||||
Text("Add")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
Row {
|
|
||||||
// Tried before saving, so a wrong address or an unauthorised key is caught while
|
|
||||||
// this form is still on screen rather than at the first spawn.
|
|
||||||
TextButton(
|
|
||||||
enabled = !testing,
|
|
||||||
onClick = {
|
|
||||||
testing = true
|
|
||||||
tested = "Asking…"
|
|
||||||
scope.launch {
|
|
||||||
tested =
|
|
||||||
runCatching { onTest(details()) }
|
|
||||||
.fold(
|
|
||||||
onSuccess = { found ->
|
|
||||||
if (found.isEmpty()) {
|
|
||||||
"Reached it, but found nothing it can run."
|
|
||||||
} else {
|
|
||||||
"Found ${found.joinToString(", ") { it.name }}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onFailure = { it.message ?: "Couldn't reach it" },
|
|
||||||
)
|
|
||||||
testing = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text("Test")
|
|
||||||
}
|
|
||||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RenameDialog(machine: Machine, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
|
||||||
var name by remember { mutableStateOf(machine.name) }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Rename") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
label = { Text("Name") },
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
"Sessions already running on it keep working -- they refer to the machine, " +
|
|
||||||
"not to what it is called.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(enabled = name.isNotBlank(), onClick = { onRename(name.trim()) }) {
|
|
||||||
Text("Rename")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Splits `user@host:port` into its two halves, with the port left null when none was typed.
|
|
||||||
*
|
|
||||||
* One field rather than two because that is how an address is written and read everywhere else, and
|
|
||||||
* because a port that is almost always 22 does not deserve a box of its own on a phone keyboard.
|
|
||||||
* Null rather than 22: the backend already decides the default.
|
|
||||||
*
|
|
||||||
* A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it,
|
|
||||||
* `[::1]:22`; a bare `::1` keeps every colon. So the rule is: brackets, or exactly one colon
|
|
||||||
* followed by digits.
|
|
||||||
*/
|
|
||||||
private fun splitHostAndPort(typed: String): Pair<String, Int?> {
|
|
||||||
if (typed.startsWith("[")) {
|
|
||||||
val close = typed.indexOf(']')
|
|
||||||
if (close > 0) {
|
|
||||||
val host = typed.substring(1, close)
|
|
||||||
val rest = typed.substring(close + 1)
|
|
||||||
val port = rest.removePrefix(":").toIntOrNull().takeIf { rest.startsWith(":") }
|
|
||||||
return host to port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typed.count { it == ':' } == 1) {
|
|
||||||
val host = typed.substringBeforeLast(':')
|
|
||||||
val port = typed.substringAfterLast(':').toIntOrNull()
|
|
||||||
if (port != null && host.isNotEmpty()) return host to port
|
|
||||||
}
|
|
||||||
return typed to null
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
|
||||||
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
|
||||||
import androidx.compose.ui.graphics.luminance
|
|
||||||
import androidx.compose.ui.layout.layout
|
|
||||||
import androidx.core.view.WindowCompat
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
// Bumped whenever enrollment lands via an aiapp:// intent so the composition below re-reads the
|
|
||||||
// stored settings.
|
|
||||||
private var settingsVersion by mutableIntStateOf(0)
|
|
||||||
|
|
||||||
// The session a notification tap asked for, or null if nothing has. The serial is what makes a
|
|
||||||
// second tap on the same session's notification a second request: without it the two compare
|
|
||||||
// equal and the composition below has nothing to react to.
|
|
||||||
private var openRequest by mutableStateOf<SessionOpenRequest?>(null)
|
|
||||||
private var opens = 0
|
|
||||||
|
|
||||||
// What another app shared into this one, for the same reason and with the same serial.
|
|
||||||
private var shareRequest by mutableStateOf<ShareRequest?>(null)
|
|
||||||
private var shares = 0
|
|
||||||
|
|
||||||
// Registered up front since permission launchers must be registered before the activity reaches
|
|
||||||
// STARTED.
|
|
||||||
private val requestLocalNetworkPermission =
|
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The service starts either way, and posts nothing if this is refused.
|
|
||||||
*
|
|
||||||
* Deliberately not gated on the answer: the permission can be granted later from Android's own
|
|
||||||
* settings, and a service that only ever started at the moment it was granted would stay down
|
|
||||||
* until the app was launched again.
|
|
||||||
*/
|
|
||||||
private val requestNotificationPermission =
|
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
// Before anything else that could throw, so the first crash of a launch is caught too.
|
|
||||||
installCrashLog(this)
|
|
||||||
|
|
||||||
// Transparent status bar on every version; the Surface below paints through underneath it
|
|
||||||
// and content insets itself. Same reasoning as dev-updater's MainActivity.
|
|
||||||
enableEdgeToEdge()
|
|
||||||
// Dark status-bar icons only over a light background, decided from the scheme rather than
|
|
||||||
// fixed. It was hardcoded to `true`, which was right against the default light surface and
|
|
||||||
// became unreadable the moment the app wore Catppuccin Mocha.
|
|
||||||
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
|
|
||||||
AiAppColors.background.luminance() > 0.5f
|
|
||||||
|
|
||||||
// Android 17+ silently drops local-network traffic without this; requested up front because
|
|
||||||
// a denial is invisible at the socket layer (it just times out).
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
|
||||||
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
}
|
|
||||||
|
|
||||||
handleIntent(intent)
|
|
||||||
// After enrollment, so a first launch that arrives with a token starts the service with
|
|
||||||
// something to connect to rather than stopping it and waiting for the next launch.
|
|
||||||
NotificationService.sync(this)
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
// Selection colours with the theme rather than at each place text is drawn: the
|
|
||||||
// transcript is one selection container, and a selection that ran from a reply into the
|
|
||||||
// code block under it would otherwise change colour halfway.
|
|
||||||
MaterialTheme(colorScheme = AiAppColors) {
|
|
||||||
CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) {
|
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
// Timed like the transcript times itself, and for the same reason:
|
|
||||||
// the frame's draw phase is where Compose's measurement lands, and
|
|
||||||
// a report saying "draw is high" cannot otherwise say whether the
|
|
||||||
// cost is the transcript or the chrome around it. The keyboard is
|
|
||||||
// the case that made it matter.
|
|
||||||
Modifier.layout { measurable, constraints ->
|
|
||||||
val started = System.nanoTime()
|
|
||||||
val placeable = measurable.measure(constraints)
|
|
||||||
DebugStats.record(
|
|
||||||
"measure: the app root",
|
|
||||||
System.nanoTime() - started,
|
|
||||||
)
|
|
||||||
layout(placeable.width, placeable.height) {
|
|
||||||
val placing = System.nanoTime()
|
|
||||||
placeable.place(0, 0)
|
|
||||||
DebugStats.record(
|
|
||||||
"place: the app root",
|
|
||||||
System.nanoTime() - placing,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.drawWithContent {
|
|
||||||
val started = System.nanoTime()
|
|
||||||
drawContent()
|
|
||||||
DebugStats.record(
|
|
||||||
"record: the app root",
|
|
||||||
System.nanoTime() - started,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.fillMaxSize()
|
|
||||||
.statusBarsPadding()
|
|
||||||
// The gesture strip at the bottom of most phones. Without it
|
|
||||||
// the send row sits under the swipe area, where a tap is as
|
|
||||||
// likely to navigate away as to press a button.
|
|
||||||
//
|
|
||||||
// No imePadding here, deliberately: applied at the root it
|
|
||||||
// resizes this whole box on every frame of the keyboard
|
|
||||||
// animation, which re-measures, re-places and re-records every
|
|
||||||
// screen's entire tree per frame. Each screen takes the
|
|
||||||
// keyboard itself, so the per-frame cost is scoped to what
|
|
||||||
// actually moves.
|
|
||||||
.navigationBarsPadding()
|
|
||||||
) {
|
|
||||||
AppRoot(settingsVersion, openRequest, shareRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
|
|
||||||
// lands here rather than in a second activity instance.
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
|
||||||
super.onNewIntent(intent)
|
|
||||||
handleIntent(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one place an incoming intent is sorted into what it means.
|
|
||||||
*
|
|
||||||
* Three things arrive this way -- a share from another app, and an `aiapp://` URI that is
|
|
||||||
* either an enrollment code or a notification naming a session. The URIs are told apart by host
|
|
||||||
* rather than by two entry points, so a further kind is a branch here.
|
|
||||||
*/
|
|
||||||
private fun handleIntent(intent: Intent?) {
|
|
||||||
intent ?: return
|
|
||||||
sharedContent(intent, shares + 1)?.let { shared ->
|
|
||||||
shares = shared.serial
|
|
||||||
shareRequest = shared
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val uri = intent.data ?: return
|
|
||||||
val sessionId = notifiedSessionId(uri)
|
|
||||||
if (sessionId != null) {
|
|
||||||
opens++
|
|
||||||
openRequest = SessionOpenRequest(sessionId, opens)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val settings = parseEnrollmentUri(uri)
|
|
||||||
if (settings == null) {
|
|
||||||
Toast.makeText(this, "Not a valid enrollment code", Toast.LENGTH_LONG).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
saveServerSettings(this, settings)
|
|
||||||
settingsVersion++
|
|
||||||
// Enrolling is the moment there is a backend to watch, and re-enrolling elsewhere is the
|
|
||||||
// moment the old one stops being it.
|
|
||||||
NotificationService.sync(this)
|
|
||||||
Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.PrimaryTabRow
|
|
||||||
import androidx.compose.material3.Tab
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
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.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's root: one title, and three views of the backend behind it.
|
|
||||||
*
|
|
||||||
* These were screens reached by words in a row under the title, and the row was already full. Tabs
|
|
||||||
* say the same thing in less space and say one more thing besides: that these are places to be
|
|
||||||
* rather than errands to run. Sessions, the machine's importable history and the machines
|
|
||||||
* themselves are all *the same backend*, looked at three ways, and none is a step down from
|
|
||||||
* another. Settings still is, which is why it stays a pushed screen with its own Back.
|
|
||||||
*
|
|
||||||
* Models were a fourth tab until 2026-09-19. They are a machine's models now -- downloaded onto the
|
|
||||||
* machine that has to serve them -- so they live under that machine's llama.cpp provider, beside
|
|
||||||
* the settings deciding how each one is loaded. A tab about "the models" was a claim that there is
|
|
||||||
* one such set, and there is one per machine.
|
|
||||||
*/
|
|
||||||
private enum class MainTab(val label: String) {
|
|
||||||
Sessions("Sessions"),
|
|
||||||
Import("Import"),
|
|
||||||
Machines("Machines"),
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MainScreen(
|
|
||||||
settings: ServerSettings,
|
|
||||||
reloadToken: Int,
|
|
||||||
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
|
|
||||||
share: ShareRequest? = null,
|
|
||||||
onOpen: (SessionSummary) -> Unit,
|
|
||||||
onSpawn: () -> Unit,
|
|
||||||
onImported: (SessionSummary) -> Unit,
|
|
||||||
onSettings: () -> Unit,
|
|
||||||
/** One machine's provider, opened from the machines tab. */
|
|
||||||
onProvider: (String, String) -> Unit,
|
|
||||||
/** A session the list has just deleted; see [SessionListScreen]. */
|
|
||||||
onDeleted: (String) -> Unit = {},
|
|
||||||
) {
|
|
||||||
var tab by remember { mutableStateOf(MainTab.Sessions) }
|
|
||||||
var refreshToken by remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
// Coming back to the app asks again, on whichever tab is showing.
|
|
||||||
//
|
|
||||||
// What these four draw is a snapshot of a backend they are not connected to, so it is only as
|
|
||||||
// fresh as the last answer -- and a *failed* answer is the one that outstays its welcome. A
|
|
||||||
// phone that was away while the tunnel was down came back to "Couldn't reach the server"
|
|
||||||
// sitting at the top of a list the server would now answer for perfectly well. A stale failure
|
|
||||||
// is worse than a stale list: it is a claim about right now.
|
|
||||||
//
|
|
||||||
// Through the same token the Refresh button uses, so this is one instruction the tabs already
|
|
||||||
// understand. Not on the first entry: the tab composing already asks.
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
|
||||||
LaunchedEffect(lifecycleOwner) {
|
|
||||||
var opening = true
|
|
||||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
||||||
if (!opening) refreshToken++
|
|
||||||
opening = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A tab the app put over the list has to step back to it rather than fall through to the system
|
|
||||||
// default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled.
|
|
||||||
BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions }
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 16.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"AI Sessions",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
// Glyphs rather than the words they replaced: neither ever changes, both are read
|
|
||||||
// faster than they are spelled, and together they take the width that let the title
|
|
||||||
// keep its own line. They sit on the title's row because they act on the whole screen.
|
|
||||||
//
|
|
||||||
// Flush against each other: a glyph button carries its own padding, so two side by side
|
|
||||||
// already have two rings between their marks.
|
|
||||||
Row {
|
|
||||||
GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ })
|
|
||||||
GlyphButton(SETTINGS_GLYPH, "Settings", onSettings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// What is waiting to be attached, and what to do about it. Said here because the list below
|
|
||||||
// is where the choice is made, and a share that arrived with nothing on screen saying so
|
|
||||||
// would read as a tap that did nothing.
|
|
||||||
share?.let {
|
|
||||||
Text(
|
|
||||||
it.summary() + " -- open the session it belongs in.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
MaterialTheme.shapes.small,
|
|
||||||
)
|
|
||||||
.padding(12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Primary rather than the plain TabRow, which is deprecated in favour of the two that say
|
|
||||||
// where they sit: these are the app's top-level destinations.
|
|
||||||
PrimaryTabRow(selectedTabIndex = tab.ordinal) {
|
|
||||||
MainTab.entries.forEach { entry ->
|
|
||||||
Tab(
|
|
||||||
selected = tab == entry,
|
|
||||||
onClick = { tab = entry },
|
|
||||||
text = { Text(entry.label) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refreshing means "ask again about what I am looking at", so the button feeds the tab that
|
|
||||||
// is showing. The token from above means something else already changed what these show;
|
|
||||||
// the two are the same instruction, so they are summed rather than tracked apart.
|
|
||||||
val token = reloadToken + refreshToken
|
|
||||||
when (tab) {
|
|
||||||
MainTab.Sessions ->
|
|
||||||
SessionListScreen(
|
|
||||||
settings = settings,
|
|
||||||
reloadToken = token,
|
|
||||||
onOpen = onOpen,
|
|
||||||
onSpawn = onSpawn,
|
|
||||||
onDeleted = onDeleted,
|
|
||||||
)
|
|
||||||
MainTab.Import ->
|
|
||||||
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
|
|
||||||
MainTab.Machines ->
|
|
||||||
MachinesScreen(settings = settings, reloadToken = token, onProvider = onProvider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,667 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.Stable
|
|
||||||
import androidx.compose.runtime.key
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.layout.layout
|
|
||||||
import androidx.compose.ui.semantics.CollectionInfo
|
|
||||||
import androidx.compose.ui.semantics.CollectionItemInfo
|
|
||||||
import androidx.compose.ui.semantics.collectionInfo
|
|
||||||
import androidx.compose.ui.semantics.collectionItemInfo
|
|
||||||
import androidx.compose.ui.semantics.heading
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.TextLinkStyles
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.mikepenz.markdown.compose.LocalImageTransformer
|
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownAnimations
|
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownComponents
|
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownPadding
|
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownTypography
|
|
||||||
import com.mikepenz.markdown.compose.LocalReferenceLinkHandler
|
|
||||||
import com.mikepenz.markdown.compose.components.markdownComponents
|
|
||||||
import com.mikepenz.markdown.compose.elements.MarkdownDivider
|
|
||||||
import com.mikepenz.markdown.compose.elements.listDepth
|
|
||||||
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
|
|
||||||
import com.mikepenz.markdown.m3.markdownColor
|
|
||||||
import com.mikepenz.markdown.m3.markdownTypography
|
|
||||||
import com.mikepenz.markdown.model.NoOpImageTransformerImpl
|
|
||||||
import com.mikepenz.markdown.model.State
|
|
||||||
import com.mikepenz.markdown.model.markdownAnimations
|
|
||||||
import com.mikepenz.markdown.model.markdownDimens
|
|
||||||
import com.mikepenz.markdown.model.markdownPadding
|
|
||||||
import com.mikepenz.markdown.model.parseMarkdown
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.intellij.markdown.MarkdownTokenTypes
|
|
||||||
import org.intellij.markdown.ast.ASTNode
|
|
||||||
import org.intellij.markdown.ast.findChildOfType
|
|
||||||
import org.intellij.markdown.flavours.gfm.GFMElementTypes
|
|
||||||
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [text] drawn as its pieces, one under the other; see [Piece].
|
|
||||||
*
|
|
||||||
* [live] is the reply still arriving, and two things are different for it. Its parse is incremental
|
|
||||||
* -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole
|
|
||||||
* message. And its pieces get a layer each, so only the piece that changed is re-recorded. That is
|
|
||||||
* worth a great deal while every delta invalidates the message and worth nothing once it stops
|
|
||||||
* changing -- and it is not free: each layer is a layout node and a display list held for the life
|
|
||||||
* of the row, and live node count is what the transcript's per-frame cost scales with.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun MarkdownText(
|
|
||||||
text: String,
|
|
||||||
replies: ParsedReplies,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
live: Boolean = false,
|
|
||||||
) {
|
|
||||||
val segments =
|
|
||||||
if (live) liveSegments(text)
|
|
||||||
else remember(text) { listOf(Segment(text, 0, replies.of(text), replies.piecesOf(text))) }
|
|
||||||
Column(modifier.fillMaxWidth()) {
|
|
||||||
var previous: Piece? = null
|
|
||||||
var previousSegment: Segment? = null
|
|
||||||
segments.forEachIndexed { at, segment ->
|
|
||||||
val nextContinues = segments.getOrNull(at + 1)?.continues == true
|
|
||||||
// Only the tail is still being written; a frozen segment is finished text that happens
|
|
||||||
// to sit in a live reply, and it takes its colours now.
|
|
||||||
MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) {
|
|
||||||
segment.pieces.forEachIndexed { index, piece ->
|
|
||||||
val gap =
|
|
||||||
when {
|
|
||||||
previousSegment == null -> 0.dp
|
|
||||||
previousSegment !== segment ->
|
|
||||||
if (segment.continues) 0.dp else BLOCK_SPACING
|
|
||||||
else -> gapBefore(previous, piece)
|
|
||||||
}
|
|
||||||
// Keyed by where the piece starts in the message rather than by its position in
|
|
||||||
// this column, so a delta landing in the last block leaves every other piece's
|
|
||||||
// composition alone -- and a block keeps its key when it freezes.
|
|
||||||
key(segment.start, piece) {
|
|
||||||
MarkdownPiece(
|
|
||||||
segment.parse,
|
|
||||||
segment.text,
|
|
||||||
piece,
|
|
||||||
Modifier.padding(top = gap)
|
|
||||||
.then(if (live) Modifier.graphicsLayer() else Modifier)
|
|
||||||
.drawWithContent {
|
|
||||||
val started = System.nanoTime()
|
|
||||||
drawContent()
|
|
||||||
DebugStats.record(
|
|
||||||
"record: one block",
|
|
||||||
System.nanoTime() - started,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
continuesList = segment.continues && index == 0,
|
|
||||||
listContinues = nextContinues && index == segment.pieces.lastIndex,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
previous = piece
|
|
||||||
previousSegment = segment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A stretch of a message with a parse of its own: the whole of a settled message, or one block, the
|
|
||||||
* finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins
|
|
||||||
* in the message. [continues] says the first piece is an item of the list the segment before it
|
|
||||||
* ended with, so the two draw as one list.
|
|
||||||
*/
|
|
||||||
private class Segment(
|
|
||||||
val text: String,
|
|
||||||
val start: Int,
|
|
||||||
val parse: State,
|
|
||||||
val pieces: List<Piece>,
|
|
||||||
val continues: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The live reply's segments: parsed on the composing thread the first time the row is drawn, and
|
|
||||||
* incrementally off it for every delta afterwards.
|
|
||||||
*
|
|
||||||
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
|
|
||||||
* slot until its result arrives, so a row is measured at nothing before it is measured at its real
|
|
||||||
* height, and the transcript above it collapses and springs back -- seen with five replies on
|
|
||||||
* screen at once, the whole conversation shrunk to fit a single screen.
|
|
||||||
*
|
|
||||||
* Every parse after the first is off the composing thread, and the row keeps drawing the parse it
|
|
||||||
* already has until the new one lands, so there is never a frame without a height.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun liveSegments(text: String): List<Segment> {
|
|
||||||
val parsed = remember {
|
|
||||||
mutableStateOf(
|
|
||||||
DebugStats.timed("markdown parsed while composing") { LiveParse.whole(text) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LaunchedEffect(text) {
|
|
||||||
if (parsed.value.text == text) return@LaunchedEffect
|
|
||||||
val previous = parsed.value
|
|
||||||
parsed.value =
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
DebugStats.timed("markdown reparsed while streaming") { previous.advanceTo(text) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parsed.value.segments
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A reply still arriving, parsed a block at a time.
|
|
||||||
*
|
|
||||||
* Reparsing the whole message per delta was fine for a short reply and not for a long one: a
|
|
||||||
* twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran
|
|
||||||
* off the composing thread it was every core busy while the frame's own thread waited for one.
|
|
||||||
*
|
|
||||||
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
|
|
||||||
* finished -- nothing appended later can reach back into it. So every block but the last is
|
|
||||||
* [frozen] with the parse that finished it, and only the tail is parsed again.
|
|
||||||
*
|
|
||||||
* A list is cut once more, at its last item, by the same reasoning one level down. Without this a
|
|
||||||
* reply that is one long list -- forty sources -- parsed the whole list per delta. The item the cut
|
|
||||||
* lands on has to have begun in earnest: a bare `-` is an empty item now and the first character of
|
|
||||||
* a paragraph line once `-x` arrives.
|
|
||||||
*
|
|
||||||
* What the cut gives up is one thing: a reference definition arriving later than a link that uses
|
|
||||||
* it. The link draws as its brackets until the reply settles and is parsed whole by [warm].
|
|
||||||
*/
|
|
||||||
private class LiveParse(
|
|
||||||
val text: String,
|
|
||||||
private val frozen: List<Segment>,
|
|
||||||
/** How much of [text] the frozen segments cover; the tail starts here. */
|
|
||||||
private val consumed: Int,
|
|
||||||
private val tail: Segment,
|
|
||||||
) {
|
|
||||||
val segments: List<Segment>
|
|
||||||
get() = frozen + tail
|
|
||||||
|
|
||||||
fun advanceTo(next: String): LiveParse {
|
|
||||||
// Anything but an append to what was frozen -- a message replaced, a stream reset -- starts
|
|
||||||
// over.
|
|
||||||
if (!next.regionMatches(0, text, 0, consumed)) return whole(next)
|
|
||||||
val tailText = next.substring(consumed)
|
|
||||||
val parse = parseMarkdown(tailText)
|
|
||||||
val all = pieces(parse)
|
|
||||||
val open = (parse as? State.Success)?.let { openPiece(it, all) }
|
|
||||||
if (open == null) {
|
|
||||||
return LiveParse(
|
|
||||||
next,
|
|
||||||
frozen,
|
|
||||||
consumed,
|
|
||||||
Segment(tailText, consumed, parse, all, tail.continues),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val done =
|
|
||||||
all.subList(0, all.indexOf(open))
|
|
||||||
.groupBy { it.block }
|
|
||||||
.values
|
|
||||||
.mapIndexed { at, pieces ->
|
|
||||||
Segment(
|
|
||||||
tailText,
|
|
||||||
consumed,
|
|
||||||
parse,
|
|
||||||
pieces,
|
|
||||||
continues = at == 0 && tail.continues,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Cut at the start of the open piece's line rather than at the piece, so an indented item
|
|
||||||
// or block keeps the indentation the parse of the rest reads its nesting from.
|
|
||||||
val node =
|
|
||||||
parse.node.children[open.block].let {
|
|
||||||
if (open.item == Piece.WHOLE_BLOCK) it else it.listItems()[open.item]
|
|
||||||
}
|
|
||||||
val cut = tailText.lastIndexOf('\n', node.startOffset) + 1
|
|
||||||
val rest = tailText.substring(cut)
|
|
||||||
val restParse = parseMarkdown(rest)
|
|
||||||
return LiveParse(
|
|
||||||
next,
|
|
||||||
frozen + done,
|
|
||||||
consumed + cut,
|
|
||||||
Segment(rest, consumed + cut, restParse, pieces(restParse), continues = open.item > 0),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The piece of the tail still being written: the last item of a list of several, or the first
|
|
||||||
* piece of the last block when there is more than one. Null when nothing before it is finished.
|
|
||||||
*/
|
|
||||||
private fun openPiece(parse: State.Success, all: List<Piece>): Piece? {
|
|
||||||
val last = all.lastOrNull() ?: return null
|
|
||||||
val lastBlockStart = all.indexOfFirst { it.block == last.block }
|
|
||||||
return when {
|
|
||||||
last.item > 0 && parse.node.children[last.block].listItems()[last.item].hasBegun -> last
|
|
||||||
lastBlockStart > 0 -> all[lastBlockStart]
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a list item holds anything beyond its marker yet. */
|
|
||||||
private val ASTNode.hasBegun: Boolean
|
|
||||||
get() = children.any { it.type !in MARKER_TOKENS }
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun whole(text: String): LiveParse {
|
|
||||||
val parse = parseMarkdown(text)
|
|
||||||
return LiveParse(text, emptyList(), 0, Segment(text, 0, parse, pieces(parse)))
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MARKER_TOKENS =
|
|
||||||
setOf(
|
|
||||||
MarkdownTokenTypes.LIST_BULLET,
|
|
||||||
MarkdownTokenTypes.LIST_NUMBER,
|
|
||||||
MarkdownTokenTypes.WHITE_SPACE,
|
|
||||||
MarkdownTokenTypes.EOL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One [piece] of [text], drawn on its own -- a unit of the transcript list. */
|
|
||||||
@Composable
|
|
||||||
fun MarkdownPiece(
|
|
||||||
text: String,
|
|
||||||
piece: Piece,
|
|
||||||
replies: ParsedReplies,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
// Remembered so a message the flatten drew before [warm] reached it is parsed once here, not
|
|
||||||
// once per composition.
|
|
||||||
val parse = remember(text) { replies.of(text) }
|
|
||||||
MarkdownRoot(parse, replies) { MarkdownPiece(parse, text, piece, modifier) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The renderer's own environment -- its colours, type scale, dimensions, component table and
|
|
||||||
* reference links -- around whatever draws pieces of [parse].
|
|
||||||
*
|
|
||||||
* The parsing is the library's: markdown is somebody else's specification, and a hand-written
|
|
||||||
* parser would get the edge cases wrong one case at a time. So is the environment: the element
|
|
||||||
* composables its dispatch reaches read these locals, and providing them once here is what lets a
|
|
||||||
* piece be drawn anywhere -- in a message's column, or as one item of the transcript list.
|
|
||||||
*
|
|
||||||
* The locals are provided directly rather than through the renderer's `Markdown()` composable,
|
|
||||||
* which was the last of its composables on the hot path and was here only to provide them. So
|
|
||||||
* nothing between a piece and the screen is the library's but the leaf composables named in the
|
|
||||||
* component table.
|
|
||||||
*
|
|
||||||
* Colours come from the theme rather than the renderer's defaults. Nothing here picks one of its
|
|
||||||
* own.
|
|
||||||
*
|
|
||||||
* [streaming] says this parse is the part of a reply still being written, which only the fences
|
|
||||||
* care about: lexing is proportional to how much code there is. Measured streaming a two-hundred-
|
|
||||||
* line Kotlin fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms --
|
|
||||||
* for colours on text being replaced as fast as they were computed. So a fence still being written
|
|
||||||
* is drawn plain and takes its colours when the block freezes.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun MarkdownRoot(
|
|
||||||
parse: State,
|
|
||||||
replies: ParsedReplies,
|
|
||||||
streaming: Boolean = false,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
if (parse !is State.Success) {
|
|
||||||
// Nothing below needs the environment; [MarkdownPiece] draws the words plainly.
|
|
||||||
content()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val body = MaterialTheme.typography.bodyLarge
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalReferenceLinkHandler provides parse.referenceLinkHandler,
|
|
||||||
LocalMarkdownPadding provides markdownPadding(),
|
|
||||||
// Read by the renderer's own text composable, which no paragraph reaches any more, and by
|
|
||||||
// its checkbox. Provided so a path that does reach them draws no image rather than failing
|
|
||||||
// to compose.
|
|
||||||
LocalImageTransformer provides remember { NoOpImageTransformerImpl() },
|
|
||||||
LocalMarkdownAnimations provides markdownAnimations(),
|
|
||||||
LocalMarkdownColors provides
|
|
||||||
markdownColor(
|
|
||||||
text = MaterialTheme.colorScheme.onSurface,
|
|
||||||
dividerColor = MaterialTheme.colorScheme.outlineVariant,
|
|
||||||
// The dark surface every verbatim thing in this app sits on -- and the tool call
|
|
||||||
// above this reply, which now matches. `surfaceVariant` was exactly a card's own
|
|
||||||
// fill, so a fenced block inside a tool call had no background at all.
|
|
||||||
codeBackground = rawSurface,
|
|
||||||
// The same colour. Not drawn by the renderer as a span background but by
|
|
||||||
// [LinkedText] behind the text, so a selection lands on top of it -- see
|
|
||||||
// `appendCodeChip`.
|
|
||||||
inlineCodeBackground = rawSurface,
|
|
||||||
// The same tint a code block gets, rather than the renderer's 2%-alpha default: two
|
|
||||||
// adjacent tints that differ by a fiftieth read as one flat block on a phone.
|
|
||||||
tableBackground = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
LocalMarkdownTypography provides
|
|
||||||
markdownTypography(
|
|
||||||
// A ladder that starts near the body text and descends, because these are headings
|
|
||||||
// inside a chat message rather than the top of a document. The renderer's defaults
|
|
||||||
// are the Material *display* styles -- `#` came out at 57sp, bigger than this app's
|
|
||||||
// own screen titles. Every step is a different size, so two levels of nesting never
|
|
||||||
// draw the same.
|
|
||||||
h1 = MaterialTheme.typography.headlineSmall,
|
|
||||||
h2 = MaterialTheme.typography.titleLarge,
|
|
||||||
h3 = MaterialTheme.typography.titleMedium,
|
|
||||||
h4 = MaterialTheme.typography.titleSmall,
|
|
||||||
h5 = MaterialTheme.typography.labelMedium,
|
|
||||||
h6 = MaterialTheme.typography.labelSmall,
|
|
||||||
text = body,
|
|
||||||
paragraph = body,
|
|
||||||
ordered = body,
|
|
||||||
bullet = body,
|
|
||||||
list = body,
|
|
||||||
table = body,
|
|
||||||
// Code in a monospace face, in the ordinary text colour. The face and the tinted
|
|
||||||
// background are what say "this is code"; colour is not, and it used to be green --
|
|
||||||
// the palette's colour for a *literal*. A block of code is not a literal, and
|
|
||||||
// painting all of it green said the whole block was one. Where a literal really
|
|
||||||
// does appear inside code, what should colour it is a syntax highlighter.
|
|
||||||
code =
|
|
||||||
MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
inlineCode =
|
|
||||||
body.copy(
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
// Unspecified so an inline span keeps the size of the line it sits in.
|
|
||||||
fontSize = TextUnit.Unspecified,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
textLink =
|
|
||||||
TextLinkStyles(
|
|
||||||
style =
|
|
||||||
body
|
|
||||||
.copy(
|
|
||||||
color = linkColor,
|
|
||||||
textDecoration = TextDecoration.Underline,
|
|
||||||
)
|
|
||||||
.toSpanStyle()
|
|
||||||
),
|
|
||||||
),
|
|
||||||
LocalMarkdownDimens provides
|
|
||||||
markdownDimens(
|
|
||||||
// Half the renderer's 16dp. Padding is charged on both sides of every cell, so at
|
|
||||||
// the default a fifth of the narrowest column went on space rather than on words.
|
|
||||||
tableCellPadding = 8.dp,
|
|
||||||
// What a column narrows to before the table starts scrolling sideways instead. It
|
|
||||||
// is the floor, not the width: a table with room to spare spreads across it.
|
|
||||||
//
|
|
||||||
// Down from the renderer's 160dp, and the number is a measurement rather than a
|
|
||||||
// taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp
|
|
||||||
// makes even a three-column table scroll, while 136dp fits three across the phone
|
|
||||||
// this app is read on. Four and up still scroll, which is the right answer for
|
|
||||||
// genuinely too many columns. This is the widest minimum that keeps three on
|
|
||||||
// screen.
|
|
||||||
tableCellWidth = 136.dp,
|
|
||||||
),
|
|
||||||
LocalMarkdownComponents provides
|
|
||||||
markdownComponents(
|
|
||||||
// The m3 renderer's own default, restored: supplying `components` at all replaces
|
|
||||||
// the whole set, and this is the only member the Material layer overrides.
|
|
||||||
checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) },
|
|
||||||
// Everything that draws a run of text, so a link is a span rather than a node --
|
|
||||||
// see [LinkedText]. Setext headings take the same styles as `#` and `##`.
|
|
||||||
text = { LinkedText(it, it.typography.text) },
|
|
||||||
paragraph = { LinkedText(it, it.typography.paragraph) },
|
|
||||||
heading1 = { LinkedHeading(it, it.typography.h1) },
|
|
||||||
heading2 = { LinkedHeading(it, it.typography.h2) },
|
|
||||||
heading3 = { LinkedHeading(it, it.typography.h3) },
|
|
||||||
heading4 = { LinkedHeading(it, it.typography.h4) },
|
|
||||||
heading5 = { LinkedHeading(it, it.typography.h5) },
|
|
||||||
heading6 = { LinkedHeading(it, it.typography.h6) },
|
|
||||||
setextHeading1 = { LinkedHeading(it, it.typography.h1) },
|
|
||||||
setextHeading2 = { LinkedHeading(it, it.typography.h2) },
|
|
||||||
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- so
|
|
||||||
// they draw like the top-level ones the transcript cuts into items.
|
|
||||||
orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
|
|
||||||
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
|
|
||||||
table = { LinkedTable(it.content, it.node, it.typography.table) },
|
|
||||||
// Code is highlighted the way a tool call's input is; see [CodeFence].
|
|
||||||
codeFence = {
|
|
||||||
CodeFence(it.content, it.node, it.typography.code, replies, streaming)
|
|
||||||
},
|
|
||||||
codeBlock = {
|
|
||||||
CodeBlock(it.content, it.node, it.typography.code, replies, streaming)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need.
|
|
||||||
*
|
|
||||||
* Each column has a floor, so the table is at least columns-times-floor wide; narrower than the
|
|
||||||
* room it has, it spreads to fill it, and wider, it scrolls sideways rather than squeezing. The
|
|
||||||
* renderer decided that with a `BoxWithConstraints`, which is a subcomposition; here it is one
|
|
||||||
* layout modifier. `fillMaxWidth` fixes the minimum width to the room available, the horizontal
|
|
||||||
* scroll passes that minimum through while lifting the maximum to unbounded, and the modifier after
|
|
||||||
* it reads the minimum back and sizes the rows to the larger of that and the floor.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
|
|
||||||
val dimens = LocalMarkdownDimens.current
|
|
||||||
val colors = LocalMarkdownColors.current
|
|
||||||
val columns =
|
|
||||||
remember(node) {
|
|
||||||
node.findChildOfType(GFMElementTypes.HEADER)?.children?.count {
|
|
||||||
it.type == GFMTokenTypes.CELL
|
|
||||||
} ?: 0
|
|
||||||
}
|
|
||||||
val rows = remember(node) { node.children.count { it.type == GFMElementTypes.ROW } + 1 }
|
|
||||||
val floor = dimens.tableCellWidth * columns
|
|
||||||
Column(
|
|
||||||
Modifier.background(colors.tableBackground, RoundedCornerShape(dimens.tableCornerSize))
|
|
||||||
.semantics { collectionInfo = CollectionInfo(rowCount = rows, columnCount = columns) }
|
|
||||||
.fillMaxWidth()
|
|
||||||
.horizontalScroll(rememberScrollState())
|
|
||||||
.layout { measurable, constraints ->
|
|
||||||
val width = maxOf(constraints.minWidth, floor.roundToPx())
|
|
||||||
val placeable =
|
|
||||||
measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
|
|
||||||
layout(width, placeable.height) { placeable.place(0, 0) }
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
var rowIndex = 1
|
|
||||||
node.children.forEach { child ->
|
|
||||||
when (child.type) {
|
|
||||||
GFMElementTypes.HEADER -> LinkedTableRow(content, child, style, rowIndex = 0)
|
|
||||||
GFMElementTypes.ROW -> LinkedTableRow(content, child, style, rowIndex = rowIndex++)
|
|
||||||
GFMTokenTypes.TABLE_SEPARATOR -> MarkdownDivider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText].
|
|
||||||
*
|
|
||||||
* The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means
|
|
||||||
* most of a table is simply not readable: an elided cell looks like a short one, so a table of
|
|
||||||
* measurements reads as a table of plausible shorter measurements. And they draw a link in a cell
|
|
||||||
* as its own layout node, the cost [LinkedText] exists to avoid.
|
|
||||||
*
|
|
||||||
* So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell
|
|
||||||
* beside a one-line one centred the short one against the middle of the tall one. What the wrapping
|
|
||||||
* does *not* do is make a wide table fit; [LinkedTable] scrolls it instead.
|
|
||||||
*
|
|
||||||
* The semantics are the renderer's: each cell is an item of the table's collection.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) {
|
|
||||||
val padding = LocalMarkdownDimens.current.tableCellPadding
|
|
||||||
val header = rowIndex == 0
|
|
||||||
val cellStyle = if (header) style.copy(fontWeight = FontWeight.Bold) else style
|
|
||||||
Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
row.children
|
|
||||||
.filter { it.type == GFMTokenTypes.CELL }
|
|
||||||
.forEachIndexed { column, cell ->
|
|
||||||
LinkedText(
|
|
||||||
content,
|
|
||||||
cell,
|
|
||||||
cellStyle,
|
|
||||||
Modifier.padding(padding).weight(1f).semantics {
|
|
||||||
if (header) heading()
|
|
||||||
collectionItemInfo =
|
|
||||||
CollectionItemInfo(
|
|
||||||
rowIndex = rowIndex,
|
|
||||||
rowSpan = 1,
|
|
||||||
columnIndex = column,
|
|
||||||
columnSpan = 1,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replies parsed before the row that draws them is composed.
|
|
||||||
*
|
|
||||||
* Parsing is the expensive half of drawing a reply, and it is expensive in proportion to how much
|
|
||||||
* was written. Measured against a real Claude Code transcript on the emulator, one message took
|
|
||||||
* **51ms** and several took 10-25ms, against 4.6ms for the short synthetic replies this was first
|
|
||||||
* tuned on -- so a page of history landing composed several rows that each stalled the frame.
|
|
||||||
*
|
|
||||||
* Nothing here changes what a row does when it has no answer waiting: it parses inline, because a
|
|
||||||
* row measured at nothing before its real height collapses the transcript above it. The point is
|
|
||||||
* only that by the time the reader scrolls to a row, the answer is usually already made.
|
|
||||||
*
|
|
||||||
* A miss is not stored, and that is what bounds this: the map holds one entry per message a page
|
|
||||||
* warmed, so a reply still streaming cannot fill it with hundreds of copies of itself.
|
|
||||||
*/
|
|
||||||
@Stable
|
|
||||||
class ParsedReplies {
|
|
||||||
private val parsed = ConcurrentHashMap<String, State>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per
|
|
||||||
* fold, and walking the tree again each time is proportional to the message.
|
|
||||||
*/
|
|
||||||
private val pieces = ConcurrentHashMap<String, List<Piece>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How each message divides into prose and memory notes, cached for the same reason: the regex
|
|
||||||
* scan behind [messageParts] is proportional to the message.
|
|
||||||
*/
|
|
||||||
private val parts = ConcurrentHashMap<String, List<MessagePart>>()
|
|
||||||
|
|
||||||
private val chunks = ConcurrentHashMap<String, List<String>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Each fence's coloured text, keyed by its language and code.
|
|
||||||
*
|
|
||||||
* Beside the parses for the same reason and at the same cost: lexing is proportional to how
|
|
||||||
* much code was written -- a two-hundred-line Kotlin fence measured 174ms on the emulator --
|
|
||||||
* and a lazy list drops the composition of a block that scrolls away, so a `remember` inside
|
|
||||||
* the fence paid that again every time the reader came back to it. Six times in one scroll,
|
|
||||||
* measured.
|
|
||||||
*/
|
|
||||||
private val highlights = ConcurrentHashMap<String, AnnotatedString>()
|
|
||||||
|
|
||||||
private val ready = ConcurrentHashMap.newKeySet<String>()
|
|
||||||
|
|
||||||
/** The pieces of [text], from its parse -- made now if [warm] has not made it. */
|
|
||||||
fun piecesOf(text: String): List<Piece> =
|
|
||||||
pieces.computeIfAbsent(text) {
|
|
||||||
DebugStats.timed("markdown cut into pieces") { pieces(of(it)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How a long user message divides into slices; cached for the same reason as [piecesOf]. */
|
|
||||||
fun chunksOf(text: String): List<String> =
|
|
||||||
chunks.computeIfAbsent(text) {
|
|
||||||
DebugStats.timed("user message cut into slices") { userChunks(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether [warm] has made everything drawing [text] as pieces will look up.
|
|
||||||
*
|
|
||||||
* What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole
|
|
||||||
* message and the flatten runs on the composing thread, so a reply not marked yet stays whole
|
|
||||||
* until the screen has warmed it. An explicit mark rather than a peek into the parse cache,
|
|
||||||
* because a message with memory notes is warmed as its *parts*: nothing ever parses its full
|
|
||||||
* text, and inferring readiness from the cache left exactly that message unsplittable forever.
|
|
||||||
*/
|
|
||||||
fun splitReady(text: String): Boolean = text in ready
|
|
||||||
|
|
||||||
/** The other half of [splitReady]; [warm] calls it once a message's parses exist. */
|
|
||||||
fun markSplitReady(text: String) {
|
|
||||||
ready.add(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun partsOf(text: String): List<MessagePart> =
|
|
||||||
parts.computeIfAbsent(text) {
|
|
||||||
DebugStats.timed("message cut into parts") { messageParts(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [code] coloured for [language] -- the answer made ahead, or one made now. The key carries the
|
|
||||||
* language, because the same code lexes differently under two of them.
|
|
||||||
*/
|
|
||||||
fun highlighted(code: String, language: Language?): AnnotatedString =
|
|
||||||
if (language == null) AnnotatedString(code)
|
|
||||||
else highlights.computeIfAbsent("$language\n$code") { highlight(code, language) }
|
|
||||||
|
|
||||||
/** The parse of [text] -- the one made ahead, or one made now. */
|
|
||||||
fun of(text: String): State =
|
|
||||||
parsed[text]?.also { DebugStats.count("markdown ready") }
|
|
||||||
?: DebugStats.timed("markdown parsed while composing") { parseMarkdown(text) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses whatever is not held yet. Call off the composing thread; that is the whole point.
|
|
||||||
*
|
|
||||||
* Suspending, and yielding between messages, because "off the composing thread" is not the same
|
|
||||||
* as "free". A page of history arrives as hundreds of parses at once -- 1.5 seconds of them in
|
|
||||||
* a twelve second scroll on a Pixel 9 Pro XL -- and on the default dispatcher that is every
|
|
||||||
* core busy, with the frame's own thread waiting for one: 21ms of `waited` at the 90th
|
|
||||||
* percentile.
|
|
||||||
*/
|
|
||||||
suspend fun warm(texts: List<String>) {
|
|
||||||
texts.forEach { text ->
|
|
||||||
val parse =
|
|
||||||
parsed.computeIfAbsent(text) {
|
|
||||||
DebugStats.timed("markdown warmed") { parseMarkdown(it) }
|
|
||||||
}
|
|
||||||
// The fences too, and here rather than in a pass of its own: they are found in the
|
|
||||||
// parse this just made, and lexing one is the same kind of cost as parsing the message
|
|
||||||
// it is in.
|
|
||||||
fences(parse).forEach { (code, language) -> highlighted(code, language) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Everything these described is gone; see [ParsedReplies]. */
|
|
||||||
fun clear() {
|
|
||||||
parsed.clear()
|
|
||||||
pieces.clear()
|
|
||||||
parts.clear()
|
|
||||||
chunks.clear()
|
|
||||||
highlights.clear()
|
|
||||||
ready.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
||||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
|
||||||
import androidx.compose.foundation.text.BasicText
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.drawBehind
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Rect
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.node.Ref
|
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
|
||||||
import androidx.compose.ui.semantics.heading
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import com.mikepenz.markdown.annotator.AnnotatorSettings
|
|
||||||
import com.mikepenz.markdown.annotator.annotatorSettings
|
|
||||||
import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString
|
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
|
||||||
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
|
|
||||||
import com.mikepenz.markdown.model.markdownAnnotator
|
|
||||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
|
||||||
import com.mikepenz.markdown.utils.resolveImageAlt
|
|
||||||
import com.mikepenz.markdown.utils.resolveImageLink
|
|
||||||
import java.net.URI
|
|
||||||
import org.intellij.markdown.MarkdownElementTypes
|
|
||||||
import org.intellij.markdown.MarkdownTokenTypes
|
|
||||||
import org.intellij.markdown.ast.ASTNode
|
|
||||||
import org.intellij.markdown.ast.findChildOfType
|
|
||||||
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A paragraph, heading or bare text whose links are spans of the text rather than nodes of their
|
|
||||||
* own.
|
|
||||||
*
|
|
||||||
* Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable,
|
|
||||||
* hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text
|
|
||||||
* layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one annotation
|
|
||||||
* per link. Measured on the emulator against the same paragraphs with each link replaced by its
|
|
||||||
* label and address as plain words -- *more* text, the same gestures -- the linked version cost
|
|
||||||
* five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time.
|
|
||||||
*
|
|
||||||
* Here a link is the link colour and underline, a string annotation carrying its address, and one
|
|
||||||
* tap detector for the whole text that asks the layout which character was under the finger. What
|
|
||||||
* that gives up is a link being its own accessibility node with a pressed state; the app's link
|
|
||||||
* style never defined a pressed style, so nothing visible changes.
|
|
||||||
*
|
|
||||||
* Every block the renderer dispatches through its component table comes here, and so does every
|
|
||||||
* table cell. Reference-style links are the one kind still drawn the renderer's way.
|
|
||||||
*
|
|
||||||
* An image is a link too, carrying its alt text. The app has no image loader and the renderer's
|
|
||||||
* transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the
|
|
||||||
* model put something. The link says what was there and where, and opens it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
|
|
||||||
LinkedText(model.content, model.node, style)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or
|
|
||||||
* `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it
|
|
||||||
* does not know, so handed the heading node itself it draws an empty line.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) {
|
|
||||||
val words =
|
|
||||||
model.node.findChildOfType(MarkdownTokenTypes.ATX_CONTENT)
|
|
||||||
?: model.node.findChildOfType(MarkdownTokenTypes.SETEXT_CONTENT)
|
|
||||||
?: model.node
|
|
||||||
LinkedText(model.content, words, style, Modifier.semantics { heading() })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The inline content of [node] within [content], drawn as [LinkedText] describes. */
|
|
||||||
@Composable
|
|
||||||
fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modifier = Modifier) {
|
|
||||||
val settings = plainLinkSettings()
|
|
||||||
val text =
|
|
||||||
remember(content, node, style) {
|
|
||||||
content.buildMarkdownAnnotatedString(node, style, settings)
|
|
||||||
}
|
|
||||||
val uriHandler = LocalUriHandler.current
|
|
||||||
val fileLinkHandler = LocalFileLinkHandler.current
|
|
||||||
val onPlainTap = LocalMarkdownTap.current
|
|
||||||
val layout = remember { Ref<TextLayoutResult>() }
|
|
||||||
// The renderer's own rule for a style that names no colour: the theme's text colour.
|
|
||||||
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
|
|
||||||
val chips = remember(text) { text.getStringAnnotations(CODE_CHIP, 0, text.length) }
|
|
||||||
val chipColor = LocalMarkdownColors.current.inlineCodeBackground
|
|
||||||
// Filled in by `onTextLayout`, which runs in the layout phase, so the draw of the same frame
|
|
||||||
// finds it set -- no state needed, and a relayout redraws the node anyway.
|
|
||||||
val chipFills = remember { Ref<List<Rect>>() }
|
|
||||||
val chipFill =
|
|
||||||
if (chips.isEmpty()) Modifier
|
|
||||||
else
|
|
||||||
Modifier.drawBehind {
|
|
||||||
chipFills.value?.forEach { drawRect(chipColor, it.topLeft, it.size) }
|
|
||||||
}
|
|
||||||
BasicText(
|
|
||||||
text = text,
|
|
||||||
modifier =
|
|
||||||
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the second
|
|
||||||
// one has to be answered from inside the text rather than left to the card.
|
|
||||||
modifier.then(chipFill).pointerInput(text, onPlainTap) {
|
|
||||||
awaitEachGesture {
|
|
||||||
// Unconsumed is not required: something outside may already be tracking this
|
|
||||||
// press, and it is still the press that may land on a link.
|
|
||||||
awaitFirstDown(requireUnconsumed = false)
|
|
||||||
// A tap and nothing else. Null when the gesture became somebody else's -- a
|
|
||||||
// scroll, or a press held past the long-press timeout, which is how a selection
|
|
||||||
// starts. The timeout is the load-bearing half: without it a press held for a
|
|
||||||
// second and released was still an up with nothing consumed, so holding a peer
|
|
||||||
// message to select from it shut the card instead.
|
|
||||||
val up =
|
|
||||||
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
|
|
||||||
waitForUpOrCancellation()
|
|
||||||
} ?: return@awaitEachGesture
|
|
||||||
val url = text.linkAt(layout.value, up.position)
|
|
||||||
when {
|
|
||||||
url != null -> {
|
|
||||||
up.consume()
|
|
||||||
if (fileLinkHandler?.invoke(url) != true) uriHandler.openUri(url)
|
|
||||||
}
|
|
||||||
onPlainTap != null -> {
|
|
||||||
up.consume()
|
|
||||||
onPlainTap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style = style,
|
|
||||||
color = { color },
|
|
||||||
onTextLayout = {
|
|
||||||
layout.value = it
|
|
||||||
chipFills.value = chips.flatMap { chip -> it.chipRects(chip.start, chip.end) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a tap on markdown text means when it lands on no link -- shutting the card it is drawn in,
|
|
||||||
* usually -- or null where a plain tap means nothing.
|
|
||||||
*
|
|
||||||
* A composition local because there is nowhere else to put it. The paragraphs of a message are
|
|
||||||
* composed by the renderer's own dispatch, so nothing between a card and the text inside it is ours
|
|
||||||
* to pass a parameter through.
|
|
||||||
*
|
|
||||||
* It exists because a pointer-input node over the glyphs takes the tap and the card's own click
|
|
||||||
* handler never sees it. Measured against an opened peer message: with a handler on the text --
|
|
||||||
* consuming or not -- a tap on its words did nothing at all, and with the handler removed the same
|
|
||||||
* tap shut the card. So a card whose body is markdown cannot be shut by pressing its words unless
|
|
||||||
* the words do the shutting.
|
|
||||||
*
|
|
||||||
* Provided as a value that outlives a recomposition, since a fresh lambda per composition would
|
|
||||||
* invalidate every paragraph reading it.
|
|
||||||
*/
|
|
||||||
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens a markdown destination inside the current session when it names a file on that session's
|
|
||||||
* machine. Null outside a session, where every link keeps its ordinary URI behaviour.
|
|
||||||
*/
|
|
||||||
val LocalFileLinkHandler = compositionLocalOf<((String) -> Boolean)?> { null }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A stable markdown link handler whose behaviour follows the latest [onFile]. Keeping its identity
|
|
||||||
* stable matters: every visible markdown paragraph reads it, and a session recomposes on every
|
|
||||||
* streamed event.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberFileLinkHandler(onFile: (String) -> Unit): (String) -> Boolean {
|
|
||||||
val latest = rememberUpdatedState(onFile)
|
|
||||||
return remember {
|
|
||||||
{ destination ->
|
|
||||||
val path = filePathOf(destination)
|
|
||||||
if (path == null) false
|
|
||||||
else {
|
|
||||||
latest.value(path)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The path named by a local-file markdown destination.
|
|
||||||
*
|
|
||||||
* Only absolute paths and local `file:` URIs are claimed. A relative destination might be a web
|
|
||||||
* link, and sending one to a machine's filesystem would silently give an ordinary link a different
|
|
||||||
* meaning. Editors commonly append a line and optional column; the current viewer opens the file
|
|
||||||
* itself, so those coordinates are removed here.
|
|
||||||
*/
|
|
||||||
internal fun filePathOf(destination: String): String? {
|
|
||||||
val uri = runCatching { URI(destination) }.getOrNull()
|
|
||||||
val path =
|
|
||||||
when {
|
|
||||||
destination.startsWith("/") && !destination.startsWith("//") ->
|
|
||||||
uri?.path ?: destination.substringBefore('#').substringBefore('?')
|
|
||||||
uri != null &&
|
|
||||||
uri.scheme.equals("file", ignoreCase = true) &&
|
|
||||||
(uri.host.isNullOrEmpty() || uri.host == "localhost") -> uri.path
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (path.isNullOrEmpty() || !path.startsWith('/')) return null
|
|
||||||
return path.replace(Regex(":\\d+(?::\\d+)?$"), "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the
|
|
||||||
* behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
|
|
||||||
* under it on every recomposition of the card.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit {
|
|
||||||
val latest = rememberUpdatedState(onTap)
|
|
||||||
return remember { { latest.value() } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The address under [position], if a link's glyph is there rather than merely nearest to it.
|
|
||||||
*
|
|
||||||
* The layout answers with a caret, the boundary nearest the finger, so a tap on the right half of a
|
|
||||||
* glyph names the character after it; the glyph under the finger is the one on either side of that
|
|
||||||
* boundary whose box holds the point. Checked with the box rather than assumed, so a tap past the
|
|
||||||
* end of a line ending in a link opens nothing.
|
|
||||||
*/
|
|
||||||
private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): String? {
|
|
||||||
layout ?: return null
|
|
||||||
val caret = layout.getOffsetForPosition(position)
|
|
||||||
val glyph =
|
|
||||||
(caret - 1..caret).firstOrNull {
|
|
||||||
it in 0 until length && layout.getBoundingBox(it).contains(position)
|
|
||||||
} ?: return null
|
|
||||||
return getStringAnnotations(LINK_URL, glyph, glyph + 1).firstOrNull()?.item
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val LINK_URL = "url"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends [node] as inline code -- the renderer's own span, padded by a space each side as it does,
|
|
||||||
* but with no background of its own -- if it is a code span; false leaves anything else to the
|
|
||||||
* renderer.
|
|
||||||
*
|
|
||||||
* The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's
|
|
||||||
* background is part of the text's own drawing, and the text node draws the selection first and the
|
|
||||||
* glyphs over it, so a chip painted as a span background covered the selection: selecting a
|
|
||||||
* sentence highlighted every word except the ones in backticks. Anything drawn by a modifier on the
|
|
||||||
* text is under both, which is where a fenced block's box already is.
|
|
||||||
*/
|
|
||||||
private fun appendCodeChip(
|
|
||||||
builder: AnnotatedString.Builder,
|
|
||||||
content: String,
|
|
||||||
node: ASTNode,
|
|
||||||
settings: AnnotatorSettings,
|
|
||||||
): Boolean {
|
|
||||||
if (node.type != MarkdownElementTypes.CODE_SPAN) return false
|
|
||||||
builder.pushStringAnnotation(CODE_CHIP, "")
|
|
||||||
builder.pushStyle(settings.codeSpanStyle.copy(background = Color.Unspecified))
|
|
||||||
builder.append(' ')
|
|
||||||
// The backticks are the first and last children.
|
|
||||||
builder.buildMarkdownAnnotatedString(content, node.children.drop(1).dropLast(1), settings)
|
|
||||||
builder.append(' ')
|
|
||||||
builder.pop()
|
|
||||||
builder.pop()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val CODE_CHIP = "code"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One box per line of the text [start] until [end] covers, in the layout's own coordinates.
|
|
||||||
*
|
|
||||||
* Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every
|
|
||||||
* line but the last, so a chip whose code wrapped left a full-width empty box behind on the line
|
|
||||||
* above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space
|
|
||||||
* stops being drawn -- the same rule the selection rectangle obeys, so the two agree.
|
|
||||||
*
|
|
||||||
* A run's extent is taken from the boxes of its first and last characters, which is exact while a
|
|
||||||
* line reads in one direction; mixed directions inside a code span would draw one box across the
|
|
||||||
* whole run, and code spans are code.
|
|
||||||
*/
|
|
||||||
private fun TextLayoutResult.chipRects(start: Int, end: Int): List<Rect> {
|
|
||||||
val rects = mutableListOf<Rect>()
|
|
||||||
for (line in getLineForOffset(start)..getLineForOffset(end - 1)) {
|
|
||||||
val from = maxOf(start, getLineStart(line))
|
|
||||||
val to = minOf(end, getLineEnd(line, visibleEnd = true))
|
|
||||||
if (from >= to) continue
|
|
||||||
val head = getBoundingBox(from)
|
|
||||||
val tail = getBoundingBox(to - 1)
|
|
||||||
rects +=
|
|
||||||
Rect(
|
|
||||||
left = minOf(head.left, tail.left),
|
|
||||||
top = minOf(head.top, tail.top),
|
|
||||||
right = maxOf(head.right, tail.right),
|
|
||||||
bottom = maxOf(head.bottom, tail.bottom),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return rects
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The renderer's annotator settings with [appendPlainLink] answering for links and [appendCodeChip]
|
|
||||||
* for inline code. The annotator needs the settings to draw a link's label, and the settings hold
|
|
||||||
* the annotator, so the reference goes through a cell filled in once both exist.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun plainLinkSettings(): AnnotatorSettings {
|
|
||||||
val cell = remember { Ref<AnnotatorSettings>() }
|
|
||||||
val annotator = remember {
|
|
||||||
markdownAnnotator { content, node ->
|
|
||||||
appendPlainLink(this, content, node, cell.value!!) ||
|
|
||||||
appendCodeChip(this, content, node, cell.value!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return annotatorSettings(annotator = annotator).also { cell.value = it }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends [node] as a styled, annotated span if it is a link the renderer would otherwise emit a
|
|
||||||
* `LinkAnnotation` for, or an image it would place; false leaves anything else to the renderer.
|
|
||||||
*/
|
|
||||||
private fun appendPlainLink(
|
|
||||||
builder: AnnotatedString.Builder,
|
|
||||||
content: String,
|
|
||||||
node: ASTNode,
|
|
||||||
settings: AnnotatorSettings,
|
|
||||||
): Boolean {
|
|
||||||
val destination: String
|
|
||||||
/** The label's own inline nodes, when it has markup of its own to draw. */
|
|
||||||
var label: List<ASTNode>? = null
|
|
||||||
/** Plain words for the label; the address itself when there are none. */
|
|
||||||
var words: String? = null
|
|
||||||
when (node.type) {
|
|
||||||
MarkdownElementTypes.INLINE_LINK -> {
|
|
||||||
val text = node.findChildOfType(MarkdownElementTypes.LINK_TEXT) ?: return false
|
|
||||||
destination =
|
|
||||||
node
|
|
||||||
.findChildOfType(MarkdownElementTypes.LINK_DESTINATION)
|
|
||||||
?.getUnescapedTextInNode(content)
|
|
||||||
?.removeSurrounding("<", ">") ?: return false
|
|
||||||
// The brackets are the first and last children of the label.
|
|
||||||
label = text.children.drop(1).dropLast(1)
|
|
||||||
}
|
|
||||||
MarkdownElementTypes.AUTOLINK ->
|
|
||||||
destination = node.getUnescapedTextInNode(content).removeSurrounding("<", ">")
|
|
||||||
GFMTokenTypes.GFM_AUTOLINK -> destination = node.getUnescapedTextInNode(content)
|
|
||||||
MarkdownElementTypes.IMAGE -> {
|
|
||||||
destination =
|
|
||||||
node.resolveImageLink(content, settings.referenceLinkHandler) ?: return false
|
|
||||||
words = node.resolveImageAlt(content)
|
|
||||||
}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
builder.pushStringAnnotation(LINK_URL, destination)
|
|
||||||
builder.pushStyle(settings.linkTextSpanStyle.style ?: SpanStyle())
|
|
||||||
if (label != null) builder.buildMarkdownAnnotatedString(content, label, settings)
|
|
||||||
else builder.append(words ?: destination)
|
|
||||||
builder.pop()
|
|
||||||
builder.pop()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.text.BasicText
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.semantics.isTraversalGroup
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownComponents
|
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownPadding
|
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownTypography
|
|
||||||
import com.mikepenz.markdown.compose.MarkdownElement
|
|
||||||
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
|
|
||||||
import com.mikepenz.markdown.model.State
|
|
||||||
import org.intellij.markdown.MarkdownElementTypes
|
|
||||||
import org.intellij.markdown.MarkdownTokenTypes
|
|
||||||
import org.intellij.markdown.ast.ASTNode
|
|
||||||
import org.intellij.markdown.ast.findChildOfType
|
|
||||||
import org.intellij.markdown.ast.getTextInNode
|
|
||||||
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One drawable piece of a parsed message: a top-level block, or one item of a top-level list.
|
|
||||||
*
|
|
||||||
* The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and
|
|
||||||
* is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a
|
|
||||||
* hundred short ones; and the list composes an item whole in the frame it scrolls into. Measured on
|
|
||||||
* a Pixel 9 Pro XL, the tallest row still being drawn was 36,982px -- twenty-five screens in one
|
|
||||||
* message. A piece is a paragraph, a fence, a table, one bullet: bounded, so both costs are.
|
|
||||||
*
|
|
||||||
* Cut where the parser says the blocks are, which is what makes it safe: a fence, a table and a
|
|
||||||
* nested list are each one node whatever is inside them. A list is the one block that is not
|
|
||||||
* bounded -- a reply's list of sources can be forty items -- so it is cut once more, into its
|
|
||||||
* items.
|
|
||||||
*
|
|
||||||
* A piece is an *address* into the message's one parse rather than a substring of it. Every piece
|
|
||||||
* is drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and
|
|
||||||
* a reference definition at its foot still resolves the links above it.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
|
|
||||||
companion object {
|
|
||||||
const val WHOLE_BLOCK = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The pieces of [parse], in reading order. Blank nodes between blocks are not pieces.
|
|
||||||
*
|
|
||||||
* A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a
|
|
||||||
* message that drew as nothing would be a hole in the transcript with no sign of what fell out.
|
|
||||||
*/
|
|
||||||
fun pieces(parse: State): List<Piece> {
|
|
||||||
val success = parse as? State.Success ?: return listOf(Piece(0))
|
|
||||||
val out = ArrayList<Piece>()
|
|
||||||
success.node.children.forEachIndexed { at, node ->
|
|
||||||
when {
|
|
||||||
node.getTextInNode(success.content).isBlank() -> {}
|
|
||||||
node.isList -> repeat(node.listItems().size) { out += Piece(at, it) }
|
|
||||||
else -> out += Piece(at)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The room above [piece] when it follows [previous] in the same message: none between two items of
|
|
||||||
* one list, whose own padding already separates them, and a block's gap otherwise. The first piece
|
|
||||||
* of a message takes the message's gap, which is the caller's to know.
|
|
||||||
*/
|
|
||||||
fun gapBefore(previous: Piece?, piece: Piece): Dp =
|
|
||||||
if (previous != null && previous.block == piece.block) 0.dp else BLOCK_SPACING
|
|
||||||
|
|
||||||
/** The gap between one block of a reply and the next, wherever a reply is drawn in pieces. */
|
|
||||||
val BLOCK_SPACING: Dp = 6.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which carries the theme,
|
|
||||||
* the components and the reference links to the renderer's element composables.
|
|
||||||
*
|
|
||||||
* A whole block goes to the renderer's own dispatch with this app's component table. Only the list
|
|
||||||
* item is drawn directly, because a list item is the one piece the renderer has no element for.
|
|
||||||
*
|
|
||||||
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply: an
|
|
||||||
* item that is the first or last of its own parse but not of the list the reader sees keeps an
|
|
||||||
* inner item's padding, so nothing moves when the seam between segments does.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun MarkdownPiece(
|
|
||||||
parse: State,
|
|
||||||
text: String,
|
|
||||||
piece: Piece,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
continuesList: Boolean = false,
|
|
||||||
listContinues: Boolean = false,
|
|
||||||
) {
|
|
||||||
if (parse !is State.Success) {
|
|
||||||
// The parser threw. Nothing else in the app has seen this happen; if it does, the words are
|
|
||||||
// still worth more than a blank.
|
|
||||||
Text(text, modifier, style = MaterialTheme.typography.bodyLarge)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val node = parse.node.children[piece.block]
|
|
||||||
if (piece.item == Piece.WHOLE_BLOCK) {
|
|
||||||
Box(modifier) {
|
|
||||||
MarkdownElement(
|
|
||||||
node,
|
|
||||||
LocalMarkdownComponents.current,
|
|
||||||
parse.content,
|
|
||||||
includeSpacer = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val items = node.listItems()
|
|
||||||
MarkdownListItem(
|
|
||||||
content = parse.content,
|
|
||||||
list = node,
|
|
||||||
item = items[piece.item],
|
|
||||||
index = piece.item,
|
|
||||||
first = piece.item == 0 && !continuesList,
|
|
||||||
last = piece.item == items.lastIndex && !listContinues,
|
|
||||||
depth = 0,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a
|
|
||||||
* list inside a quote, and the nested lists an item holds. Top-level lists never come here.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) {
|
|
||||||
val items = list.listItems()
|
|
||||||
Column(modifier) {
|
|
||||||
items.forEachIndexed { index, item ->
|
|
||||||
MarkdownListItem(
|
|
||||||
content,
|
|
||||||
list,
|
|
||||||
item,
|
|
||||||
index,
|
|
||||||
first = index == 0,
|
|
||||||
last = index == items.lastIndex,
|
|
||||||
depth = depth,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One item: its marker beside its content, laid out the way the renderer's own list does so that a
|
|
||||||
* list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first
|
|
||||||
* and last items, since there is no list column to carry it.
|
|
||||||
*
|
|
||||||
* The marker is drawn here rather than by a handler because it is the thing a reader might one day
|
|
||||||
* want styled -- a different glyph per depth, a colour -- and this is the one place it is drawn.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun MarkdownListItem(
|
|
||||||
content: String,
|
|
||||||
list: ASTNode,
|
|
||||||
item: ASTNode,
|
|
||||||
index: Int,
|
|
||||||
first: Boolean,
|
|
||||||
last: Boolean,
|
|
||||||
depth: Int,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val padding = LocalMarkdownPadding.current
|
|
||||||
val typography = LocalMarkdownTypography.current
|
|
||||||
val components = LocalMarkdownComponents.current
|
|
||||||
// A task item's box sits right after the bullet: `- [ ] text`.
|
|
||||||
val checkbox = item.children.getOrNull(1)?.takeIf { it.type == GFMTokenTypes.CHECK_BOX }
|
|
||||||
Row(
|
|
||||||
modifier
|
|
||||||
.semantics { isTraversalGroup = true }
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(
|
|
||||||
start = padding.listIndent * depth,
|
|
||||||
top = padding.listItemTop + if (first) padding.list else 0.dp,
|
|
||||||
bottom = padding.listItemBottom + if (last) padding.list else 0.dp,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
if (checkbox != null) {
|
|
||||||
components.checkbox(MarkdownComponentModel(content, checkbox, typography))
|
|
||||||
} else if (list.type == MarkdownElementTypes.ORDERED_LIST) {
|
|
||||||
Marker("${list.startNumber(content) + index}. ", typography.ordered)
|
|
||||||
} else {
|
|
||||||
Marker(BULLETS[depth % BULLETS.size], typography.bullet)
|
|
||||||
}
|
|
||||||
Column {
|
|
||||||
item.children.forEach { child ->
|
|
||||||
when (child.type) {
|
|
||||||
MarkdownTokenTypes.LIST_BULLET,
|
|
||||||
MarkdownTokenTypes.LIST_NUMBER,
|
|
||||||
GFMTokenTypes.CHECK_BOX -> {}
|
|
||||||
MarkdownElementTypes.ORDERED_LIST,
|
|
||||||
MarkdownElementTypes.UNORDERED_LIST -> MarkdownList(content, child, depth + 1)
|
|
||||||
else -> MarkdownElement(child, components, content, includeSpacer = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The marker in [listMarkerColor]; the renderer's styles carry no colour of their own. */
|
|
||||||
@Composable
|
|
||||||
private fun Marker(text: String, style: TextStyle) {
|
|
||||||
BasicText(text, style = style.copy(color = listMarkerColor))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a
|
|
||||||
* browser draws, so a nested list is told from its parent by the glyph as well as by the indent.
|
|
||||||
* Checked on the emulator's system fonts; a glyph the platform lacks draws as a box, and that check
|
|
||||||
* is the price of adding one here.
|
|
||||||
*/
|
|
||||||
private val BULLETS = listOf("• ", "◦ ", "▪ ")
|
|
||||||
|
|
||||||
internal val ASTNode.isList: Boolean
|
|
||||||
get() = type == MarkdownElementTypes.ORDERED_LIST || type == MarkdownElementTypes.UNORDERED_LIST
|
|
||||||
|
|
||||||
internal fun ASTNode.listItems(): List<ASTNode> = children.filter {
|
|
||||||
it.type == MarkdownElementTypes.LIST_ITEM
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where an ordered list counts from: the number its first item was written with. */
|
|
||||||
private fun ASTNode.startNumber(content: String): Int =
|
|
||||||
findChildOfType(MarkdownElementTypes.LIST_ITEM)
|
|
||||||
?.findChildOfType(MarkdownTokenTypes.LIST_NUMBER)
|
|
||||||
?.getTextInNode(content)
|
|
||||||
?.takeWhile(Char::isDigit)
|
|
||||||
?.toString()
|
|
||||||
?.toIntOrNull() ?: 1
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Markdown read into the spans that carry a colour -- a ```markdown fence in a reply, and a `.md`
|
|
||||||
* file in the viewer.
|
|
||||||
*
|
|
||||||
* Its own scanner rather than a row of [Rules] because markdown has neither keywords nor strings:
|
|
||||||
* what a character means depends on where it sits. A `#` opens a heading at the start of a line and
|
|
||||||
* is an ordinary character three words in; a `*` opens emphasis only if something closes it on the
|
|
||||||
* same line. The token scanner cannot ask either question.
|
|
||||||
*
|
|
||||||
* Structure is read a line at a time and each line's prose left to right, so every decision is made
|
|
||||||
* inside one line -- except the two that are not. A fenced block is state carried forward, so an
|
|
||||||
* unclosed fence colours the rest of the text, which is what it looks like while somebody is
|
|
||||||
* writing it. A table is found by its delimiter row (`|---|---|`), the only line of one that cannot
|
|
||||||
* be anything else, and its header is the line before that -- the one place here that looks ahead.
|
|
||||||
*
|
|
||||||
* What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is
|
|
||||||
* one, four spaces after a bullet is a list item's second paragraph, and the two are told apart by
|
|
||||||
* what came before. Colouring the wrong one as code is a mistake the reader cannot see.
|
|
||||||
*
|
|
||||||
* Like [scan], the spans come out ordered, non-overlapping and inside the text by construction.
|
|
||||||
*/
|
|
||||||
fun scanMarkdown(code: String): List<Span> = MarkdownScanner(code).run()
|
|
||||||
|
|
||||||
/** The characters an unordered list may be bulleted with. */
|
|
||||||
private const val BULLETS = "-*+"
|
|
||||||
|
|
||||||
/** The characters a thematic break, or a setext heading's underline, can be drawn with. */
|
|
||||||
private const val RULE_MARKERS = "-*_="
|
|
||||||
|
|
||||||
/** The characters that can open emphasis, strong emphasis or a strikethrough. */
|
|
||||||
private const val EMPHASIS = "*_~"
|
|
||||||
|
|
||||||
/** Characters that end a bare URL wherever they appear, and ones only trimmed off the end. */
|
|
||||||
private const val URL_STOPS = "<>\"'`|"
|
|
||||||
private const val URL_TRAILING = ".,:;!?"
|
|
||||||
|
|
||||||
private class MarkdownScanner(private val code: String) {
|
|
||||||
private val spans = ArrayList<Span>()
|
|
||||||
|
|
||||||
fun run(): List<Span> {
|
|
||||||
var at = 0
|
|
||||||
// The delimiter run that opened the fenced block we are inside, or null between them.
|
|
||||||
var fence: String? = null
|
|
||||||
// Whether the row above was part of a table, which is what makes this one a body row.
|
|
||||||
var table = false
|
|
||||||
while (at <= code.length) {
|
|
||||||
val end = lineEnd(at)
|
|
||||||
val open = fence
|
|
||||||
if (open != null) {
|
|
||||||
// The content and the closing line alike: a fence is one block of code, and its own
|
|
||||||
// delimiters belong to it the way a string's quotes belong to the string.
|
|
||||||
emit(at, end, Kind.STRING)
|
|
||||||
if (closesFence(at, end, open)) fence = null
|
|
||||||
} else {
|
|
||||||
val opened = opensFence(at, end)
|
|
||||||
fence = opened
|
|
||||||
if (opened != null) table = false else table = row(at, end, table)
|
|
||||||
}
|
|
||||||
if (end == code.length) break
|
|
||||||
at = end + 1
|
|
||||||
}
|
|
||||||
return spans
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The end of the line beginning at [at]: the newline, or the end of the text. */
|
|
||||||
private fun lineEnd(at: Int): Int {
|
|
||||||
val newline = code.indexOf('\n', at)
|
|
||||||
return if (newline < 0) code.length else newline
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One line that is not inside a fence, and whether the table it may be part of is still open.
|
|
||||||
*
|
|
||||||
* A table is recognised by its delimiter row, the only line of one that cannot be anything
|
|
||||||
* else. That row comes *after* the header it belongs to, so the header is found by looking one
|
|
||||||
* line ahead -- the single piece of lookahead here, and cheaper than colouring every `|` in the
|
|
||||||
* document, which would mark the pipes in a shell command written in a paragraph.
|
|
||||||
*/
|
|
||||||
private fun row(start: Int, end: Int, table: Boolean): Boolean {
|
|
||||||
if (tableDelimiter(start, end)) {
|
|
||||||
emit(indented(start, end), end, Kind.MARK)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
val header = end < code.length && tableDelimiter(end + 1, lineEnd(end + 1))
|
|
||||||
if ((table || header) && hasPipe(start, end)) {
|
|
||||||
tableRow(start, end)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
structure(start, end)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A line of nothing but pipes, dashes, alignment colons and space, with one of each needed. */
|
|
||||||
private fun tableDelimiter(start: Int, end: Int): Boolean {
|
|
||||||
var dashes = false
|
|
||||||
var pipes = false
|
|
||||||
for (at in indented(start, end) until end) {
|
|
||||||
when (code[at]) {
|
|
||||||
'-' -> dashes = true
|
|
||||||
'|' -> pipes = true
|
|
||||||
':',
|
|
||||||
' ',
|
|
||||||
'\t' -> {}
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dashes && pipes
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasPipe(start: Int, end: Int): Boolean {
|
|
||||||
var at = start
|
|
||||||
while (at < end) {
|
|
||||||
if (code[at] == '\\') at += 2 else if (code[at] == '|') return true else at++
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A table row: the pipes are the structure, and what is between them is prose. */
|
|
||||||
private fun tableRow(start: Int, end: Int) {
|
|
||||||
var at = indented(start, end)
|
|
||||||
var cell = at
|
|
||||||
while (at < end) {
|
|
||||||
when (code[at]) {
|
|
||||||
'\\' -> at += 2
|
|
||||||
'|' -> {
|
|
||||||
inline(cell, at)
|
|
||||||
emit(at, at + 1, Kind.MARK)
|
|
||||||
at++
|
|
||||||
cell = at
|
|
||||||
}
|
|
||||||
else -> at++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inline(cell, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spans, coalesced with the one before when they touch and agree. Worth doing here rather than
|
|
||||||
* leaving it to the caller: the line scanner emits per marker and per word, so a heading would
|
|
||||||
* otherwise arrive as a dozen abutting spans of one colour.
|
|
||||||
*/
|
|
||||||
private fun emit(start: Int, end: Int, kind: Kind) {
|
|
||||||
if (end <= start) return
|
|
||||||
val last = spans.lastOrNull()
|
|
||||||
if (last != null && last.kind == kind && last.end == start) {
|
|
||||||
spans[spans.size - 1] = Span(last.start, end, kind)
|
|
||||||
} else {
|
|
||||||
spans.add(Span(start, end, kind))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The first character of the line at or after [start] that is not indentation. */
|
|
||||||
private fun indented(start: Int, end: Int): Int {
|
|
||||||
var at = start
|
|
||||||
while (at < end && (code[at] == ' ' || code[at] == '\t')) at++
|
|
||||||
return at
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The run of backticks or tildes that could open or close a fence on this line, or null. */
|
|
||||||
private fun fenceRun(start: Int, end: Int): IntRange? {
|
|
||||||
val at = indented(start, end)
|
|
||||||
if (at == end) return null
|
|
||||||
val marker = code[at]
|
|
||||||
if (marker != '`' && marker != '~') return null
|
|
||||||
var run = at
|
|
||||||
while (run < end && code[run] == marker) run++
|
|
||||||
return if (run - at >= 3) at until run else null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Draws an opening fence line and answers its delimiter, or null if this is not one. */
|
|
||||||
private fun opensFence(start: Int, end: Int): String? {
|
|
||||||
val run = fenceRun(start, end) ?: return null
|
|
||||||
emit(run.first, run.last + 1, Kind.STRING)
|
|
||||||
// The info word is what the fence is a fence *of*, which is metadata about the block rather
|
|
||||||
// than part of it.
|
|
||||||
emit(indented(run.last + 1, end), end, Kind.METADATA)
|
|
||||||
return code.substring(run.first, run.last + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this line closes a fence opened by [open]: the same character, at least as many of
|
|
||||||
* them, and nothing else on the line -- so a longer run closes a shorter one and a line of
|
|
||||||
* backticks with a word after it does not close anything.
|
|
||||||
*/
|
|
||||||
private fun closesFence(start: Int, end: Int, open: String): Boolean {
|
|
||||||
val run = fenceRun(start, end) ?: return false
|
|
||||||
if (code[run.first] != open[0] || run.last + 1 - run.first < open.length) return false
|
|
||||||
return indented(run.last + 1, end) == end
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One ordinary line: what its opening characters make it, and then its prose. */
|
|
||||||
private fun structure(start: Int, end: Int) {
|
|
||||||
var at = indented(start, end)
|
|
||||||
// Quote markers come before everything else and can be several deep, and what follows one
|
|
||||||
// is an ordinary line again -- a heading inside a quote is still a heading.
|
|
||||||
while (at < end && code[at] == '>') {
|
|
||||||
at++
|
|
||||||
emit(at - 1, at, Kind.MARK)
|
|
||||||
at = indented(at, end)
|
|
||||||
}
|
|
||||||
if (at == end) return
|
|
||||||
if (heading(at, end) || thematicBreak(at, end)) return
|
|
||||||
inline(bullet(at, end), end)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `#` to `######` and a space. Without the space it is a word beginning with a hash. */
|
|
||||||
private fun heading(start: Int, end: Int): Boolean {
|
|
||||||
var at = start
|
|
||||||
while (at < end && code[at] == '#') at++
|
|
||||||
val depth = at - start
|
|
||||||
if (depth !in 1..6) return false
|
|
||||||
if (at < end && code[at] != ' ' && code[at] != '\t') return false
|
|
||||||
emit(start, end, Kind.KEYWORD)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A line made of one repeated rule character and nothing else.
|
|
||||||
*
|
|
||||||
* `---`, `***` and `___` are thematic breaks; `===` and `---` are also the underline of a
|
|
||||||
* setext heading. The two are the same line to look at and mean the same thing to a reader, so
|
|
||||||
* they get one appearance rather than a lookback. One `=` is enough because a setext underline
|
|
||||||
* may be a single character; a break needs three, which keeps a `- ` bullet out of here.
|
|
||||||
*/
|
|
||||||
private fun thematicBreak(start: Int, end: Int): Boolean {
|
|
||||||
val marker = code[start]
|
|
||||||
if (marker !in RULE_MARKERS) return false
|
|
||||||
var seen = 0
|
|
||||||
for (at in start until end) {
|
|
||||||
val character = code[at]
|
|
||||||
if (character == marker) seen++ else if (!character.isWhitespace()) return false
|
|
||||||
}
|
|
||||||
if (seen < if (marker == '=') 1 else 3) return false
|
|
||||||
emit(start, end, Kind.MARK)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Draws a list marker if the line opens with one, and answers where the item's text starts. */
|
|
||||||
private fun bullet(start: Int, end: Int): Int {
|
|
||||||
val marker = code[start]
|
|
||||||
if (marker in BULLETS && spaceOrEnd(start + 1, end)) {
|
|
||||||
emit(start, start + 1, Kind.MARK)
|
|
||||||
return indented(start + 1, end)
|
|
||||||
}
|
|
||||||
var digits = start
|
|
||||||
while (digits < end && code[digits].isDigit()) digits++
|
|
||||||
val delimiter = code.getOrNull(digits)
|
|
||||||
if (
|
|
||||||
digits > start && (delimiter == '.' || delimiter == ')') && spaceOrEnd(digits + 1, end)
|
|
||||||
) {
|
|
||||||
emit(start, digits + 1, Kind.MARK)
|
|
||||||
return indented(digits + 1, end)
|
|
||||||
}
|
|
||||||
return start
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun spaceOrEnd(at: Int, end: Int) = at >= end || code[at] == ' ' || code[at] == '\t'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The inline forms, left to right.
|
|
||||||
*
|
|
||||||
* Every branch answers a position strictly after [start] of its call, so this terminates
|
|
||||||
* whether or not the form it was looking at turned out to be one.
|
|
||||||
*/
|
|
||||||
private fun inline(start: Int, end: Int) {
|
|
||||||
var at = start
|
|
||||||
while (at < end) {
|
|
||||||
val character = code[at]
|
|
||||||
at =
|
|
||||||
when {
|
|
||||||
// A backslash takes the character after it out of the running entirely, which
|
|
||||||
// is how `\*` stays an asterisk rather than opening emphasis.
|
|
||||||
character == '\\' -> at + 2
|
|
||||||
character == '`' -> codeSpan(at, end)
|
|
||||||
character == '[' -> link(at, at, end)
|
|
||||||
character == '!' && code.getOrNull(at + 1) == '[' -> link(at, at + 1, end)
|
|
||||||
character == '<' -> autolink(at, end)
|
|
||||||
character in EMPHASIS -> emphasis(at, end)
|
|
||||||
else -> url(at, end) ?: (at + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `` `code` ``, closed by a run of exactly as many backticks as opened it. That count is what
|
|
||||||
* lets a span hold a backtick of its own, and why the search skips over a shorter or longer run
|
|
||||||
* rather than stopping at the first backtick.
|
|
||||||
*/
|
|
||||||
private fun codeSpan(start: Int, end: Int): Int {
|
|
||||||
var open = start
|
|
||||||
while (open < end && code[open] == '`') open++
|
|
||||||
val ticks = open - start
|
|
||||||
var at = open
|
|
||||||
while (at < end) {
|
|
||||||
if (code[at] != '`') {
|
|
||||||
at++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var close = at
|
|
||||||
while (close < end && code[close] == '`') close++
|
|
||||||
if (close - at == ticks) {
|
|
||||||
emit(start, close, Kind.STRING)
|
|
||||||
return close
|
|
||||||
}
|
|
||||||
at = close
|
|
||||||
}
|
|
||||||
// Nothing closes it on this line, so those were ordinary backticks.
|
|
||||||
return open
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `[text](destination)`, and the same with a leading `!` for an image.
|
|
||||||
*
|
|
||||||
* The text is drawn as prose -- it is what the reader reads -- so only the brackets around it
|
|
||||||
* are marked, and the destination is metadata. A `[text]` with no destination after it is left
|
|
||||||
* plain, because that is what a reference link and a bracketed aside look like.
|
|
||||||
*/
|
|
||||||
private fun link(start: Int, bracket: Int, end: Int): Int {
|
|
||||||
var depth = 0
|
|
||||||
var close = bracket
|
|
||||||
while (close < end) {
|
|
||||||
when (code[close]) {
|
|
||||||
'\\' -> close++
|
|
||||||
'[' -> depth++
|
|
||||||
']' -> {
|
|
||||||
depth--
|
|
||||||
if (depth == 0) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
close++
|
|
||||||
}
|
|
||||||
if (close >= end) return start + 1
|
|
||||||
val destination = close + 1
|
|
||||||
if (code.getOrNull(destination) != '(') return start + 1
|
|
||||||
val paren = code.indexOf(')', destination)
|
|
||||||
if (paren < 0 || paren >= end) return start + 1
|
|
||||||
emit(start, bracket + 1, Kind.MARK)
|
|
||||||
inline(bracket + 1, close)
|
|
||||||
emit(close, destination, Kind.MARK)
|
|
||||||
emit(destination, paren + 1, Kind.METADATA)
|
|
||||||
return paren + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `<https://example.com>` and `<name@example.com>`, drawn as the destination they are.
|
|
||||||
*
|
|
||||||
* The angle brackets have to hold no whitespace and something that makes an address of it -- a
|
|
||||||
* scheme's colon or an at sign -- which is what keeps an HTML tag out.
|
|
||||||
*/
|
|
||||||
private fun autolink(start: Int, end: Int): Int {
|
|
||||||
var at = start + 1
|
|
||||||
var addressed = false
|
|
||||||
while (at < end) {
|
|
||||||
val character = code[at]
|
|
||||||
if (character.isWhitespace() || character == '<') return start + 1
|
|
||||||
if (character == '>') {
|
|
||||||
if (!addressed) return start + 1
|
|
||||||
emit(start, at + 1, Kind.METADATA)
|
|
||||||
return at + 1
|
|
||||||
}
|
|
||||||
if (character == ':' || character == '@') addressed = true
|
|
||||||
at++
|
|
||||||
}
|
|
||||||
return start + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A bare `scheme://…` written in prose, or null if one does not start here.
|
|
||||||
*
|
|
||||||
* A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry.
|
|
||||||
*
|
|
||||||
* Where it ends is the part worth stating: the sentence's punctuation is not the address, so a
|
|
||||||
* trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the
|
|
||||||
* URL -- otherwise a link in parentheses loses its `)`. A pipe stops it too, because a URL in a
|
|
||||||
* table cell must not swallow the cell's edge.
|
|
||||||
*/
|
|
||||||
private fun url(start: Int, end: Int): Int? {
|
|
||||||
if (start > 0 && isWord(code[start - 1])) return null
|
|
||||||
var scheme = start
|
|
||||||
while (scheme < end && code[scheme].isLetter()) scheme++
|
|
||||||
if (scheme == start || !code.startsWith("://", scheme)) return null
|
|
||||||
val body = scheme + 3
|
|
||||||
var at = body
|
|
||||||
var openers = 0
|
|
||||||
var closers = 0
|
|
||||||
while (at < end && !code[at].isWhitespace() && code[at] !in URL_STOPS) {
|
|
||||||
if (code[at] == '(') openers++ else if (code[at] == ')') closers++
|
|
||||||
at++
|
|
||||||
}
|
|
||||||
while (at > body) {
|
|
||||||
val last = code[at - 1]
|
|
||||||
if (last in URL_TRAILING) at--
|
|
||||||
else if (last == ')' && closers > openers) {
|
|
||||||
closers--
|
|
||||||
at--
|
|
||||||
} else break
|
|
||||||
}
|
|
||||||
if (at == body) return null
|
|
||||||
emit(start, at, Kind.METADATA)
|
|
||||||
return at
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all -- which is how the
|
|
||||||
* token scanner draws a string: the quotes are part of the thing.
|
|
||||||
*
|
|
||||||
* The two guards keep this off code that happens to be in a paragraph: the opener must be
|
|
||||||
* followed by something to emphasise and the closer preceded by something emphasised, so `a * b
|
|
||||||
* * c` opens nothing and neither does the `*p = *q` of a C fragment. Underscores may not start
|
|
||||||
* or end inside a word, or every `snake_case_name` would be half emphasised.
|
|
||||||
*/
|
|
||||||
private fun emphasis(start: Int, end: Int): Int {
|
|
||||||
val marker = code[start]
|
|
||||||
var open = start
|
|
||||||
while (open < end && code[open] == marker) open++
|
|
||||||
val length = open - start
|
|
||||||
if (marker == '~' && length != 2) return open
|
|
||||||
if (length > 3) return open
|
|
||||||
if (open == end || code[open].isWhitespace()) return open
|
|
||||||
if (marker == '_' && start > 0 && isWord(code[start - 1])) return open
|
|
||||||
var at = open
|
|
||||||
while (at < end) {
|
|
||||||
if (code[at] == '\\') {
|
|
||||||
at += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (code[at] != marker) {
|
|
||||||
at++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var close = at
|
|
||||||
while (close < end && code[close] == marker) close++
|
|
||||||
val finish = at + length
|
|
||||||
if (
|
|
||||||
close - at >= length &&
|
|
||||||
!code[at - 1].isWhitespace() &&
|
|
||||||
!(marker == '_' && finish < end && isWord(code[finish]))
|
|
||||||
) {
|
|
||||||
emit(start, finish, Kind.LITERAL)
|
|
||||||
return finish
|
|
||||||
}
|
|
||||||
at = close
|
|
||||||
}
|
|
||||||
return open
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isWord(character: Char) = character.isLetterOrDigit() || character == '_'
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
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.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An assistant's reply, with anything it says it remembered drawn as a note rather than as markup.
|
|
||||||
*
|
|
||||||
* Claude Code marks a sentence that came from its stored memory by wrapping it in `<cc-memory
|
|
||||||
* filenames="...">`. Markdown has nothing to say about that, so it arrived on screen as literal
|
|
||||||
* angle brackets in the middle of a sentence -- which reads as the model having emitted broken
|
|
||||||
* HTML. It is really the opposite: a claim about where something came from, and "I was told this
|
|
||||||
* before" and "I worked this out just now" are different things the reader cannot otherwise tell
|
|
||||||
* apart.
|
|
||||||
*
|
|
||||||
* A tag that has not finished arriving is left alone: a half-written marker is not a marker yet.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun AssistantMessage(
|
|
||||||
text: String,
|
|
||||||
replies: ParsedReplies,
|
|
||||||
/** Which notes are open, by [MessagePart.Remembered.text] -- see [MemoryNote]. */
|
|
||||||
openNotes: Set<String>,
|
|
||||||
onToggleNote: (String) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
live: Boolean = false,
|
|
||||||
) {
|
|
||||||
DebugStats.count("message composed")
|
|
||||||
val parts = remember(text) { messageParts(text) }
|
|
||||||
val only = parts.singleOrNull()
|
|
||||||
if (only is MessagePart.Prose) {
|
|
||||||
MarkdownText(only.text, replies, modifier, live)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
parts.forEach { part ->
|
|
||||||
when (part) {
|
|
||||||
is MessagePart.Prose -> MarkdownText(part.text, replies, live = live)
|
|
||||||
is MessagePart.Remembered ->
|
|
||||||
MemoryNote(part, replies, part.text in openNotes) { onToggleNote(part.text) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The pieces [AssistantMessage] draws, which is [splitMemoryNotes] with one correction.
|
|
||||||
*
|
|
||||||
* A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed
|
|
||||||
* prose part made while looking for them -- inspecting a message must not change it. That belongs
|
|
||||||
* here rather than at the places that need the answer, because [warm] has to name the same strings
|
|
||||||
* the rows draw: a string warmed under a key no row ever looks up is a miss nothing reports.
|
|
||||||
*
|
|
||||||
* Public because [transcriptUnits] flattens settled replies into the same parts; go through
|
|
||||||
* [ParsedReplies.partsOf] on any path that runs per fold or per page.
|
|
||||||
*/
|
|
||||||
fun messageParts(text: String): List<MessagePart> {
|
|
||||||
val parts = splitMemoryNotes(text)
|
|
||||||
return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One sentence the model attributed to a memory file, closed until somebody asks.
|
|
||||||
*
|
|
||||||
* Closed by default, like a tool call and a peer message and for the same reason: it is not part of
|
|
||||||
* what was said to the reader, it is a note about where a claim came from. Left open it breaks the
|
|
||||||
* reply in half around a card, and these arrive several to a message.
|
|
||||||
*
|
|
||||||
* What stays visible is which file it came from, because that is the whole of what the note claims
|
|
||||||
* and the part a reader scanning for "why does it think that" is looking for.
|
|
||||||
*
|
|
||||||
* Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to
|
|
||||||
* still be open on the way back, and a card that remembered for itself would forget the moment the
|
|
||||||
* list stopped composing it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun MemoryNote(
|
|
||||||
note: MessagePart.Remembered,
|
|
||||||
replies: ParsedReplies,
|
|
||||||
expanded: Boolean,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
) {
|
|
||||||
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
// Named, not just tinted: a colour can say "this one is different", but it cannot say
|
|
||||||
// what kind of different, and "recalled from a file" is a difference in kind.
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
if (note.files.size == 1) "remembered from ${note.files[0]}"
|
|
||||||
else "remembered from ${note.files.joinToString(", ")}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
if (!expanded) {
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
note.text,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
// The head, not the tail: a sentence is identified by how it opens.
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
|
|
||||||
if (expanded) {
|
|
||||||
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
|
|
||||||
MarkdownText(note.text, replies, Modifier.padding(top = 4.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One piece of a reply: ordinary prose, or a sentence attributed to a memory file. */
|
|
||||||
sealed class MessagePart {
|
|
||||||
/** The markdown this piece is drawn from. */
|
|
||||||
abstract val text: String
|
|
||||||
|
|
||||||
data class Prose(override val text: String) : MessagePart()
|
|
||||||
|
|
||||||
data class Remembered(override val text: String, val files: List<String>) : MessagePart()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MEMORY_NOTE =
|
|
||||||
Regex("""<cc-memory\s+filenames="([^"]*)"\s*>(.*?)</cc-memory>""", RegexOption.DOT_MATCHES_ALL)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Splits [text] into prose and memory notes, in order. Always returns at least one part, so a
|
|
||||||
* message with no notes is one piece of prose and costs nothing extra to draw.
|
|
||||||
*/
|
|
||||||
fun splitMemoryNotes(text: String): List<MessagePart> {
|
|
||||||
val parts = mutableListOf<MessagePart>()
|
|
||||||
var at = 0
|
|
||||||
for (match in MEMORY_NOTE.findAll(text)) {
|
|
||||||
val before = text.substring(at, match.range.first)
|
|
||||||
if (before.isNotBlank()) parts += MessagePart.Prose(before.trim())
|
|
||||||
val files = match.groupValues[1].split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
|
||||||
parts += MessagePart.Remembered(match.groupValues[2].trim(), files)
|
|
||||||
at = match.range.last + 1
|
|
||||||
}
|
|
||||||
val rest = text.substring(at)
|
|
||||||
if (rest.isNotBlank() || parts.isEmpty()) parts += MessagePart.Prose(rest.trim())
|
|
||||||
return parts
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a session with no model of its own is called, in the button and in the list it opens.
|
|
||||||
*
|
|
||||||
* One constant rather than a literal in each place, because the two have to agree: a picker whose
|
|
||||||
* options cannot say every state its button can display is one you can leave and not get back to.
|
|
||||||
* It is also the Claude CLI's own word for "whatever is configured".
|
|
||||||
*/
|
|
||||||
const val DEFAULT_MODEL = "default"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A model's name as a person reads it.
|
|
||||||
*
|
|
||||||
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to `claude-
|
|
||||||
* haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session using"
|
|
||||||
* and far too long for a button in a row that also holds Stop and Send.
|
|
||||||
*
|
|
||||||
* So the two ends that identify nothing are dropped and nothing else is: the vendor prefix, which
|
|
||||||
* is the same on every model this app can show, and the release date, which distinguishes builds of
|
|
||||||
* one model rather than one model from another. Anything that does not look like that is returned
|
|
||||||
* untouched.
|
|
||||||
*
|
|
||||||
* A llama.cpp session's model is not an identifier at all -- it is `owner/repo/file.gguf`, where
|
|
||||||
* the file was downloaded from -- so what is kept is the file, which is the part that tells two
|
|
||||||
* models apart, and the extension goes with the directories. The model's *own* name is better still
|
|
||||||
* and is not derivable here: it is inside the file, and only the server has ever opened it. Where a
|
|
||||||
* screen has the server's answer it should prefer it; this is the floor under every screen that
|
|
||||||
* does not.
|
|
||||||
*
|
|
||||||
* A display decision, not a correction: the full name is what the session reports.
|
|
||||||
*/
|
|
||||||
fun modelLabel(model: String?): String {
|
|
||||||
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
|
|
||||||
if (name.endsWith(GGUF)) {
|
|
||||||
return name.substringAfterLast('/').removeSuffix(GGUF)
|
|
||||||
}
|
|
||||||
return name.removePrefix("claude-").replace(DATED_SUFFIX, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */
|
|
||||||
private val DATED_SUFFIX = Regex("""-\d{8}$""")
|
|
||||||
|
|
||||||
/** What every model a llama.cpp session can run is stored as. */
|
|
||||||
private const val GGUF = ".gguf"
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
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.graphics.Color
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The icons the app draws, as glyphs in a Nerd Fonts subset rather than as vector assets.
|
|
||||||
*
|
|
||||||
* Drawing them as *text* is what makes them cheap: an icon beside a line of text wants that line's
|
|
||||||
* size, colour and baseline, and a `Text` gets all three for free where an `Icon` needs each one
|
|
||||||
* set and kept in step by hand.
|
|
||||||
*
|
|
||||||
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
|
|
||||||
* grounds that a system font may not have the glyph. That objection is about *relying* on a system
|
|
||||||
* font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
|
|
||||||
* is `app/build-icon-font.sh`'s output -- eighteen glyphs, 2.9 KB, subset out of the 3 MB symbols
|
|
||||||
* font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
|
|
||||||
* the script did not subset is a glyph that silently isn't there.
|
|
||||||
*
|
|
||||||
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
|
|
||||||
* That is what makes two icons the same size without either being given a size: the proportional
|
|
||||||
* face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side by side came
|
|
||||||
* out visibly different widths. [GLYPH_SIZE] carries the cost.
|
|
||||||
*
|
|
||||||
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
|
|
||||||
* Material Design codepoints. Those two must not drift. The script is copied rather than shared
|
|
||||||
* because most of what looks like duplication is the `GLYPHS` list, which has to differ -- the
|
|
||||||
* point of subsetting is to ship only the codepoints one app draws.
|
|
||||||
*/
|
|
||||||
val NerdIcons = FontFamily(Font(R.font.nerd_icons))
|
|
||||||
|
|
||||||
/** Nerd Fonts puts these in plane 15, so each is a surrogate pair. */
|
|
||||||
private fun glyph(codePoint: Int) = String(Character.toChars(codePoint))
|
|
||||||
|
|
||||||
/** `md-cog` -- settings for the thing it sits beside. */
|
|
||||||
val SETTINGS_GLYPH = glyph(0xF0493)
|
|
||||||
|
|
||||||
/** `md-refresh` -- ask the server again for whatever is on screen. */
|
|
||||||
val REFRESH_GLYPH = glyph(0xF0450)
|
|
||||||
|
|
||||||
/** `md-send` -- the filled paper plane: submit what is in the composer. */
|
|
||||||
val SEND_GLYPH = glyph(0xF048A)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `md-stop` -- a filled square: end the process behind this session.
|
|
||||||
*
|
|
||||||
* The square is what stop has meant since tape decks, and it is spent here on the thing that
|
|
||||||
* actually stops rather than on pausing. [PAUSE_GLYPH] is the turn; this is the session.
|
|
||||||
*/
|
|
||||||
val STOP_GLYPH = glyph(0xF04DB)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `md-pause` -- two bars: take the running turn away and leave the session there.
|
|
||||||
*
|
|
||||||
* The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what
|
|
||||||
* pressing it now would do to the process, and the three marks are the three answers. An interrupt
|
|
||||||
* ends a turn and nothing else, which is a pause, not a stop.
|
|
||||||
*/
|
|
||||||
val PAUSE_GLYPH = glyph(0xF03E4)
|
|
||||||
|
|
||||||
/** `md-play` -- start the process again, on the conversation it left. See [PAUSE_GLYPH]. */
|
|
||||||
val PLAY_GLYPH = glyph(0xF040A)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn.
|
|
||||||
*
|
|
||||||
* The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than
|
|
||||||
* starting one, and one glyph doing both jobs would promise something immediate and do something
|
|
||||||
* that waits.
|
|
||||||
*/
|
|
||||||
val QUEUE_GLYPH = glyph(0xF1163)
|
|
||||||
|
|
||||||
/** `md-close` -- take this off again: an attachment picked and not wanted. */
|
|
||||||
val CLOSE_GLYPH = glyph(0xF0156)
|
|
||||||
|
|
||||||
/** `md-arrow_left` -- back one level, to whatever this was opened from. */
|
|
||||||
val BACK_GLYPH = glyph(0xF004D)
|
|
||||||
|
|
||||||
/** `md-bell` -- the notifications this session is allowed to raise. */
|
|
||||||
val BELL_GLYPH = glyph(0xF009A)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `fa-line_chart` -- how much of the account's rate limits is gone.
|
|
||||||
*
|
|
||||||
* Font Awesome's rather than Material's, which is the one break in the family above: it was asked
|
|
||||||
* for by name, and Material's chart glyphs are a bare line where this one has its axes.
|
|
||||||
*/
|
|
||||||
val USAGE_GLYPH = glyph(0xF201)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `md-speedometer` -- what this session is costing to draw.
|
|
||||||
*
|
|
||||||
* A speedometer rather than a bug, because what it copies is a measurement rather than a fault
|
|
||||||
* report: it is as useful on a screen that feels fine, where the answer is that nothing is slow.
|
|
||||||
*/
|
|
||||||
val SPEED_GLYPH = glyph(0xF04C5)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `md-folder` -- the files on the machine this session runs on.
|
|
||||||
*
|
|
||||||
* The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and
|
|
||||||
* the refresh arrow must not. Doubles as the mark on a directory row inside the explorer, which is
|
|
||||||
* what makes the button say where it leads.
|
|
||||||
*/
|
|
||||||
val FOLDER_GLYPH = glyph(0xF024B)
|
|
||||||
|
|
||||||
/** `md-file_outline` -- one file, in a listing beside the directories. */
|
|
||||||
val FILE_GLYPH = glyph(0xF0224)
|
|
||||||
|
|
||||||
/** `md-plus` -- make something here. dev-updater's codepoint as well. */
|
|
||||||
val PLUS_GLYPH = glyph(0xF0415)
|
|
||||||
|
|
||||||
/** `md-pencil` -- change what this file says, rather than only reading it. */
|
|
||||||
val EDIT_GLYPH = glyph(0xF03EB)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `md-content_save` -- write the edits back to the machine.
|
|
||||||
*
|
|
||||||
* The floppy disk, which is what save has meant for longer than most of the people reading it have
|
|
||||||
* been alive and is still the only mark anybody recognises for it.
|
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at
|
|
||||||
* most 0.83 em of its point size, so the number was standing in for the headroom above the tallest
|
|
||||||
* one; in the Mono face every glyph fills its em exactly, and keeping 20 would have stepped every
|
|
||||||
* icon in the app up by a fifth.
|
|
||||||
*/
|
|
||||||
private val GLYPH_SIZE = 17.sp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The same measurement in dp: a glyph's em box is its point size, and a layout is laid out in dp.
|
|
||||||
*/
|
|
||||||
private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The square a glyph button occupies: the mark, plus the same ring of padding on all four sides.
|
|
||||||
*
|
|
||||||
* The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to
|
|
||||||
* the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of
|
|
||||||
* its own. That is what it was: the box was the size of the mark (28dp) and the separation was
|
|
||||||
* bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the
|
|
||||||
* edge.
|
|
||||||
*
|
|
||||||
* 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has
|
|
||||||
* to find, and what the pressed-state ripple draws: at 28dp that circle was inscribed in the mark's
|
|
||||||
* own corners and beside a title it arrived at the first letter. And it is taller than any header's
|
|
||||||
* text, which is what lets the button fill a header row rather than sit in the middle of one.
|
|
||||||
*/
|
|
||||||
val GLYPH_BUTTON_SIZE = 48.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
|
|
||||||
* a back arrow.
|
|
||||||
*
|
|
||||||
* Two glyph buttons need nothing between them: each brings its own ring and the two add up. Text
|
|
||||||
* brings none, so the second ring has to be asked for -- without it the pressed-state circle
|
|
||||||
* arrives at the first letter of the title.
|
|
||||||
*/
|
|
||||||
val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A glyph you can press: the icon equivalent of a `TextButton`.
|
|
||||||
*
|
|
||||||
* Its own composable so that every icon button in the app is one size and one colour without each
|
|
||||||
* caller saying so, and so the [label] none of them displays is still there for a screen reader --
|
|
||||||
* which is also the answer to "what was that button for" six months from now.
|
|
||||||
*
|
|
||||||
* [enabled] is passed through rather than left to callers hiding the button: a control that comes
|
|
||||||
* and goes makes its own absence the signal, and absence cannot say whether there was nothing to do
|
|
||||||
* or nobody checked.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun GlyphButton(
|
|
||||||
glyph: String,
|
|
||||||
label: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
colour: Color = MaterialTheme.colorScheme.primary,
|
|
||||||
) {
|
|
||||||
MarkButton(label, onClick, modifier, enabled) {
|
|
||||||
Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The same square, around a mark that is not a glyph.
|
|
||||||
*
|
|
||||||
* A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the
|
|
||||||
* size, spacing and touch target every other icon button already is. The caller still owes it a
|
|
||||||
* [label]: nothing here draws a word.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun MarkButton(
|
|
||||||
label: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
mark: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
IconButton(
|
|
||||||
onClick = onClick,
|
|
||||||
enabled = enabled,
|
|
||||||
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
|
|
||||||
) {
|
|
||||||
mark()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The square a glyph button occupies, with a spinner in it instead of a mark.
|
|
||||||
*
|
|
||||||
* For a button whose work is under way. It takes the button's whole box rather than the mark's, so
|
|
||||||
* swapping one for the other leaves everything in the row exactly where it was.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun GlyphSpinner(label: String, modifier: Modifier = Modifier) {
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(Modifier.size(GLYPH_EXTENT), strokeWidth = 2.dp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One icon, drawn as text.
|
|
||||||
*
|
|
||||||
* Callers that are already inside something pressable use this; [GlyphButton] is the one that adds
|
|
||||||
* the press. Either way the caller owes it a description, since neither draws a word.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Glyph(
|
|
||||||
glyph: String,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
colour: Color = MaterialTheme.colorScheme.primary,
|
|
||||||
size: TextUnit = GLYPH_SIZE,
|
|
||||||
) {
|
|
||||||
// Line height of the point size, which for this font is the square the glyph draws in: its
|
|
||||||
// ascent and descent add up to exactly one em. Left to the inherited body style the line box
|
|
||||||
// was 24sp tall around a 17sp-wide mark, so a glyph took a seventh more vertical space than
|
|
||||||
// horizontal.
|
|
||||||
Text(
|
|
||||||
glyph,
|
|
||||||
fontFamily = NerdIcons,
|
|
||||||
fontSize = size,
|
|
||||||
lineHeight = size,
|
|
||||||
color = colour,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import androidx.core.app.NotificationChannelCompat
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.app.ServiceCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import java.io.IOException
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
import kotlin.concurrent.thread
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Telling somebody a session wants them, when they are not looking at the app.
|
|
||||||
*
|
|
||||||
* This is a **foreground service**, which on Android is the only way to keep a connection open
|
|
||||||
* while the app is closed -- there has been no such thing as a long-lived background service since
|
|
||||||
* Android 8. It is what Syncthing does for the same reason. Discord is not a counter-example: it
|
|
||||||
* gets a push from Google's servers, which would mean this backend talking to Google about
|
|
||||||
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
|
|
||||||
*
|
|
||||||
* Every moment it hears about goes to the drawer; [show] decides what else is done with it.
|
|
||||||
*
|
|
||||||
* The cost Android charges is a notification of its own that cannot be dismissed. That is made as
|
|
||||||
* quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
|
|
||||||
* no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
|
|
||||||
* cannot be and because it should not be: it is the honest indicator that something is holding a
|
|
||||||
* connection open.
|
|
||||||
*/
|
|
||||||
class NotificationService : Service() {
|
|
||||||
@Volatile private var stream: HttpURLConnection? = null
|
|
||||||
@Volatile private var stopping = false
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
val settings = loadServerSettings(this)
|
|
||||||
if (settings == null) {
|
|
||||||
// Nothing to connect to. Stopping rather than idling: a service holding no connection
|
|
||||||
// still costs the ongoing notification, which would be announcing work that is not
|
|
||||||
// happening.
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
// Through ServiceCompat so the type is stated once and ignored on the versions that predate
|
|
||||||
// types, rather than branching here.
|
|
||||||
ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType())
|
|
||||||
thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) }
|
|
||||||
// Restarted if Android kills it, which is the whole point: the window this covers is
|
|
||||||
// exactly the one where nobody is watching.
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
stopping = true
|
|
||||||
stream?.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Follows the backend's notification stream, reconnecting until stopped.
|
|
||||||
*
|
|
||||||
* A dropped connection is the ordinary case here rather than an error, so it retries quietly
|
|
||||||
* and forever. Nothing is shown when it cannot connect: a notification saying "I could not tell
|
|
||||||
* you whether anything happened" is noise about a condition nobody can act on, and the session
|
|
||||||
* list already says what is waiting when they next look.
|
|
||||||
*/
|
|
||||||
private fun follow(settings: ServerSettings) {
|
|
||||||
while (!stopping) {
|
|
||||||
try {
|
|
||||||
readStream(settings)
|
|
||||||
} catch (_: IOException) {
|
|
||||||
// Deliberate: see above.
|
|
||||||
}
|
|
||||||
if (stopping) return
|
|
||||||
try {
|
|
||||||
Thread.sleep(RECONNECT_DELAY_MS)
|
|
||||||
} catch (_: InterruptedException) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readStream(settings: ServerSettings) {
|
|
||||||
val connection =
|
|
||||||
URL("${settings.baseUrl}/notifications").openConnection() as HttpURLConnection
|
|
||||||
stream = connection
|
|
||||||
try {
|
|
||||||
connection.applyPinnedTls()
|
|
||||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
|
||||||
// No read timeout, for the reason EventStream gives: between notifications there is
|
|
||||||
// nothing to read, possibly for hours.
|
|
||||||
connection.readTimeout = 0
|
|
||||||
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
|
|
||||||
connection.setRequestProperty("Accept", "text/event-stream")
|
|
||||||
if (connection.responseCode != 200) {
|
|
||||||
throw IOException("HTTP ${connection.responseCode} for the notification stream")
|
|
||||||
}
|
|
||||||
val reader = connection.inputStream.bufferedReader()
|
|
||||||
val data = StringBuilder()
|
|
||||||
while (!stopping) {
|
|
||||||
val line = reader.readLine() ?: break
|
|
||||||
when {
|
|
||||||
line.isEmpty() -> {
|
|
||||||
if (data.isNotEmpty()) show(parseNotification(data.toString()))
|
|
||||||
data.clear()
|
|
||||||
}
|
|
||||||
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
|
|
||||||
else -> {} // comments (keep-alives) and ids: nothing to do
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
connection.disconnect()
|
|
||||||
stream = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One notification per session, replacing that session's previous one.
|
|
||||||
*
|
|
||||||
* Keyed by session id rather than accumulating: two sessions wanting attention are two things
|
|
||||||
* to know about, but one session that finished and then asked a question is one thing -- the
|
|
||||||
* question. A stack of stale rows is how a drawer becomes something to clear rather than read.
|
|
||||||
*/
|
|
||||||
private fun show(notification: SessionNotification) {
|
|
||||||
// Nothing to tell somebody about the session they are reading. The transcript in front of
|
|
||||||
// them is already saying it.
|
|
||||||
if (isOnScreen(notification.sessionId)) return
|
|
||||||
// The app is up, so it says this itself as a banner over whatever screen they are on --
|
|
||||||
// which interrupts, where the drawer's row records: a banner lasts seconds and reaches only
|
|
||||||
// somebody already looking. Both go up, and the banner having done the interrupting is what
|
|
||||||
// makes the row a silent one.
|
|
||||||
val banner = handOver(notification)
|
|
||||||
val manager = NotificationManagerCompat.from(this)
|
|
||||||
// Two different noes, and both are answers rather than faults: the runtime permission
|
|
||||||
// refused, and notifications switched off for the app in Android's own settings.
|
|
||||||
//
|
|
||||||
// The permission only exists from Android 13. Asking an older version about it gets
|
|
||||||
// "denied" for a name it does not know, which read as the person having said no -- so every
|
|
||||||
// notification on Android 12 and below was silently dropped.
|
|
||||||
val allowed =
|
|
||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
|
||||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!allowed || !manager.areNotificationsEnabled()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val open =
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
sessionIntent(this, notification.sessionId),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
val built =
|
|
||||||
NotificationCompat.Builder(this, ALERT_CHANNEL)
|
|
||||||
.setContentTitle(notification.title)
|
|
||||||
.setContentText(attentionLine(notification.kind))
|
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
|
||||||
.setContentIntent(open)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setWhen((notification.at * 1000).toLong())
|
|
||||||
.setShowWhen(true)
|
|
||||||
.setSilent(banner)
|
|
||||||
.build()
|
|
||||||
manager.notify(notification.sessionId, ALERT_ID, built)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The type Android 14+ requires a foreground service to declare, and nothing before it.
|
|
||||||
*
|
|
||||||
* Named behind a version check rather than passed as a constant: the value is inlined at
|
|
||||||
* compile time and would be handed to platforms that have no concept of it, which is what
|
|
||||||
* lint's InlinedApi exists to catch.
|
|
||||||
*/
|
|
||||||
private fun foregroundType(): Int =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ongoingNotification(): Notification =
|
|
||||||
NotificationCompat.Builder(this, ONGOING_CHANNEL)
|
|
||||||
.setContentTitle("Watching for sessions that need you")
|
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Starts the service if there is a server to connect to, and stops it otherwise.
|
|
||||||
*
|
|
||||||
* Called on every launch rather than once: a service Android killed does not restart itself
|
|
||||||
* if the process was replaced, and asking for one that is already running is free.
|
|
||||||
*/
|
|
||||||
fun sync(context: Context) {
|
|
||||||
val intent = Intent(context, NotificationService::class.java)
|
|
||||||
if (loadServerSettings(context) == null) {
|
|
||||||
context.stopService(intent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
createChannels(context)
|
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Two channels, because they are two different things to be told.
|
|
||||||
*
|
|
||||||
* The alerts are what somebody turned this on for, so they get the default importance. The
|
|
||||||
* ongoing one is the platform's tax for staying connected, so it takes the lowest
|
|
||||||
* importance that exists. Both are created before the service starts, since posting to a
|
|
||||||
* channel that does not exist is silently dropped.
|
|
||||||
*/
|
|
||||||
private fun createChannels(context: Context) {
|
|
||||||
val manager = NotificationManagerCompat.from(context)
|
|
||||||
manager.createNotificationChannel(
|
|
||||||
NotificationChannelCompat.Builder(
|
|
||||||
ALERT_CHANNEL,
|
|
||||||
NotificationManagerCompat.IMPORTANCE_DEFAULT,
|
|
||||||
)
|
|
||||||
.setName("Sessions needing attention")
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
manager.createNotificationChannel(
|
|
||||||
NotificationChannelCompat.Builder(
|
|
||||||
ONGOING_CHANNEL,
|
|
||||||
NotificationManagerCompat.IMPORTANCE_MIN,
|
|
||||||
)
|
|
||||||
.setName("Staying connected")
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The session somebody is looking at, or null when no screen is showing one.
|
|
||||||
*
|
|
||||||
* Process-wide state, which the rest of this app does without: Android constructs the
|
|
||||||
* service and the composition draws the screen, so the two have no common owner. Clearing
|
|
||||||
* names the session rather than setting null outright, because moving from one session to
|
|
||||||
* another composes the new screen before the old one's coroutine is cancelled -- an
|
|
||||||
* unconditional clear would throw away the new screen's claim.
|
|
||||||
*/
|
|
||||||
@Volatile private var onScreen: String? = null
|
|
||||||
|
|
||||||
private fun isOnScreen(sessionId: String) = onScreen == sessionId
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The way a notification reaches the app instead of Android's drawer.
|
|
||||||
*
|
|
||||||
* Whether there is an app to reach is the subscriber count rather than a flag of its own:
|
|
||||||
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
|
|
||||||
* nor blocks the thread reading the stream, and the buffer is there so a handful of
|
|
||||||
* sessions finishing together all land rather than the last one winning. Reaching the app
|
|
||||||
* does not stop the drawer's row; it makes it a silent one.
|
|
||||||
*/
|
|
||||||
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
|
|
||||||
|
|
||||||
/** Everything meant for the screen rather than the drawer; see [toApp]. */
|
|
||||||
val forTheScreen: SharedFlow<SessionNotification> = toApp.asSharedFlow()
|
|
||||||
|
|
||||||
private fun handOver(notification: SessionNotification) =
|
|
||||||
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Somebody is looking at [sessionId]; nothing is posted about it until they stop, and
|
|
||||||
* whatever the drawer is already holding about it goes now rather than waiting to be swiped
|
|
||||||
* away. Opening the session *is* reading the notification, whichever way they got here.
|
|
||||||
*/
|
|
||||||
fun showing(context: Context, sessionId: String) {
|
|
||||||
onScreen = sessionId
|
|
||||||
// Whatever was posted about it before is about to be read, so it has nothing left to
|
|
||||||
// say.
|
|
||||||
NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** They have stopped, unless another screen has claimed it since. */
|
|
||||||
fun stoppedShowing(sessionId: String) {
|
|
||||||
if (onScreen == sessionId) onScreen = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val ALERT_CHANNEL = "sessions"
|
|
||||||
private const val ONGOING_CHANNEL = "connection"
|
|
||||||
private const val ONGOING_ID = 1
|
|
||||||
/** Shared by every alert; the session id is the tag that separates them. */
|
|
||||||
private const val ALERT_ID = 2
|
|
||||||
private const val RECONNECT_DELAY_MS = 5_000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The intent that opens one session, and the id it carries back out.
|
|
||||||
*
|
|
||||||
* The two halves are written together so neither can be changed without the other, and the scheme
|
|
||||||
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look
|
|
||||||
* at.
|
|
||||||
*
|
|
||||||
* The id rides in the intent's **data** rather than in an extra, which is not a style choice:
|
|
||||||
* PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring
|
|
||||||
* extras. Carried as an extra, every session's notification would update one shared PendingIntent
|
|
||||||
* and every tap would open whichever session was notified last.
|
|
||||||
*/
|
|
||||||
fun sessionIntent(context: Context, sessionId: String): Intent =
|
|
||||||
Intent(context, MainActivity::class.java)
|
|
||||||
.setAction(Intent.ACTION_VIEW)
|
|
||||||
.setData(
|
|
||||||
// Built rather than concatenated so an id needing escaping survives the round trip;
|
|
||||||
// lastPathSegment below decodes what appendPath encoded.
|
|
||||||
Uri.Builder().scheme("aiapp").authority("session").appendPath(sessionId).build()
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The session [sessionIntent] named, or null for any other URI -- enrollment's included. */
|
|
||||||
fun notifiedSessionId(uri: Uri): String? =
|
|
||||||
if (uri.scheme == "aiapp" && uri.host == "session") uri.lastPathSegment else null
|
|
||||||
|
|
||||||
/** One frame of `GET /notifications`. */
|
|
||||||
data class SessionNotification(
|
|
||||||
val sessionId: String,
|
|
||||||
val title: String,
|
|
||||||
/** The wire's word: "awaitingInput" or "finished". */
|
|
||||||
val kind: String,
|
|
||||||
val at: Double,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a notification asks of the reader, in the words they see.
|
|
||||||
*
|
|
||||||
* What they have to do, not what the session did: "awaitingInput" is the wire's word and says
|
|
||||||
* nothing to somebody reading a lock screen. One function because the same fact is shown in two
|
|
||||||
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift.
|
|
||||||
*/
|
|
||||||
fun attentionLine(kind: String): String =
|
|
||||||
when (kind) {
|
|
||||||
"awaitingInput" -> "Waiting for you"
|
|
||||||
else -> "Finished"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseNotification(json: String): SessionNotification {
|
|
||||||
val body = JSONObject(json)
|
|
||||||
return SessionNotification(
|
|
||||||
sessionId = body.getString("sessionId"),
|
|
||||||
title = body.getString("title"),
|
|
||||||
kind = body.getString("kind"),
|
|
||||||
at = body.optDouble("at", 0.0),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CornerSize
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A message another agent sent this session, closed until somebody asks.
|
|
||||||
*
|
|
||||||
* Closed by default, like a tool call and for the same reason: these are long, there can be several
|
|
||||||
* in a row, and what a reader scanning the transcript needs from one is that it happened and who
|
|
||||||
* sent it. The first line comes with the heading because a name alone does not say which message
|
|
||||||
* this was.
|
|
||||||
*
|
|
||||||
* Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a
|
|
||||||
* transcript that puts it in their voice is making a claim about who asked for the work that
|
|
||||||
* follows.
|
|
||||||
*
|
|
||||||
* Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block,
|
|
||||||
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for what that bought;
|
|
||||||
* what matters here is that the pieces have to add up to the card that was there before, so the
|
|
||||||
* fill, the corner radius and the padding all live in [peerSurface].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun PeerHeadRow(
|
|
||||||
item: TranscriptItem.PeerNote,
|
|
||||||
open: Boolean,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier.cardPiece(
|
|
||||||
top = true,
|
|
||||||
bottom = !open,
|
|
||||||
fill = CardDefaults.cardColors().containerColor,
|
|
||||||
onPress = onToggle,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
|
|
||||||
if (!open) {
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
item.text.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
// The head, not the tail: a message is identified by how it opens.
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One block of an opened peer message, on the same card the heading started.
|
|
||||||
*
|
|
||||||
* Clickable like the heading, so the card still shuts wherever it is pressed -- it was one control
|
|
||||||
* before it was several items, and which piece the finger lands on is not something the reader
|
|
||||||
* chose.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggle: () -> Unit) {
|
|
||||||
Column(
|
|
||||||
Modifier.cardPiece(
|
|
||||||
top = false,
|
|
||||||
bottom = unit.last,
|
|
||||||
fill = CardDefaults.cardColors().containerColor,
|
|
||||||
onPress = onToggle,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
|
|
||||||
// Without this the card closes everywhere except on the text, which is most of it.
|
|
||||||
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
|
|
||||||
// The gap the card's own column used to provide between its heading and its prose, and
|
|
||||||
// between one block and the next -- inside the piece, so the card's fill runs through
|
|
||||||
// it.
|
|
||||||
MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it.
|
|
||||||
*
|
|
||||||
* A filled Material card is elevation zero, so there is no shadow that a seam would show through --
|
|
||||||
* which is the whole reason a card can be cut up at all. Each piece paints the caller's container
|
|
||||||
* colour and rounds only the corners at the ends of the message, so the pieces abut into one
|
|
||||||
* continuous card. Shared by the two rows cut this way -- an opened peer message and a long user
|
|
||||||
* message -- because two copies of the corner logic is how one of them grows a seam.
|
|
||||||
*
|
|
||||||
* The padding is the other half: 12dp all round was the card's own, so the top piece keeps the top
|
|
||||||
* of it, the bottom piece the bottom, and the middle pieces neither.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Modifier.cardPiece(
|
|
||||||
top: Boolean,
|
|
||||||
bottom: Boolean,
|
|
||||||
fill: Color,
|
|
||||||
onPress: (() -> Unit)? = null,
|
|
||||||
): Modifier {
|
|
||||||
val square = CornerSize(0.dp)
|
|
||||||
val shape =
|
|
||||||
MaterialTheme.shapes.medium.copy(
|
|
||||||
topStart = if (top) MaterialTheme.shapes.medium.topStart else square,
|
|
||||||
topEnd = if (top) MaterialTheme.shapes.medium.topEnd else square,
|
|
||||||
bottomStart = if (bottom) MaterialTheme.shapes.medium.bottomStart else square,
|
|
||||||
bottomEnd = if (bottom) MaterialTheme.shapes.medium.bottomEnd else square,
|
|
||||||
)
|
|
||||||
return fillMaxWidth()
|
|
||||||
.clip(shape)
|
|
||||||
.background(fill)
|
|
||||||
.then(if (onPress == null) Modifier else Modifier.clickable(onClick = onPress))
|
|
||||||
.padding(
|
|
||||||
start = CARD_PADDING,
|
|
||||||
end = CARD_PADDING,
|
|
||||||
top = if (top) CARD_PADDING else 0.dp,
|
|
||||||
bottom = if (bottom) CARD_PADDING else 0.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The room inside a sliced card, which was `Card { Column(padding(12.dp)) }`. */
|
|
||||||
private val CARD_PADDING = 12.dp
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
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.draw.clip
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What is about to be sent, directly above the box it will be sent from.
|
|
||||||
*
|
|
||||||
* The count on the "+" button was the whole of what said an image was attached, so the only way to
|
|
||||||
* find out *which* image was to send it. A control belongs with the thing it acts on.
|
|
||||||
*
|
|
||||||
* Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is
|
|
||||||
* in it, so four attachments look like four of the same thing rather than four smaller ones.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun PendingAttachments(
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
refs: List<String>,
|
|
||||||
onRemove: (String) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
if (refs.isEmpty()) return
|
|
||||||
Row(
|
|
||||||
modifier = modifier.horizontalScroll(rememberScrollState()).padding(bottom = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
refs.forEach { ref ->
|
|
||||||
if (isImageRef(ref)) PendingThumbnail(settings, sessionId, ref) { onRemove(ref) }
|
|
||||||
else PendingFile(ref) { onRemove(ref) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One attachment, square, tap to take it back off.
|
|
||||||
*
|
|
||||||
* Removal is here because there is nowhere else it could be: an image picked by mistake could
|
|
||||||
* otherwise only be dealt with by sending it. The whole thumbnail is the target rather than a
|
|
||||||
* corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun PendingThumbnail(
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
ref: String,
|
|
||||||
onRemove: () -> Unit,
|
|
||||||
) {
|
|
||||||
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
|
||||||
val shape = RoundedCornerShape(8.dp)
|
|
||||||
Box(
|
|
||||||
Modifier.size(THUMBNAIL)
|
|
||||||
.clip(shape)
|
|
||||||
// An outline as well as a fill. Most of what gets attached here is a screenshot of a
|
|
||||||
// dark app, and cropped to a square its middle is often near-black -- against this
|
|
||||||
// background the tile then had no edge at all.
|
|
||||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
|
||||||
// Behind the picture as well as under a missing one, so the tile is a tile before
|
|
||||||
// anything has arrived to fill it.
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
.clickable(onClick = onRemove)
|
|
||||||
.semantics { contentDescription = "Attached image, tap to remove" },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
when (val image = bitmap) {
|
|
||||||
// The two are told apart for the same reason the transcript's images are: one of them
|
|
||||||
// is worth waiting for and the other never resolves.
|
|
||||||
null ->
|
|
||||||
if (failed) {
|
|
||||||
Text(
|
|
||||||
"!",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// A spinner, as the transcript's images have: one appearance for "a picture is
|
|
||||||
// on its way", learned once. An ellipsis had to be read as a spinner not
|
|
||||||
// moving.
|
|
||||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
|
||||||
}
|
|
||||||
else ->
|
|
||||||
Image(
|
|
||||||
bitmap = image,
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier.size(THUMBNAIL),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// The whole square removes it, and this only says so. A cross small enough to sit in the
|
|
||||||
// corner of a 64dp thumbnail is smaller than a fingertip.
|
|
||||||
//
|
|
||||||
// The disc is sized here and the mark centred inside it, rather than the glyph being
|
|
||||||
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box to
|
|
||||||
// the corner hung the visible mark over the edge.
|
|
||||||
Box(
|
|
||||||
Modifier.align(Alignment.TopEnd)
|
|
||||||
.padding(2.dp)
|
|
||||||
.size(20.dp)
|
|
||||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f), CircleShape),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Glyph(CLOSE_GLYPH, colour = MaterialTheme.colorScheme.onSurface, size = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One attached file: its name, tap to take it back off. The same height and removal as a thumbnail,
|
|
||||||
* so a row of mixed attachments is one row; the cross sits after the name because a tile this wide
|
|
||||||
* has no corner the eye goes to.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun PendingFile(ref: String, onRemove: () -> Unit) {
|
|
||||||
val name = attachmentName(ref)
|
|
||||||
val shape = RoundedCornerShape(8.dp)
|
|
||||||
Row(
|
|
||||||
Modifier.height(THUMBNAIL)
|
|
||||||
.clip(shape)
|
|
||||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
.clickable(onClick = onRemove)
|
|
||||||
.semantics { contentDescription = "Attached file $name, tap to remove" }
|
|
||||||
.padding(horizontal = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
FileName(name, Modifier.widthIn(max = FILE_TILE_WIDTH))
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Glyph(CLOSE_GLYPH, colour = MaterialTheme.colorScheme.onSurface, size = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val THUMBNAIL = 64.dp
|
|
||||||
|
|
||||||
/** Wide enough for most names whole; longer ones lose their middle, keeping both ends. */
|
|
||||||
private val FILE_TILE_WIDTH = 200.dp
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import com.example.wgapplink.PinnedTls
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
|
|
||||||
// PINNED_CA_PEM is generated at build time from the CA on the machine doing the build -- see the
|
|
||||||
// generatePinnedCert task in build.gradle.kts. It is deliberately not a checked-in constant: the
|
|
||||||
// private key that signs against it must never be anywhere this repo is, and an APK should pin
|
|
||||||
// whatever CA the backend it was built for actually serves.
|
|
||||||
//
|
|
||||||
// The pinning itself lives in wg-app-link, since dev-updater needs exactly the same thing. What
|
|
||||||
// stays here is which certificate this app pins.
|
|
||||||
private val pinned = PinnedTls(PINNED_CA_PEM)
|
|
||||||
|
|
||||||
/** Every request this app makes goes through this -- there is no unpinned path. */
|
|
||||||
fun HttpURLConnection.applyPinnedTls() = pinned.applyTo(this)
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.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") }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
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,49 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verbatim text, on the surface that says so: a command about to be run, what a tool printed.
|
|
||||||
*
|
|
||||||
* A composable rather than a modifier repeated at each site, because the inset is part of it --
|
|
||||||
* monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three
|
|
||||||
* copies of "clip, fill, pad" drift apart the first time one is adjusted.
|
|
||||||
*
|
|
||||||
* **Nothing in here wraps; it scrolls sideways instead.** This is column-aligned far more often
|
|
||||||
* than it is prose -- a diff, a table, a test run, a command and its arguments -- and wrapping
|
|
||||||
* destroys exactly the alignment that was carrying the meaning, while turning one line into four
|
|
||||||
* and a run of them into a wall. The scroll belongs to the block rather than to each line so that
|
|
||||||
* the lines stay aligned with each other as it moves: one offset for the whole column is what makes
|
|
||||||
* a shifted diff still read as a diff. Every [Text] inside is therefore drawn with `softWrap =
|
|
||||||
* false`, which is the half of this a caller has to remember.
|
|
||||||
*
|
|
||||||
* The colour is [rawSurface], which is also what a code block inside a reply is given.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
|
||||||
Column(
|
|
||||||
modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a rounded
|
|
||||||
// rectangle drawn at the same radius as the one behind it reads as a misprint.
|
|
||||||
.clip(MaterialTheme.shapes.extraSmall)
|
|
||||||
.background(rawSurface)
|
|
||||||
// Clipped and filled before this, so the tint is the viewport and does not scroll away
|
|
||||||
// from under the text; padded after it, so the inset travels with the content and the
|
|
||||||
// last column does not end flush against the edge.
|
|
||||||
.horizontalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
|
|
||||||
// How long is left in a usage window. Shared by the session bar and the usage screen: the
|
|
||||||
// arithmetic is the same in both, so everything here returns the span or the state on its own and
|
|
||||||
// leaves the wording to the caller.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words.
|
|
||||||
*
|
|
||||||
* Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s
|
|
||||||
* left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the
|
|
||||||
* figure on a minute it has already spent. One rule, so the session bar and the usage dialog cannot
|
|
||||||
* round a shared measurement two different ways.
|
|
||||||
*/
|
|
||||||
fun formatSpan(until: Duration): String {
|
|
||||||
val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1)
|
|
||||||
return when {
|
|
||||||
up.toHours() >= 24 -> "${up.toDays()}d ${up.toHours() % 24}h"
|
|
||||||
up.toHours() > 0 -> "${up.toHours()}h ${up.toMinutes() % 60}m"
|
|
||||||
else -> "${up.toMinutes()}m"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What is known about when a usage window ends.
|
|
||||||
*
|
|
||||||
* Three answers rather than a nullable duration, because two of them shared `null` and they are not
|
|
||||||
* the same thing at all. A window the server sent no reset time for is one that is **not running**:
|
|
||||||
* the five-hour window is anchored to the block it started in, so between sessions there is nothing
|
|
||||||
* counting down and the API says so by omitting the field. A timestamp that did arrive and could
|
|
||||||
* not be read is the genuinely unknown case.
|
|
||||||
*
|
|
||||||
* Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on
|
|
||||||
* the one row somebody reads before starting something big -- and the usage dialog, looking at the
|
|
||||||
* same field, quietly drew nothing.
|
|
||||||
*/
|
|
||||||
sealed class WindowEnd {
|
|
||||||
/** No reset time was sent, so nothing is running in this window. Not a failure to find out. */
|
|
||||||
data object NotRunning : WindowEnd()
|
|
||||||
|
|
||||||
/** A timestamp arrived and could not be read. The one case that is actually unknown. */
|
|
||||||
data object Unreadable : WindowEnd()
|
|
||||||
|
|
||||||
/** How long is left. Negative once the window is past, which each caller words for itself. */
|
|
||||||
data class Ends(val until: Duration) : WindowEnd()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [resetsAt] as the server sent it -- absent, unreadable, or a moment -- against [now].
|
|
||||||
*
|
|
||||||
* [now] is a parameter rather than read here so a caller can drive it from state and have the
|
|
||||||
* countdown recompute on its own schedule.
|
|
||||||
*/
|
|
||||||
fun windowEnd(resetsAt: String?, now: OffsetDateTime): WindowEnd {
|
|
||||||
if (resetsAt == null) return WindowEnd.NotRunning
|
|
||||||
return try {
|
|
||||||
WindowEnd.Ends(Duration.between(now, OffsetDateTime.parse(resetsAt)))
|
|
||||||
} catch (_: Exception) {
|
|
||||||
WindowEnd.Unreadable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.core.content.edit
|
|
||||||
|
|
||||||
private const val ANCHORS = "session-scroll"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a session's transcript was left, so reopening it lands where reading stopped.
|
|
||||||
*
|
|
||||||
* Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by
|
|
||||||
* the row key the list draws with. An index means nothing across a reopen, since the transcript is
|
|
||||||
* fetched newest-first. The row key looks stable and is not: a tool row is named after its run,
|
|
||||||
* `joinPages` gives a run the name of its newest half, and the newest half is whatever the newest
|
|
||||||
* page started with -- so an active session renames its tool runs every time it is reopened. A seq
|
|
||||||
* is the server's own numbering, assigned once and never moved.
|
|
||||||
*
|
|
||||||
* [unit] is which unit of the row the viewport started at and [offset] how far that unit was
|
|
||||||
* scrolled past the viewport's newest edge. A seq alone is not a place: a reply is one seq and can
|
|
||||||
* be forty blocks long.
|
|
||||||
*/
|
|
||||||
data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On this device rather than on the backend, which is where this app otherwise keeps state so every
|
|
||||||
* device sees it. Scroll position is the same exception a draft is: it is where the phone in
|
|
||||||
* somebody's hand is pointed.
|
|
||||||
*/
|
|
||||||
fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
|
|
||||||
val stored =
|
|
||||||
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).getString(sessionId, null)
|
|
||||||
?: return null
|
|
||||||
val fields = stored.split(':')
|
|
||||||
val seq = fields.getOrNull(0)?.toLongOrNull() ?: return null
|
|
||||||
val offset = fields.getOrNull(1)?.toIntOrNull() ?: return null
|
|
||||||
// Positions saved before the unit was recorded name the row's oldest unit, which is the closest
|
|
||||||
// older place -- the same choice [unitIndexFor] makes when a unit is gone.
|
|
||||||
return ScrollAnchor(seq, offset, fields.getOrNull(2)?.toIntOrNull() ?: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records where [sessionId] is being read, or forgets it when [anchor] is null.
|
|
||||||
*
|
|
||||||
* The path out is reading to the newest end, which is what the caller passes null for: a session
|
|
||||||
* left at the bottom has nothing to restore. A session *deleted* while it held an anchor leaves its
|
|
||||||
* key behind, for the reason and at the cost `Drafts.kt` describes.
|
|
||||||
*/
|
|
||||||
fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) {
|
|
||||||
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit {
|
|
||||||
if (anchor == null) remove(sessionId)
|
|
||||||
else putString(sessionId, "${anchor.seq}:${anchor.offset}:${anchor.unit}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import com.example.wgapplink.ServerStore
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the backend is and how to authenticate to it. Absent until the phone is enrolled -- by
|
|
||||||
* scanning the server's terminal QR (an `aiapp://enroll` URI the camera app hands to MainActivity)
|
|
||||||
* or by typing the fields into the settings screen.
|
|
||||||
*/
|
|
||||||
typealias ServerSettings = com.example.wgapplink.ServerSettings
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This app's enrollment, which is the whole of what is product-specific about it.
|
|
||||||
*
|
|
||||||
* Both values are load-bearing. The scheme is what routes a scanned QR here rather than to Dev
|
|
||||||
* Updater, and the key alias names the Android Keystore key the token is already sealed under on
|
|
||||||
* every enrolled phone -- changing it would leave those phones reading as not enrolled.
|
|
||||||
*/
|
|
||||||
private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key")
|
|
||||||
|
|
||||||
fun loadServerSettings(context: Context): ServerSettings? = store.load(context)
|
|
||||||
|
|
||||||
fun saveServerSettings(context: Context, settings: ServerSettings) = store.save(context, settings)
|
|
||||||
|
|
||||||
fun parseEnrollmentUri(uri: Uri): ServerSettings? = store.parseEnrollmentUri(uri)
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.SwipeToDismissBox
|
|
||||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.key
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A session wanting attention, said over the app as well as in Android's drawer.
|
|
||||||
*
|
|
||||||
* Two places carry the same fact and they are doing different jobs: a row in the shade waits
|
|
||||||
* however long it has to, which makes it the record, and a banner is read now or not at all, which
|
|
||||||
* makes it the interruption. So somebody with the app open gets both -- this, and a silent row
|
|
||||||
* behind it that is still there when they go looking and goes by itself when they open the session.
|
|
||||||
* Whether the app is open at all is this collection and nothing else.
|
|
||||||
*
|
|
||||||
* A banner can go three ways, each somebody deciding something different: tapped, which opens the
|
|
||||||
* session; pushed off either side; or left alone, in which case it goes when the bar runs out.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) {
|
|
||||||
val queue = remember { mutableStateListOf<SessionAlert>() }
|
|
||||||
// What tells two notifications about one session apart, and what a replaced banner gets a new
|
|
||||||
// one of so its timer starts again rather than inheriting the remains of the last one's.
|
|
||||||
var arrivals by remember { mutableIntStateOf(0) }
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
|
||||||
LaunchedEffect(lifecycleOwner) {
|
|
||||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
|
||||||
try {
|
|
||||||
NotificationService.forTheScreen.collect { notification ->
|
|
||||||
arrivals++
|
|
||||||
val alert = SessionAlert(notification, arrivals)
|
|
||||||
// One banner per session, replacing that session's own -- the same rule the
|
|
||||||
// drawer follows: a session that finished and then asked a question is one
|
|
||||||
// thing to know about, the question. It keeps its place in the queue rather
|
|
||||||
// than moving to the end, because the reader may already be reaching for it.
|
|
||||||
val already = queue.indexOfFirst {
|
|
||||||
it.notification.sessionId == notification.sessionId
|
|
||||||
}
|
|
||||||
if (already >= 0) queue[already] = alert else queue.add(alert)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Leaving the app hands the job back to the drawer, so nothing arriving while it is
|
|
||||||
// away is lost. What would be lost is the truth of what is already up: these say a
|
|
||||||
// session wants somebody *now*, and one still sitting here on a return several
|
|
||||||
// minutes later is a claim nobody checked. Frozen, too -- Compose stops the clock
|
|
||||||
// with the window.
|
|
||||||
queue.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Oldest at the top, so a new one appears below the ones already being read instead of shoving
|
|
||||||
// them down the screen mid-reach.
|
|
||||||
Column(modifier.fillMaxWidth().padding(8.dp)) {
|
|
||||||
queue.forEach { alert ->
|
|
||||||
key(alert.arrival) {
|
|
||||||
AlertBanner(
|
|
||||||
alert = alert,
|
|
||||||
onOpen = {
|
|
||||||
queue.remove(alert)
|
|
||||||
onOpen(SessionOpenRequest(alert.notification.sessionId, alert.arrival))
|
|
||||||
},
|
|
||||||
onGone = { queue.remove(alert) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One notification queued for the screen, with the arrival that tells it from its predecessor. */
|
|
||||||
private data class SessionAlert(val notification: SessionNotification, val arrival: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One banner: what wants attention, and how long this has left to say so.
|
|
||||||
*
|
|
||||||
* The bar and the going away are one value rather than a bar beside a timer, because two of them
|
|
||||||
* would be two accounts of the same countdown and only one can be the one that fires.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) {
|
|
||||||
val swipe = rememberSwipeToDismissBoxState()
|
|
||||||
val life = remember { Animatable(1f) }
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
life.animateTo(0f, animationSpec = tween(ALERT_LIFE_MS, easing = LinearEasing))
|
|
||||||
onGone()
|
|
||||||
}
|
|
||||||
// Settled is "still where it started"; anything else is a push that carried far enough for the
|
|
||||||
// gesture to commit, which the platform decides rather than this screen.
|
|
||||||
LaunchedEffect(swipe.currentValue) {
|
|
||||||
if (swipe.currentValue != SwipeToDismissBoxValue.Settled) onGone()
|
|
||||||
}
|
|
||||||
SwipeToDismissBox(
|
|
||||||
state = swipe,
|
|
||||||
// Nothing behind it. Pushing one of these away means the same thing whichever way it went,
|
|
||||||
// so a coloured ground with an icon would be drawing a distinction that isn't there.
|
|
||||||
backgroundContent = {},
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
onClick = onOpen,
|
|
||||||
colors =
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
),
|
|
||||||
// Outlined, because the step it needs to make is not one this palette can make with a
|
|
||||||
// tint: the card under a banner on the session list is the same surface, so a banner
|
|
||||||
// relying on colour alone reads as one more row in the way. The border is the one cue.
|
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 10.dp)) {
|
|
||||||
Text(
|
|
||||||
alert.notification.title,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
// One line, cut at the tail: a session is identified by the start of its name,
|
|
||||||
// and a banner that grew with the name would move the one below it.
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
attentionLine(alert.notification.kind),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
// The list's own colour for a session waiting on a person, so the banner and
|
|
||||||
// the row behind it are saying one thing rather than two.
|
|
||||||
color =
|
|
||||||
if (alert.notification.kind == "awaitingInput") awaitingColor
|
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { life.value },
|
|
||||||
// Blue because it is reporting how much of something is left rather than passing
|
|
||||||
// judgement on it. Stated beside the track, which is the card's own colour so that
|
|
||||||
// the spent part reads as empty rather than as a second bar.
|
|
||||||
color = progressColor,
|
|
||||||
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
drawStopIndicator = {},
|
|
||||||
gapSize = 0.dp,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long a banner stays if nobody touches it.
|
|
||||||
*
|
|
||||||
* Long enough to read a session name and a line, short enough that a stack of them clears itself
|
|
||||||
* while somebody is still on the screen that produced them. The bar makes the number visible.
|
|
||||||
*/
|
|
||||||
private const val ALERT_LIFE_MS = 6_000
|
|
||||||
@@ -1,470 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.FilterQuality
|
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.isSpecified
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.compose.ui.window.DialogProperties
|
|
||||||
import androidx.compose.ui.window.DialogWindowProvider
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One image from the session's files route: the bitmap once it arrives, and whether it never will.
|
|
||||||
*
|
|
||||||
* [failed] exists because the two empty states differ in kind -- still coming and never coming --
|
|
||||||
* and a reader can act on the second; each caller supplies its own words for them.
|
|
||||||
*/
|
|
||||||
data class SessionBitmap(val bitmap: ImageBitmap?, val failed: Boolean)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling
|
|
||||||
* does not refetch. Shared by the transcript's images and the composer's pending attachments,
|
|
||||||
* because the fetch, the decode and the two-state answer are one block of logic that had been
|
|
||||||
* written twice.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap {
|
|
||||||
var state by remember(ref) { mutableStateOf(SessionBitmap(null, failed = false)) }
|
|
||||||
LaunchedEffect(ref) {
|
|
||||||
state =
|
|
||||||
try {
|
|
||||||
val bytes =
|
|
||||||
withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) }
|
|
||||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
|
||||||
SessionBitmap(decoded, failed = decoded == null)
|
|
||||||
} catch (_: ApiException) {
|
|
||||||
SessionBitmap(null, failed = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An image in the transcript: a fixed-height thumbnail that opens full screen.
|
|
||||||
*
|
|
||||||
* The height is decided before the bytes arrive and never changes. An image row that grew when it
|
|
||||||
* finished loading pushed everything below it, so a transcript being read scrolled itself -- and in
|
|
||||||
* a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes.
|
|
||||||
*
|
|
||||||
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
|
|
||||||
* than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the
|
|
||||||
* screen, which draws [SessionImageViewer] outside the list.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SessionImage(
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
ref: String,
|
|
||||||
onOpen: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
|
||||||
val height = thumbnailHeight()
|
|
||||||
val heightPx = with(LocalDensity.current) { height.roundToPx() }
|
|
||||||
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
|
||||||
when (val image = bitmap) {
|
|
||||||
// Two states, not one: an image still arriving and an image that will never arrive look
|
|
||||||
// nothing alike to a reader who can do something about the second.
|
|
||||||
null ->
|
|
||||||
if (failed) {
|
|
||||||
Text(
|
|
||||||
"[image $ref unavailable]",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LoadingImage(height)
|
|
||||||
}
|
|
||||||
else ->
|
|
||||||
Image(
|
|
||||||
bitmap = image,
|
|
||||||
contentDescription = "Attached image, tap to view full screen",
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
filterQuality = enlargingFilter(image.height, heightPx),
|
|
||||||
modifier = Modifier.fillMaxSize().clickable { onOpen(ref) },
|
|
||||||
alignment = Alignment.CenterStart,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The image somebody opened, drawn by the screen rather than by the row it was tapped in.
|
|
||||||
*
|
|
||||||
* The row is the wrong place to hold this, and it took a real fault to see why: an image from a
|
|
||||||
* `Read` on its own is a row of one call, and the moment the next call arrives the two become a
|
|
||||||
* group -- a different composable in a different part of the tree, so everything the old subtree
|
|
||||||
* remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the
|
|
||||||
* transcript because the session made another tool call.
|
|
||||||
*
|
|
||||||
* Held by the screen, none of that reaches it: what is open is a property of the screen.
|
|
||||||
*
|
|
||||||
* The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go
|
|
||||||
* through. Paid deliberately: it is one request for a picture somebody asked to see.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SessionImageViewer(
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
ref: String,
|
|
||||||
onClose: () -> Unit,
|
|
||||||
) {
|
|
||||||
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
|
||||||
val view = LocalView.current
|
|
||||||
var hiddenBars by remember(ref) { mutableStateOf(ViewerBars()) }
|
|
||||||
var barInsets by remember(ref) { mutableStateOf(ViewerBarInsets()) }
|
|
||||||
Dialog(
|
|
||||||
onDismissRequest = onClose,
|
|
||||||
properties =
|
|
||||||
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
|
||||||
) {
|
|
||||||
ViewerSystemBars(hiddenBars)
|
|
||||||
Box(
|
|
||||||
Modifier.fillMaxSize()
|
|
||||||
.background(Color.Black)
|
|
||||||
.clickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = null,
|
|
||||||
onClick = onClose,
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
when (val image = bitmap) {
|
|
||||||
// Two states, not one, exactly as the thumbnail has them. Stated in white because
|
|
||||||
// this box paints its own black behind them and a theme colour would be picked
|
|
||||||
// against a surface that is not there.
|
|
||||||
null ->
|
|
||||||
if (failed) {
|
|
||||||
Text(
|
|
||||||
"Image $ref is unavailable",
|
|
||||||
color = Color.White,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// The whole dialog is the area this picture is about to fill, so the
|
|
||||||
// spinner sits in the middle of it. White for the same reason the words
|
|
||||||
// beside it are.
|
|
||||||
CircularProgressIndicator(color = Color.White)
|
|
||||||
}
|
|
||||||
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%")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The room a picture is about to take, with a spinner in the middle of it.
|
|
||||||
*
|
|
||||||
* A square of the row's own height rather than the full width of the transcript: the height is what
|
|
||||||
* [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width
|
|
||||||
* placeholder would promise a picture wider than most turn out to be.
|
|
||||||
*
|
|
||||||
* Tinted, so the reader can see that something is being kept for a picture -- which is also what
|
|
||||||
* distinguishes it from the failure beside it, words on the ordinary surface.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun LoadingImage(height: Dp) {
|
|
||||||
Box(
|
|
||||||
Modifier.size(height)
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(Modifier.size(LOADING_SPINNER), strokeWidth = 2.dp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Small enough to sit inside the thumbnail's square without filling it. */
|
|
||||||
private val LOADING_SPINNER = 24.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Four lines of the body style the transcript is set in.
|
|
||||||
*
|
|
||||||
* Measured from the type rather than written as a dp, so it stays four lines when the text size
|
|
||||||
* changes -- including when the reader has scaled fonts up, which is when a hardcoded height is
|
|
||||||
* wrong.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun thumbnailHeight(): Dp {
|
|
||||||
val line = MaterialTheme.typography.bodyLarge.lineHeight
|
|
||||||
val density = LocalDensity.current
|
|
||||||
return remember(line, density) {
|
|
||||||
with(density) { if (line.isSpecified) (line * 4).toDp() else 96.dp }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nearest neighbour when the image is being enlarged, smooth when it is being shrunk.
|
|
||||||
*
|
|
||||||
* A small image blown up with interpolation turns into a blur that hides what it is -- the same
|
|
||||||
* image with hard pixel edges stays readable. Shrinking wants the opposite.
|
|
||||||
*/
|
|
||||||
private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality =
|
|
||||||
if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The image on its own, as large as it fits, with pinch to zoom.
|
|
||||||
*
|
|
||||||
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
|
|
||||||
* gesture returns to the transcript instead of leaving the app. It opens fitted, with the whole
|
|
||||||
* image visible without enlarging a smaller one; the 100% control changes to one bitmap pixel per
|
|
||||||
* screen pixel and recenters it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ZoomableImage(
|
|
||||||
image: ImageBitmap,
|
|
||||||
nativeSizeRequest: Int,
|
|
||||||
onViewportChanged: (IntSize) -> Unit,
|
|
||||||
onBarsChanged: (ViewerBars) -> Unit,
|
|
||||||
barInsets: ViewerBarInsets,
|
|
||||||
viewport: IntSize,
|
|
||||||
) {
|
|
||||||
var scale by remember { mutableFloatStateOf(1f) }
|
|
||||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
|
||||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
|
||||||
val nativeScale = nativeScale(image.width, image.height, viewport.width, viewport.height)
|
|
||||||
LaunchedEffect(nativeSizeRequest, nativeScale) {
|
|
||||||
if (nativeSizeRequest > 0) {
|
|
||||||
scale = nativeScale
|
|
||||||
offsetX = 0f
|
|
||||||
offsetY = 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val bars =
|
|
||||||
viewerBars(
|
|
||||||
image.width,
|
|
||||||
image.height,
|
|
||||||
viewport.width,
|
|
||||||
viewport.height,
|
|
||||||
scale,
|
|
||||||
Offset(offsetX, offsetY),
|
|
||||||
barInsets,
|
|
||||||
)
|
|
||||||
SideEffect { onBarsChanged(bars) }
|
|
||||||
Image(
|
|
||||||
bitmap = image,
|
|
||||||
contentDescription = "Attached image",
|
|
||||||
contentScale = ContentScale.Inside,
|
|
||||||
// Zoomed in, the reader is looking at pixels on purpose.
|
|
||||||
filterQuality = FilterQuality.None,
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxSize()
|
|
||||||
.onSizeChanged(onViewportChanged)
|
|
||||||
.pointerInput(nativeScale) {
|
|
||||||
detectTransformGestures { centroid, pan, zoom, _ ->
|
|
||||||
val oldScale = scale
|
|
||||||
val maximumScale = maxOf(8f, nativeScale)
|
|
||||||
val newScale = (oldScale * zoom).coerceIn(1f, maximumScale)
|
|
||||||
if (newScale > 1f) {
|
|
||||||
val offset =
|
|
||||||
zoomOffset(
|
|
||||||
Offset(offsetX, offsetY),
|
|
||||||
centroid,
|
|
||||||
pan,
|
|
||||||
oldScale,
|
|
||||||
newScale,
|
|
||||||
Offset(size.width / 2f, size.height / 2f),
|
|
||||||
)
|
|
||||||
offsetX = offset.x
|
|
||||||
offsetY = offset.y
|
|
||||||
} else {
|
|
||||||
offsetX = 0f
|
|
||||||
offsetY = 0f
|
|
||||||
}
|
|
||||||
scale = newScale
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = scale
|
|
||||||
scaleY = scale
|
|
||||||
translationX = offsetX
|
|
||||||
translationY = offsetY
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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,641 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
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.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.FloatingActionButton
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
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.draw.alpha
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The sessions tab: every session, in the order the reader has put them in.
|
|
||||||
*
|
|
||||||
* Nothing here sorts. The order is the server's `sessions` list and the reader's own -- see
|
|
||||||
* [reorderSessions] -- which is the one arrangement a row cannot be moved out of by something the
|
|
||||||
* session does. It replaced sorting by activity, and then sorting by when each agent was turned on:
|
|
||||||
* both meant a list that rearranged itself under whoever was reading it, and the status word and
|
|
||||||
* its colour already say which session wants something without the row having to move to say it.
|
|
||||||
*
|
|
||||||
* Holding a row puts the screen in selection mode, the same gesture and the same bottom bar as the
|
|
||||||
* import tab, so the two lists are learned once. Rearranging is deliberately *not* part of a
|
|
||||||
* selection -- the handle moves the row it is on, whether or not that row is picked out -- because
|
|
||||||
* "which rows am I acting on" and "where does this one go" are two questions.
|
|
||||||
*
|
|
||||||
* No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one.
|
|
||||||
* What stays here is the button that adds a session, because that acts on this list and nothing
|
|
||||||
* else.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SessionListScreen(
|
|
||||||
settings: ServerSettings,
|
|
||||||
reloadToken: Int,
|
|
||||||
onOpen: (SessionSummary) -> Unit,
|
|
||||||
onSpawn: () -> Unit,
|
|
||||||
/** A session this list has just deleted, for whoever is showing it elsewhere. */
|
|
||||||
onDeleted: (String) -> Unit = {},
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
|
||||||
|
|
||||||
// Which rows the reader has picked out. Empty means selection mode is off, as on the import
|
|
||||||
// tab: a selection mode with nothing in it has no controls and no way out but Back.
|
|
||||||
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
|
|
||||||
|
|
||||||
// The sessions a delete has been confirmed for, or none. A list rather than one session,
|
|
||||||
// because a selection is what the bar below acts on.
|
|
||||||
var confirmingDelete by remember { mutableStateOf<List<SessionSummary>>(emptyList()) }
|
|
||||||
|
|
||||||
// Whether an answer is outstanding, which is a different question from whether there is
|
|
||||||
// anything to draw: see [refresh].
|
|
||||||
var reloading by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
|
|
||||||
// own card. The two scopes are decided by whether the server answered: it answered and refused,
|
|
||||||
// so this says nothing about the other rows.
|
|
||||||
//
|
|
||||||
// Cleared on the next successful load below -- an entry outlives its session otherwise.
|
|
||||||
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
|
||||||
|
|
||||||
// Why the order on screen is not the order that was saved, when saving one failed. The list is
|
|
||||||
// what failed, so it is reported over the list rather than on any row.
|
|
||||||
var orderError by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, because
|
|
||||||
// the rows are rebuilt from whatever the server last said and this belongs to the request.
|
|
||||||
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
|
|
||||||
|
|
||||||
// This phone's copies of these sessions' transcripts, pruned from here because this is where a
|
|
||||||
// session stops existing. See TranscriptCache.
|
|
||||||
val context = LocalContext.current
|
|
||||||
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
|
||||||
|
|
||||||
fun refresh() {
|
|
||||||
// The rows stay while the answer is on its way, with the bar below saying one is: this
|
|
||||||
// list is asked again every time the panel over a session is opened, and blanking it each
|
|
||||||
// time hands the reader an empty screen to report on something that was never in doubt.
|
|
||||||
// A first load has nothing to keep, and says so with the spinner instead.
|
|
||||||
if (listState !is LoadState.Loaded) listState = LoadState.Loading
|
|
||||||
reloading = true
|
|
||||||
scope.launch {
|
|
||||||
listState =
|
|
||||||
try {
|
|
||||||
val loaded =
|
|
||||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
|
|
||||||
deleteErrors = emptyMap()
|
|
||||||
val alive = loaded.value.map { it.id }.toSet()
|
|
||||||
// A selection is of sessions, so one deleted somewhere else leaves it. Only
|
|
||||||
// that one: the other rows the reader picked out are still there.
|
|
||||||
selected = selected.intersect(alive)
|
|
||||||
// The path out for a cached transcript whose session was deleted somewhere
|
|
||||||
// else. This list is the only place that ever learns the full set. On the
|
|
||||||
// answer rather than in `finally`: a list that failed to arrive says nothing
|
|
||||||
// about which sessions exist.
|
|
||||||
withContext(Dispatchers.IO) { transcriptCache.retainOnly(alive) }
|
|
||||||
loaded
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
reloading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes every session in [targets], one after another.
|
|
||||||
*
|
|
||||||
* One at a time and in the order they are drawn: the server has no batch delete for sessions,
|
|
||||||
* and each one ends a process. Each row says what is happening to it from the moment the work
|
|
||||||
* is handed over, which is also when the selection goes -- a bar still naming sessions being
|
|
||||||
* deleted is a set nobody can act on.
|
|
||||||
*/
|
|
||||||
fun deleteChosen(targets: List<SessionSummary>, alsoDeleteForeign: Boolean) {
|
|
||||||
selected = emptySet()
|
|
||||||
// Marked here rather than after the request returns: a row has to say something is
|
|
||||||
// happening to it from the moment it is asked for.
|
|
||||||
deleting = deleting + targets.map { it.id }
|
|
||||||
deleteErrors = deleteErrors - targets.map { it.id }.toSet()
|
|
||||||
scope.launch {
|
|
||||||
for (session in targets) {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
deleteSession(settings, session.id, alsoDeleteForeign)
|
|
||||||
// After it succeeded, not before: a refused delete leaves the session
|
|
||||||
// exactly as it was, and its transcript with it.
|
|
||||||
transcriptCache.session(TranscriptAddress(session.id)).purge()
|
|
||||||
}
|
|
||||||
// Only this row, and only what changed. Refetching the list instead put every
|
|
||||||
// other session back through loading and handed the reader an empty screen, to
|
|
||||||
// report on something never in doubt.
|
|
||||||
val loaded = listState
|
|
||||||
if (loaded is LoadState.Loaded) {
|
|
||||||
listState = LoadState.Loaded(loaded.value.filterNot { it.id == session.id })
|
|
||||||
}
|
|
||||||
onDeleted(session.id)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// Kept, because it is still there: the server refused, so the session it
|
|
||||||
// refused about is exactly as it was.
|
|
||||||
deleteErrors = deleteErrors + (session.id to (e.message ?: "Delete failed"))
|
|
||||||
} finally {
|
|
||||||
deleting = deleting - session.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(reloadToken) { refresh() }
|
|
||||||
|
|
||||||
val rows = rememberLazyListState()
|
|
||||||
val reorder =
|
|
||||||
rememberReorder(
|
|
||||||
listState = rows,
|
|
||||||
onMove = { from, to ->
|
|
||||||
// Moved here and now, because the row is under a finger: waiting for the server to
|
|
||||||
// agree would drag the handle away from the card it is on. What the server thinks
|
|
||||||
// is asked for when the finger comes up, and a refusal puts the list back.
|
|
||||||
val loaded = listState
|
|
||||||
if (loaded is LoadState.Loaded) {
|
|
||||||
val moved = loaded.value.toMutableList()
|
|
||||||
moved.add(to, moved.removeAt(from))
|
|
||||||
listState = LoadState.Loaded(moved)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSettled = {
|
|
||||||
val loaded = listState
|
|
||||||
if (loaded is LoadState.Loaded) {
|
|
||||||
val order = loaded.value.map { it.id }
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { reorderSessions(settings, order) }
|
|
||||||
orderError = null
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
orderError = e.message ?: "The new order couldn't be saved"
|
|
||||||
// The screen must not go on showing an arrangement nothing kept, so
|
|
||||||
// the server's own order comes back -- which is also the only way to
|
|
||||||
// see what it does think.
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Back leaves selection mode rather than the tab, which is the level it is one step above.
|
|
||||||
// Nested inside MainScreen's own handler, so it wins while there is a selection.
|
|
||||||
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
|
|
||||||
|
|
||||||
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
|
|
||||||
// can still be scrolled to while it is up.
|
|
||||||
var barHeight by remember { mutableStateOf(0.dp) }
|
|
||||||
val density = LocalDensity.current
|
|
||||||
// What the bar covers *now*: its measurement is kept while it is away, but nothing is
|
|
||||||
// reserved for a bar that is not up.
|
|
||||||
val covered = if (selected.isEmpty()) 0.dp else barHeight
|
|
||||||
|
|
||||||
// The spawn button floats over the list, so the list ends above it -- measured, for the
|
|
||||||
// reason the bar is. Without this the last row sat under the button, which was survivable
|
|
||||||
// while every part of a row did the same thing and is not now that corner is a handle.
|
|
||||||
var buttonHeight by remember { mutableStateOf(0.dp) }
|
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
|
||||||
orderError?.let { message ->
|
|
||||||
// The server's own words, unprefixed, the way every other failure is shown.
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
when (val state = listState) {
|
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
|
||||||
// The message as Api.kt wrote it, with nothing added: it is already a whole
|
|
||||||
// sentence naming the address and what to check, so a prefix here read "Couldn't
|
|
||||||
// reach the server: Couldn't reach the server at ...".
|
|
||||||
is LoadState.Error ->
|
|
||||||
Text(
|
|
||||||
state.message,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
is LoadState.Loaded -> {
|
|
||||||
if (state.value.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
"No sessions. Tap + to spawn one.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LazyColumn(
|
|
||||||
state = rows,
|
|
||||||
contentPadding =
|
|
||||||
PaddingValues(bottom = covered + buttonHeight + BUTTON_RING * 2),
|
|
||||||
) {
|
|
||||||
uniqueItems(state.value, key = { it.id }) { session ->
|
|
||||||
SessionCard(
|
|
||||||
session = session,
|
|
||||||
error = deleteErrors[session.id],
|
|
||||||
deleting = session.id in deleting,
|
|
||||||
picked = session.id in selected,
|
|
||||||
// The handle is a selection-mode control, so it is absent rather
|
|
||||||
// than disabled outside one: this is not a capability being
|
|
||||||
// withheld, it is a mode the list is not in.
|
|
||||||
reorder = reorder.takeIf { selected.isNotEmpty() },
|
|
||||||
onClick = {
|
|
||||||
// In selection mode a tap is a selection, so the reader is
|
|
||||||
// never one mis-tap away from opening a session they were only
|
|
||||||
// picking rows for.
|
|
||||||
if (selected.isEmpty()) onOpen(session)
|
|
||||||
else
|
|
||||||
selected =
|
|
||||||
if (session.id in selected) selected - session.id
|
|
||||||
else selected + session.id
|
|
||||||
},
|
|
||||||
onLongPress = { selected = selected + session.id },
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Over the list rather than above it: a bar that appears in the flow moves every row down
|
|
||||||
// by its own height at the moment the reader is looking at them.
|
|
||||||
if (reloading) {
|
|
||||||
LinearProgressIndicator(Modifier.align(Alignment.TopCenter).fillMaxWidth())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beside nothing in particular, because a selection is not one row: the options that act on
|
|
||||||
// it belong to the screen, and the bottom is where a thumb already is.
|
|
||||||
if (selected.isNotEmpty()) {
|
|
||||||
val picked =
|
|
||||||
(listState as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
|
|
||||||
SessionSelectionBar(
|
|
||||||
count = picked.size,
|
|
||||||
modifier =
|
|
||||||
Modifier.align(Alignment.BottomCenter).onSizeChanged {
|
|
||||||
barHeight = with(density) { it.height.toDp() }
|
|
||||||
},
|
|
||||||
onDelete = { confirmingDelete = picked },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Above the bar when there is one, by what that bar measured: the button stays rather than
|
|
||||||
// coming and going, since an absent control cannot say whether there was nothing to do.
|
|
||||||
FloatingActionButton(
|
|
||||||
onClick = onSpawn,
|
|
||||||
modifier =
|
|
||||||
Modifier.align(Alignment.BottomEnd)
|
|
||||||
.padding(end = BUTTON_RING, bottom = BUTTON_RING + covered)
|
|
||||||
.onSizeChanged { buttonHeight = with(density) { it.height.toDp() } },
|
|
||||||
) {
|
|
||||||
Text("+", style = MaterialTheme.typography.headlineMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val targets = confirmingDelete
|
|
||||||
if (targets.isNotEmpty()) {
|
|
||||||
// Reset per selection, so a toggle turned on for one set of conversations is not still
|
|
||||||
// on for the next. Off to begin with: see [deleteSession].
|
|
||||||
var alsoDeleteForeign by remember(targets) { mutableStateOf(false) }
|
|
||||||
// Whichever of these keep a transcript of their own decide what the sentences below say,
|
|
||||||
// and whether the switch is offered at all. Old servers reported only the capability, when
|
|
||||||
// Claude Code was its sole owner.
|
|
||||||
val owned = targets.filter { it.keepsOwnTranscript }
|
|
||||||
val transcriptOwner = owned.firstOrNull()?.ownTranscriptName ?: "Claude Code"
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { confirmingDelete = emptyList() },
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
if (targets.size == 1) "Delete \"${targets.first().title}\"?"
|
|
||||||
else "Delete ${targets.size} sessions?"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
// Two different acts behind one button, so it says which one this is. What
|
|
||||||
// separates them is whether the *driver* keeps its own record of the
|
|
||||||
// conversation
|
|
||||||
// -- the coding CLIs do, whether this app spawned the session or imported it;
|
|
||||||
// echo and llama.cpp do not.
|
|
||||||
//
|
|
||||||
// This used to branch on `imported`, above a comment asserting that "a session
|
|
||||||
// started here has no copy anywhere". That was false for every coding-CLI session
|
|
||||||
// this app spawned, and getting it wrong in that direction is the expensive one:
|
|
||||||
// "this can't be undone", said of something that can, spends the credibility that
|
|
||||||
// sentence needs.
|
|
||||||
//
|
|
||||||
// Neither branch promises a restore. The recoverable one says what is known,
|
|
||||||
// that the driver keeps its own record, rather than that the file is still there,
|
|
||||||
// and it names what goes either way, because this app's transcript holds images,
|
|
||||||
// peer messages and commands the CLI's own record never had.
|
|
||||||
//
|
|
||||||
// A selection takes the sentence that covers all of it: "some of these" is what
|
|
||||||
// makes the mixed case true without either half of it being read as a promise
|
|
||||||
// about every row.
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
when {
|
|
||||||
owned.isEmpty() ->
|
|
||||||
"Kills the process and deletes the conversation. Nothing else " +
|
|
||||||
"keeps a copy, so this can't be undone."
|
|
||||||
// The sentence below is the one the toggle makes false, which is
|
|
||||||
// why it is written twice rather than appended to: "should still be
|
|
||||||
// there to import again", left on screen beside a switch that removes
|
|
||||||
// it, is the reassurance being read as it stops being true.
|
|
||||||
alsoDeleteForeign ->
|
|
||||||
"Kills the process and deletes both copies of the conversation: " +
|
|
||||||
"this app's, and $transcriptOwner's own transcript on the " +
|
|
||||||
"machine. Nothing keeps another, so this can't be undone."
|
|
||||||
else ->
|
|
||||||
"Stops the process and deletes this app's copy of the " +
|
|
||||||
"conversation, including any images, peer messages and " +
|
|
||||||
"commands recorded only here. $transcriptOwner keeps its own " +
|
|
||||||
"transcript on the machine" +
|
|
||||||
(if (owned.size < targets.size) " for some of these" else "") +
|
|
||||||
", so the conversation itself should still be there to " +
|
|
||||||
"import again."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
// Only where there is a second copy to decide about. Absent rather than
|
|
||||||
// disabled, because this is not a capability being withheld: for echo and
|
|
||||||
// llama.cpp there is no other transcript, and a switch offering to delete one
|
|
||||||
// would be asking about something that does not exist.
|
|
||||||
if (owned.isNotEmpty()) {
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
// Its own row rather than beside the paragraph: a switch is taller
|
|
||||||
// than a line of text and re-centres whatever shares a row with it.
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
"Delete $transcriptOwner's transcript too",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
Switch(
|
|
||||||
checked = alsoDeleteForeign,
|
|
||||||
onCheckedChange = { alsoDeleteForeign = it },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
confirmingDelete = emptyList()
|
|
||||||
deleteChosen(targets, alsoDeleteForeign)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
// Coloured by consequence: this takes something away, and does so wherever it
|
|
||||||
// appears -- the same rule the import screen's Delete follows.
|
|
||||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { confirmingDelete = emptyList() }) { Text("Cancel") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ring of space inside a session's card, which is also what its handle leaves around itself.
|
|
||||||
*/
|
|
||||||
private val CARD_PADDING = 16.dp
|
|
||||||
|
|
||||||
/** The gap the spawn button keeps from the edges it floats over, and from the list above it. */
|
|
||||||
private val BUTTON_RING = 24.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What can be done to the sessions that are selected.
|
|
||||||
*
|
|
||||||
* Delete only, for now, which is the one thing this screen has ever done to a session from the list
|
|
||||||
* rather than from inside it. The same bar as the import tab's, down to the wording of the count.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun SessionSelectionBar(
|
|
||||||
count: Int,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
tonalElevation = 3.dp,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"$count selected",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
TextButton(onClick = onDelete) {
|
|
||||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun SessionCard(
|
|
||||||
session: SessionSummary,
|
|
||||||
/** What went wrong acting on *this* session, if anything has. */
|
|
||||||
error: String?,
|
|
||||||
/**
|
|
||||||
* Whether this session is being deleted right now.
|
|
||||||
*
|
|
||||||
* Suspended rather than removed while it is -- see [BusyItem] -- which says the row is on its
|
|
||||||
* way out without claiming it has gone: a row removed the moment Delete is pressed is a promise
|
|
||||||
* about a request that has not been answered yet.
|
|
||||||
*/
|
|
||||||
deleting: Boolean,
|
|
||||||
/** Whether this row is one of the selection the bottom bar acts on. */
|
|
||||||
picked: Boolean,
|
|
||||||
/** The drag this row can be moved by, or null where the list is not in selection mode. */
|
|
||||||
reorder: Reorder?,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onLongPress: () -> Unit,
|
|
||||||
) {
|
|
||||||
val held = reorder?.held == session.id
|
|
||||||
BusyItem(label = if (deleting) "deleting" else null) {
|
|
||||||
Card(
|
|
||||||
colors =
|
|
||||||
if (picked)
|
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
else CardDefaults.cardColors(),
|
|
||||||
// Lifted while it is in hand, which is the one cue that says this row is being carried
|
|
||||||
// rather than sitting where it belongs.
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = if (held) 8.dp else 0.dp),
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
// Drawn where the finger has taken it, above the rows it is passing over. Both
|
|
||||||
// in the layer rather than in the layout, so nothing around it moves and the
|
|
||||||
// list does not remeasure per frame of a drag.
|
|
||||||
.zIndex(if (held) 1f else 0f)
|
|
||||||
.graphicsLayer { translationY = reorder?.offsetOf(session.id) ?: 0f },
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Column(
|
|
||||||
// Everything but the handle, which is what makes the two gestures separate
|
|
||||||
// rather than competing: a press that lands on the handle never reaches this,
|
|
||||||
// so holding it cannot select the row it is about to move. The card had the
|
|
||||||
// click while the handle was the only thing inside it that did not want one,
|
|
||||||
// and a hold on the handle then both selected the row and ate the drag.
|
|
||||||
//
|
|
||||||
// Off while the delete is in flight: a card that still opens a session it is
|
|
||||||
// deleting is a race the reader can start by tapping. Here rather than in
|
|
||||||
// [BusyItem], which leaves gestures alone so the list still scrolls.
|
|
||||||
Modifier.weight(1f)
|
|
||||||
.combinedClickable(
|
|
||||||
enabled = !deleting,
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongPress,
|
|
||||||
)
|
|
||||||
.padding(CARD_PADDING)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
session.title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
StatusText(session.status)
|
|
||||||
if (session.backgroundTasks > 0) {
|
|
||||||
Text(
|
|
||||||
backgroundTaskLabel(session.backgroundTasks),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
// Machine, then what runs on it, then what it is set to: the same order
|
|
||||||
// and separator as the session screen's header and the usage dialog, so
|
|
||||||
// one pair of facts is not written three ways.
|
|
||||||
listOfNotNull(
|
|
||||||
session.machineName,
|
|
||||||
session.provider,
|
|
||||||
session.model?.let { modelLabel(it) },
|
|
||||||
)
|
|
||||||
.joinToString(" · "),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
relativeTime(session.lastActivity),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
error?.let {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
// The server's own words, unprefixed, the way every other failure is shown.
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Inside the card, so what it moves is the thing it is drawn on. Nothing is held
|
|
||||||
// open for it outside selection mode: the row is then the row it always was.
|
|
||||||
if (reorder != null) {
|
|
||||||
ReorderHandle(
|
|
||||||
reorder,
|
|
||||||
session.id,
|
|
||||||
// Dimmed with the rest of the row while something is happening to it, since
|
|
||||||
// a row on its way out is not one to rearrange -- see [BusyItem], whose
|
|
||||||
// appearance this matches rather than repeating its dimming rule.
|
|
||||||
// The mark lines up with the text on the other side of the card,
|
|
||||||
// which means taking the square it is centred in off the gap: see
|
|
||||||
// [HANDLE_MARGIN].
|
|
||||||
Modifier.alpha(if (deleting) 0.4f else 1f)
|
|
||||||
.padding(end = CARD_PADDING - HANDLE_MARGIN),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StatusText(status: String) {
|
|
||||||
val label = sessionStatusWord(status)
|
|
||||||
val color = sessionStatusColour(status)
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
if (sessionWorking(status)) {
|
|
||||||
// The same colour as the word beside it: the two are one signal, and a spinner in the
|
|
||||||
// theme's accent says the state is something other than what the label says.
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.width(14.dp).height(14.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = color,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
}
|
|
||||||
Text(label, style = MaterialTheme.typography.labelLarge, color = color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun relativeTime(epochSeconds: Double): String {
|
|
||||||
val seconds = (System.currentTimeMillis() / 1000.0 - epochSeconds).toLong()
|
|
||||||
return when {
|
|
||||||
seconds < 60 -> "just now"
|
|
||||||
seconds < 3600 -> "${seconds / 60}m ago"
|
|
||||||
seconds < 86400 -> "${seconds / 3600}h ago"
|
|
||||||
else -> "${seconds / 86400}d ago"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,606 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What can be changed about one session, as opposed to about this app.
|
|
||||||
*
|
|
||||||
* Over the session rather than a step down from it: everything here is about the conversation
|
|
||||||
* behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a
|
|
||||||
* screen of its own until 2026-08-30, which put a page transition and a back stack around two
|
|
||||||
* controls and hid the thing they act on.
|
|
||||||
*
|
|
||||||
* The model and the permission mode are on the session's own bar as well, because those are changed
|
|
||||||
* *while* reading a turn -- "not this model, try that one". They are here too because that bar is
|
|
||||||
* one row shared with three actions: a long model name leaves the other picker a few pixels wide,
|
|
||||||
* and this is where somebody goes looking for a setting anyway.
|
|
||||||
*
|
|
||||||
* Captions are for what a control costs rather than for what it is. A paragraph under every control
|
|
||||||
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
|
|
||||||
* and Reload do, because what those two take away is not visible from here.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SessionSettingsDialog(
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
/**
|
|
||||||
* What the session is called now, as the screen behind this knows it -- see the rename below.
|
|
||||||
*/
|
|
||||||
title: String,
|
|
||||||
onRenamed: (String) -> Unit,
|
|
||||||
/**
|
|
||||||
* How hard the model thinks, or null for the CLI's own default.
|
|
||||||
*
|
|
||||||
* Owned by the screen behind this rather than held here, like [title]: this dialog is what
|
|
||||||
* changes it, and a level kept only for as long as the dialog is open is the old one again the
|
|
||||||
* next time it is opened.
|
|
||||||
*
|
|
||||||
* Not fetched, because unlike the notification switch there is nothing else that changes it:
|
|
||||||
* the level is this app's to set and the server does not resolve it into something else.
|
|
||||||
*/
|
|
||||||
effort: String?,
|
|
||||||
onEffortChanged: (String?) -> Unit,
|
|
||||||
/** Whether a level does anything here; the row is left out entirely where it does not. */
|
|
||||||
takesEffort: Boolean,
|
|
||||||
/**
|
|
||||||
* The settings that are one of a list -- the model and the permission mode.
|
|
||||||
*
|
|
||||||
* Owned by the screen behind this, like [title] and [effort]: it is what asked the machine what
|
|
||||||
* the provider offers. Whichever of them this one has no answer for is not in the list, and
|
|
||||||
* draws no row.
|
|
||||||
*/
|
|
||||||
choices: List<SessionChoice>,
|
|
||||||
/**
|
|
||||||
* The settings this session's provider takes, and what they are set to.
|
|
||||||
*
|
|
||||||
* Declared by the server rather than listed here -- see [ProviderParamFields]. Empty for a
|
|
||||||
* provider with none, which draws no section at all.
|
|
||||||
*/
|
|
||||||
paramSpecs: List<ParamSpec>,
|
|
||||||
params: Map<String, String>,
|
|
||||||
onParamsChanged: (Map<String, String>) -> Unit,
|
|
||||||
/**
|
|
||||||
* What this phone is holding of the conversation, or null while that is being measured -- see
|
|
||||||
* the Reload row below, which is what would discard it.
|
|
||||||
*/
|
|
||||||
cachedBytes: Long?,
|
|
||||||
onReload: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
/**
|
|
||||||
* Copies what this session costs to draw. Built by the session screen, because everything it
|
|
||||||
* measures is that screen's own state.
|
|
||||||
*/
|
|
||||||
onCopyRenderReport: () -> Unit,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var name by remember(sessionId) { mutableStateOf(title) }
|
|
||||||
var effortError by remember { mutableStateOf<String?>(null) }
|
|
||||||
var saving by remember { mutableStateOf(false) }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
|
|
||||||
// whenever the list was last fetched, so drawing the switch straight from it would show a
|
|
||||||
// position that may have been changed since. Until the answer arrives the switch is disabled
|
|
||||||
// and a spinner sits beside it, which is what not knowing looks like.
|
|
||||||
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
|
||||||
var notifyError by remember { mutableStateOf<String?>(null) }
|
|
||||||
// The same three-state shape the notification switch has, for the same reason: until the
|
|
||||||
// server has answered, the switch is disabled rather than showing a position nothing confirmed.
|
|
||||||
var autoResume by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
|
||||||
var resumeMessage by remember(sessionId) { mutableStateOf(DEFAULT_RESUME_MESSAGE) }
|
|
||||||
// When the server next intends to ask whether the limit has lifted, or null when nothing is
|
|
||||||
// waiting. Read once with everything else: it moves on the server's schedule, not this
|
|
||||||
// screen's, and a figure that redrew itself here would be this app re-measuring what it was
|
|
||||||
// told.
|
|
||||||
var resumeAt by remember(sessionId) { mutableStateOf<Double?>(null) }
|
|
||||||
var resumeError by remember { mutableStateOf<String?>(null) }
|
|
||||||
// Where the session works. Null until the server has been asked, for the same reason the switch
|
|
||||||
// above is. An empty answer is a session that was never given a directory, which is not the
|
|
||||||
// same as one whose directory is unknown -- the field is only enabled once one of those is
|
|
||||||
// settled.
|
|
||||||
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
|
|
||||||
var typedCwd by remember(sessionId) { mutableStateOf("") }
|
|
||||||
var cwdError by remember { mutableStateOf<String?>(null) }
|
|
||||||
var movingCwd by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(sessionId) {
|
|
||||||
try {
|
|
||||||
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
|
||||||
notify = fresh.notify
|
|
||||||
autoResume = fresh.autoResume
|
|
||||||
resumeMessage = fresh.autoResumeMessage
|
|
||||||
resumeAt = fresh.resumeAt
|
|
||||||
cwd = fresh.cwd.orEmpty()
|
|
||||||
typedCwd = fresh.cwd.orEmpty()
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// Left unknown rather than falling back to the stale row: the switch stays disabled,
|
|
||||||
// instead of offering a position nothing confirmed.
|
|
||||||
notifyError = e.message
|
|
||||||
notify = null
|
|
||||||
resumeError = e.message
|
|
||||||
autoResume = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves the session, which ends the process that is in the old directory.
|
|
||||||
*
|
|
||||||
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
|
|
||||||
* process, and a stopped session is a state this app already has a word and a button for.
|
|
||||||
*/
|
|
||||||
fun moveCwd() {
|
|
||||||
val chosen = typedCwd.trim()
|
|
||||||
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
|
|
||||||
movingCwd = true
|
|
||||||
cwdError = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
|
|
||||||
cwd = chosen
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// Where it happened: this field is the only thing on screen that knows a move was
|
|
||||||
// asked for, and the reason is usually the path itself.
|
|
||||||
cwdError = e.message
|
|
||||||
} finally {
|
|
||||||
movingCwd = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chooses a thinking level, which ends the process the old level was launched with.
|
|
||||||
*
|
|
||||||
* Put back if the request is refused, for the reason the notification switch below gives: a
|
|
||||||
* control that stays where it was put after a refusal is stating something untrue.
|
|
||||||
*/
|
|
||||||
fun setEffort(chosen: String?) {
|
|
||||||
val was = effort
|
|
||||||
onEffortChanged(chosen)
|
|
||||||
effortError = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
onEffortChanged(was)
|
|
||||||
effortError = e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Moved optimistically so the switch answers the finger that moved it, and put back if the
|
|
||||||
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
|
|
||||||
// and one that stays moved after a refusal lies.
|
|
||||||
fun setNotify(wanted: Boolean) {
|
|
||||||
val was = notify
|
|
||||||
notify = wanted
|
|
||||||
notifyError = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) }
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
notify = was
|
|
||||||
notifyError = e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turns auto-resume on or off, or changes what it would say.
|
|
||||||
*
|
|
||||||
* One request for both, because the server takes one: switching it on and typing the message
|
|
||||||
* are two halves of the same decision, and sending them separately would leave a moment where
|
|
||||||
* the session is armed with the old words.
|
|
||||||
*
|
|
||||||
* Put back if refused, like the notification switch. Turning it off also clears what was
|
|
||||||
* scheduled -- said here rather than only on the server, or the row would go on naming a time
|
|
||||||
* that no longer exists.
|
|
||||||
*/
|
|
||||||
fun setAutoResume(on: Boolean, message: String) {
|
|
||||||
val wasOn = autoResume
|
|
||||||
val wasMessage = resumeMessage
|
|
||||||
val wasAt = resumeAt
|
|
||||||
autoResume = on
|
|
||||||
resumeMessage = message
|
|
||||||
if (!on) resumeAt = null
|
|
||||||
resumeError = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
setSessionAutoResume(settings, sessionId, on, message)
|
|
||||||
}
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
autoResume = wasOn
|
|
||||||
resumeMessage = wasMessage
|
|
||||||
resumeAt = wasAt
|
|
||||||
resumeError = e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing to do when the name has not changed, so the button says so rather than sending a
|
|
||||||
// request whose success would look exactly like the failure of having typed nothing.
|
|
||||||
val changed = name.trim().isNotEmpty() && name.trim() != title
|
|
||||||
|
|
||||||
fun save() {
|
|
||||||
if (!changed || saving) return
|
|
||||||
val chosen = name.trim()
|
|
||||||
saving = true
|
|
||||||
error = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
|
|
||||||
onRenamed(chosen)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// Reported here, where it happened, because this dialog is the only place that
|
|
||||||
// knows a rename was attempted.
|
|
||||||
error = e.message
|
|
||||||
saving = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Session settings") },
|
|
||||||
text = {
|
|
||||||
// Scrollable, because this dialog grew past a screenful: a Material dialog constrains
|
|
||||||
// its own height and clips what does not fit, so the last control on the list is one
|
|
||||||
// large system font away from being unreachable with nothing on screen to say so.
|
|
||||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
label = { Text("Name") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = !saving,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
// The keyboard's own action does what the button does: a one-field form where
|
|
||||||
// the return key does nothing is a form people press return at anyway.
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
keyboardActions = KeyboardActions(onDone = { save() }),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Notifications", modifier = Modifier.weight(1f))
|
|
||||||
if (notify == null && notifyError == null) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.width(16.dp).height(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = notify == true,
|
|
||||||
onCheckedChange = { setNotify(it) },
|
|
||||||
enabled = notify != null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Beside the switch that failed, not with the rename's error: they are two requests
|
|
||||||
// and a reader has to be able to tell which one the server refused.
|
|
||||||
notifyError?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Resume after a usage limit", modifier = Modifier.weight(1f))
|
|
||||||
if (autoResume == null && resumeError == null) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.width(16.dp).height(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = autoResume == true,
|
|
||||||
onCheckedChange = { setAutoResume(it, resumeMessage) },
|
|
||||||
enabled = autoResume != null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Disabled rather than hidden while the switch is off: a field that comes and goes
|
|
||||||
// makes its own presence the signal, and a visible one teaches what the switch will
|
|
||||||
// do. Committed on the keyboard's Done rather than on every keystroke, so typing a
|
|
||||||
// sentence is one request instead of one per letter.
|
|
||||||
OutlinedTextField(
|
|
||||||
value = resumeMessage,
|
|
||||||
onValueChange = { resumeMessage = it },
|
|
||||||
label = { Text("Message to send") },
|
|
||||||
// What an empty field means, in the field: the server's own word rather than a
|
|
||||||
// session poked with nothing to read.
|
|
||||||
placeholder = { Text(DEFAULT_RESUME_MESSAGE) },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = autoResume == true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
keyboardActions =
|
|
||||||
KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }),
|
|
||||||
)
|
|
||||||
// What it does and what it costs, in the order it happens. The last sentence is the
|
|
||||||
// one that matters: the time below is when the server will *ask*, not a promise
|
|
||||||
// about when the session speaks.
|
|
||||||
Text(
|
|
||||||
"When this session stops because the account is out of quota, the server " +
|
|
||||||
"checks the limit and sends this message once it has lifted. It checks " +
|
|
||||||
"again if the limit is still on.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
// Only where something is actually waiting. Absent is not a state worth a row: a
|
|
||||||
// session that has not hit a limit has nothing scheduled, which the reader can see
|
|
||||||
// from the switch.
|
|
||||||
resumeAt?.let { at ->
|
|
||||||
Text(
|
|
||||||
"Waiting now -- next check ${formatCheckTime(at)}.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
resumeError?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = typedCwd,
|
|
||||||
onValueChange = { typedCwd = it },
|
|
||||||
label = { Text("Working directory") },
|
|
||||||
// What the field cannot say by being empty: a session that was never given
|
|
||||||
// one starts wherever its launcher does, and this names that rather than
|
|
||||||
// showing a path nobody chose.
|
|
||||||
placeholder = { Text("wherever the session was started") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = cwd != null && !movingCwd,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
|
|
||||||
)
|
|
||||||
TextButton(
|
|
||||||
onClick = { moveCwd() },
|
|
||||||
enabled =
|
|
||||||
cwd != null &&
|
|
||||||
!movingCwd &&
|
|
||||||
typedCwd.trim().isNotEmpty() &&
|
|
||||||
typedCwd.trim() != cwd,
|
|
||||||
) {
|
|
||||||
Text(if (movingCwd) "Moving..." else "Move")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The whole of what pressing Move does, where it is about to be pressed. A
|
|
||||||
// directory is settled when the process is spawned, so it is ended and the next
|
|
||||||
// thing said to the session starts it in the new place.
|
|
||||||
Text(
|
|
||||||
"Moving stops the session's process. It starts again in the new directory " +
|
|
||||||
"with the next message, or with Start.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
cwdError?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
// control teaches what the thing can do, and a llama session cannot do this at all
|
|
||||||
// -- the row would be teaching something false about it.
|
|
||||||
if (takesEffort) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Thinking", modifier = Modifier.weight(1f))
|
|
||||||
PickerButton(
|
|
||||||
current = effort ?: DEFAULT_EFFORT,
|
|
||||||
// The level the CLI picks for itself is in the list as well as in the
|
|
||||||
// button, so leaving a level is not a one-way trip -- the same
|
|
||||||
// correction the model picker carries.
|
|
||||||
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
|
||||||
onPick = { chosen ->
|
|
||||||
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// What it costs, said where it is about to be pressed, like Move above: the
|
|
||||||
// CLI reads the level when it launches and has no control request for
|
|
||||||
// changing one.
|
|
||||||
Text(
|
|
||||||
"Changing this stops the session's process. It starts again with the " +
|
|
||||||
"next message, or with Start.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
effortError?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Transcript", modifier = Modifier.weight(1f))
|
|
||||||
// The size is what the button discards, and the unknown state is drawn rather
|
|
||||||
// than guessed: a spinner while the directory is being measured, and words when
|
|
||||||
// there is nothing there, because "nothing cached" and "0 B" read as different
|
|
||||||
// claims.
|
|
||||||
when {
|
|
||||||
cachedBytes == null ->
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.width(16.dp).height(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
else ->
|
|
||||||
Text(
|
|
||||||
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
// Enabled whether or not anything is cached: "what I see disagrees with the
|
|
||||||
// machine" is a state an empty cache can be in too, and a control that comes
|
|
||||||
// and goes makes its own presence the signal.
|
|
||||||
TextButton(onClick = onReload) { Text("Reload") }
|
|
||||||
}
|
|
||||||
// Captioned, unlike the controls above it, for the same reason Move is: what it
|
|
||||||
// costs is not visible, and neither is the case it exists for.
|
|
||||||
Text(
|
|
||||||
"Reload throws away this phone's copy and fetches the transcript from the " +
|
|
||||||
"server again. Use it when what is shown here disagrees with the file " +
|
|
||||||
"on the machine.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
error?.let {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
// About this session, which is what everything in here is -- and it was on the
|
|
||||||
// header until 2026-09-03, where the folder button now is. It copies rather than
|
|
||||||
// opening anything, so it says so and then says it happened: a row that looks like
|
|
||||||
// a control and gives no sign of having run is one people press twice.
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Render timings", modifier = Modifier.weight(1f))
|
|
||||||
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Disabled rather than absent while there is nothing to save: a button that comes and goes
|
|
||||||
// makes its own presence the signal, and its absence cannot say why.
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { save() }, enabled = changed && !saving) {
|
|
||||||
Text(if (saving) "Saving..." else "Save")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One session setting that is a choice from a list, as this dialog draws it.
|
|
||||||
*
|
|
||||||
* A shape rather than a pair of parameters each, because a provider may offer either of them, both
|
|
||||||
* or neither, and they are otherwise the same control.
|
|
||||||
*/
|
|
||||||
data class SessionChoice(
|
|
||||||
val label: String,
|
|
||||||
val current: String,
|
|
||||||
val options: List<String>,
|
|
||||||
val onPick: (String) -> Unit,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the server will next look, as a local time.
|
|
||||||
*
|
|
||||||
* A time rather than a countdown, for the reason the transcript's own limit row gives: this screen
|
|
||||||
* reads the figure once, and a span drawn from a value nothing refreshes goes stale while somebody
|
|
||||||
* is looking at it.
|
|
||||||
*/
|
|
||||||
private fun formatCheckTime(epochSeconds: Double): String =
|
|
||||||
try {
|
|
||||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
|
||||||
.withZone(ZoneId.systemDefault())
|
|
||||||
.format(Instant.ofEpochSecond(epochSeconds.toLong()))
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// A time that cannot be read is not a time to show: the sentence above still says a check
|
|
||||||
// is coming, which is the part the reader can act on.
|
|
||||||
"soon"
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
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.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/** What one machine's rate limits came back as, or why they didn't. */
|
|
||||||
sealed class SessionUsage {
|
|
||||||
/** Nothing has come back yet. Distinct from every answer, including an empty one. */
|
|
||||||
data object Waiting : SessionUsage()
|
|
||||||
|
|
||||||
/** Every window the machine reported, in the order it reported them. */
|
|
||||||
data class Known(val windows: List<UsageWindow>) : SessionUsage()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This machine meters nothing, so there is no window to show.
|
|
||||||
*
|
|
||||||
* Separate from [Unavailable], and the distinction is the point: a session on `echo` or on a
|
|
||||||
* local llama.cpp has no paid quota at all, which is a fact about how it was set up and not a
|
|
||||||
* failure to find something out. The backend never asks such a machine, and reading that
|
|
||||||
* silence as "couldn't find out" is answering with the nearest available word.
|
|
||||||
*/
|
|
||||||
data object NotMetered : SessionUsage()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The question could not be answered, and why.
|
|
||||||
*
|
|
||||||
* Its own state because "we couldn't find out" and "none of it is used" must never share an
|
|
||||||
* appearance: a bar sitting at zero because a machine is unreachable reads as plenty of
|
|
||||||
* headroom.
|
|
||||||
*/
|
|
||||||
data class Unavailable(val why: String) : SessionUsage()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How often to ask again. The backend caches, so this re-reads its cache rather than the API. */
|
|
||||||
private const val REFRESH_MS = 60_000L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One poll of every machine's limits, and the handle to ask again.
|
|
||||||
*
|
|
||||||
* A screen shows this answer in more than one place -- the bar under the session header, the colour
|
|
||||||
* of the button beside it, and the dialog that button opens -- and each used to fetch for itself.
|
|
||||||
* Two fetches say one thing twice and then disagree: the bar's copy can be a whole refresh interval
|
|
||||||
* old when the dialog opens with a fresh one, so the header read 42% while the screen over it read
|
|
||||||
* 47%.
|
|
||||||
*/
|
|
||||||
class UsageFeed(
|
|
||||||
val snapshots: LoadState<List<UsageSnapshot>>,
|
|
||||||
/** A fetch is outstanding. Only ever true over an answer already shown. */
|
|
||||||
val refreshing: Boolean,
|
|
||||||
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
|
||||||
val refresh: () -> Unit,
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* What meters [session], and what that meter came back as. See [usageFor] for the states.
|
|
||||||
*
|
|
||||||
* A session rather than a machine, because a machine is not what is metered: one machine runs
|
|
||||||
* the Claude CLI and an echo session side by side, and only the first of them spends anything.
|
|
||||||
*/
|
|
||||||
fun forSession(session: SessionSummary): SessionUsage {
|
|
||||||
// Settled without asking anybody: a session nothing meters has nothing to check, and
|
|
||||||
// "checking" is what the fetch's own states would say about it for as long as one is out.
|
|
||||||
val provider = session.usageProvider ?: return SessionUsage.NotMetered
|
|
||||||
return when (val state = snapshots) {
|
|
||||||
is LoadState.Loading -> SessionUsage.Waiting
|
|
||||||
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
|
||||||
is LoadState.Loaded -> usageFor(state.value, session.machine, provider, session.model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one poll of the machines' rate limits, polled and refreshable.
|
|
||||||
*
|
|
||||||
* Hoisted out of [SessionUsageBar] because everything on a session's screen that reports on usage
|
|
||||||
* has to be reporting the same measurement; see [UsageFeed].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
|
|
||||||
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
|
|
||||||
var refreshing by remember { mutableStateOf(true) }
|
|
||||||
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh also
|
|
||||||
// resets the countdown rather than leaving one due immediately after.
|
|
||||||
var asked by remember { mutableIntStateOf(0) }
|
|
||||||
LaunchedEffect(asked) {
|
|
||||||
while (true) {
|
|
||||||
refreshing = true
|
|
||||||
// Replaces the answer only once the next one is in hand: dropping back to Loading would
|
|
||||||
// blank a bar somebody is reading for the length of a round trip, and what was on
|
|
||||||
// screen is still the last thing the machine actually said.
|
|
||||||
snapshots =
|
|
||||||
try {
|
|
||||||
LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) })
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
refreshing = false
|
|
||||||
delay(REFRESH_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return remember(snapshots, refreshing) { UsageFeed(snapshots, refreshing) { asked++ } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The colour for a control that reports on [usage] as a whole: the worst window's.
|
|
||||||
*
|
|
||||||
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
|
|
||||||
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
|
|
||||||
* Taken over however many windows this session's provider returned rather than the three Claude
|
|
||||||
* sends today -- the backend passes windows it does not recognise straight through.
|
|
||||||
*
|
|
||||||
* Every state that is not a measurement takes the ordinary control colour instead. That is the
|
|
||||||
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
|
|
||||||
* unknown blue would say "measured, and fine" about a machine nobody could reach.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun usageGlyphColour(usage: SessionUsage): Color =
|
|
||||||
when (usage) {
|
|
||||||
is SessionUsage.Known ->
|
|
||||||
usage.windows.maxOfOrNull { it.percent }?.let { quotaColor(it) }
|
|
||||||
?: MaterialTheme.colorScheme.primary
|
|
||||||
else -> MaterialTheme.colorScheme.primary
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The shortest usage window for the pool this session uses, under the session's own header.
|
|
||||||
*
|
|
||||||
* Here rather than only in the usage dialog because it is the number that decides whether to keep
|
|
||||||
* going, and it was a screen away from the place that decision gets made. It reports on this
|
|
||||||
* session's machine alone -- the dialog is still where every machine is compared.
|
|
||||||
*
|
|
||||||
* What it shows is the paid service's own metering, never derived from what this app has watched go
|
|
||||||
* past: the transcript's token counts are a different quantity, measured differently, and a bar
|
|
||||||
* built out of them would be a guess wearing a measurement's clothes.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|
||||||
DebugStats.count("usage bar recomposed")
|
|
||||||
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
|
|
||||||
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
|
|
||||||
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
|
|
||||||
// would sit at a stale figure for hours.
|
|
||||||
val now = rememberUsageNow()
|
|
||||||
|
|
||||||
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
|
|
||||||
// a problem about a machine somebody chose, on every screen, forever.
|
|
||||||
//
|
|
||||||
// And nothing while the first fetch is out, which is a different silence. A request in flight
|
|
||||||
// is not a state to report -- and the session that meters nothing is exactly the one this
|
|
||||||
// cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
|
|
||||||
// second and was then taken away. A row that has to be withdrawn is worse than one that
|
|
||||||
// arrives late.
|
|
||||||
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp),
|
|
||||||
) {
|
|
||||||
// Words, not a colour and not an empty bar: every one of these is a different kind of
|
|
||||||
// answer from "this much is used", and only words carry a difference in kind.
|
|
||||||
when (val state = usage) {
|
|
||||||
// Both handled above, before the row exists at all.
|
|
||||||
SessionUsage.NotMetered,
|
|
||||||
SessionUsage.Waiting -> Unit
|
|
||||||
is SessionUsage.Unavailable -> UsageNote("Usage unknown -- ${state.why}")
|
|
||||||
is SessionUsage.Known -> {
|
|
||||||
val window = shortestUsageWindow(state.windows)
|
|
||||||
if (window == null) {
|
|
||||||
UsageNote("Usage unknown -- no window duration was reported")
|
|
||||||
} else {
|
|
||||||
UsageProgressIndicator(window, now, Modifier.weight(1f))
|
|
||||||
Text(
|
|
||||||
usageWindowLabel(window, now),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A clock shared by each usage surface, advanced independently of changes to the quota. */
|
|
||||||
@Composable
|
|
||||||
internal fun rememberUsageNow(): OffsetDateTime {
|
|
||||||
var now by remember { mutableStateOf(OffsetDateTime.now()) }
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
while (true) {
|
|
||||||
delay(REFRESH_MS)
|
|
||||||
now = OffsetDateTime.now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return now
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The quota fill with a white tick showing how far the current time window has progressed. */
|
|
||||||
@Composable
|
|
||||||
internal fun UsageProgressIndicator(
|
|
||||||
window: UsageWindow,
|
|
||||||
now: OffsetDateTime,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val elapsed = usageWindowElapsedFraction(window, now)
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
|
||||||
// The same step at the same percentages everywhere: this is the same measurement, and a
|
|
||||||
// reader who learned the colour on one surface should not have to relearn it on another.
|
|
||||||
color = quotaColor(window.percent),
|
|
||||||
modifier =
|
|
||||||
modifier.drawWithContent {
|
|
||||||
drawContent()
|
|
||||||
elapsed?.let { fraction ->
|
|
||||||
drawLine(
|
|
||||||
color = Color.White,
|
|
||||||
start = Offset(size.width * fraction, 0f),
|
|
||||||
end = Offset(size.width * fraction, size.height),
|
|
||||||
strokeWidth = 2.dp.toPx(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Elapsed time divided by the reported window duration, or null when either value is unknown. */
|
|
||||||
internal fun usageWindowElapsedFraction(window: UsageWindow, now: OffsetDateTime): Float? {
|
|
||||||
val durationMinutes = window.durationMinutes?.takeIf { it > 0 } ?: return null
|
|
||||||
val end = windowEnd(window.resetsAt, now) as? WindowEnd.Ends ?: return null
|
|
||||||
val remainingMinutes =
|
|
||||||
end.until.seconds.toDouble() / 60.0 + end.until.nano.toDouble() / 60_000_000_000.0
|
|
||||||
return (1.0 - remainingMinutes / durationMinutes).coerceIn(0.0, 1.0).toFloat()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Anything this row says instead of drawing a bar, so all of them look the same. */
|
|
||||||
@Composable
|
|
||||||
private fun UsageNote(text: String) {
|
|
||||||
Text(
|
|
||||||
text,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "42% -- 2h 15m left / 5h": how much is gone, then how long what is left has to last, then how
|
|
||||||
* long the whole window is.
|
|
||||||
*
|
|
||||||
* The percentage on its own does not answer the question it gets asked, which is whether to start
|
|
||||||
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
|
|
||||||
*
|
|
||||||
* The window's *length* is what the provider's own name for it used to carry ("5-hour window"), and
|
|
||||||
* it is worth more beside the time left than in front of the percentage: "3h 42m left / 5h" says in
|
|
||||||
* one reading both how much of the cycle is to come and which cycle this is. Where the provider
|
|
||||||
* reported no duration there is simply nothing after the span -- the name it gave is not a
|
|
||||||
* measurement of one, so nothing is inferred from it.
|
|
||||||
*
|
|
||||||
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
|
|
||||||
* that is not running gets the percentage and nothing else.
|
|
||||||
*/
|
|
||||||
private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String {
|
|
||||||
val percent = "${window.percent.toInt()}%"
|
|
||||||
val outOf =
|
|
||||||
window.durationMinutes?.takeIf { it > 0 }?.let { " / ${formatMillis(it * 60_000)}" } ?: ""
|
|
||||||
return when (val end = windowEnd(window.resetsAt, now)) {
|
|
||||||
// Between blocks a window can have no reset time, and saying so is a fact about nothing:
|
|
||||||
// there is no window to run out. The percentage is the whole answer.
|
|
||||||
WindowEnd.NotRunning -> percent
|
|
||||||
WindowEnd.Unreadable -> "$percent · reset time unreadable"
|
|
||||||
is WindowEnd.Ends ->
|
|
||||||
// Under a minute, including past the end: the number would round to "0m left", which
|
|
||||||
// reads as a measurement rather than as the window having run out.
|
|
||||||
if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon"
|
|
||||||
else "$percent · ${formatSpan(end.until)} left$outOf"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One meter's snapshot, out of every machine's: [machine]'s row for [provider].
|
|
||||||
*
|
|
||||||
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
|
|
||||||
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
|
|
||||||
* service on one machine.
|
|
||||||
*
|
|
||||||
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
|
|
||||||
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
|
||||||
* machine having no quota rather than the question going unanswered.
|
|
||||||
*/
|
|
||||||
fun usageFor(
|
|
||||||
snapshots: List<UsageSnapshot>,
|
|
||||||
machine: String,
|
|
||||||
provider: String,
|
|
||||||
model: String?,
|
|
||||||
): SessionUsage {
|
|
||||||
// No snapshot at all means the backend never asked, which it only does where there is nothing
|
|
||||||
// to ask about. That is a different answer from having asked and failed.
|
|
||||||
val pools = usageSnapshotsFor(snapshots, machine, provider)
|
|
||||||
if (pools.isEmpty()) return SessionUsage.NotMetered
|
|
||||||
val mine =
|
|
||||||
usagePoolFor(pools, model)
|
|
||||||
?: return SessionUsage.Unavailable("couldn't tell which usage pool this session uses")
|
|
||||||
if (mine.state != "ok") {
|
|
||||||
val why =
|
|
||||||
mine.detail
|
|
||||||
?: when (mine.state) {
|
|
||||||
"notLoggedIn" -> "no Claude account is signed in on this machine"
|
|
||||||
"authenticating" -> "Claude sign-in is in progress"
|
|
||||||
"loginRequired" -> "Claude sign-in is required"
|
|
||||||
else -> mine.state
|
|
||||||
}
|
|
||||||
return SessionUsage.Unavailable(why)
|
|
||||||
}
|
|
||||||
return SessionUsage.Known(mine.windows)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every billing pool reported for one provider on one machine. */
|
|
||||||
internal fun usageSnapshotsFor(
|
|
||||||
snapshots: List<UsageSnapshot>,
|
|
||||||
machine: String,
|
|
||||||
provider: String?,
|
|
||||||
): List<UsageSnapshot> =
|
|
||||||
if (provider == null) emptyList()
|
|
||||||
else snapshots.filter { it.machine == machine && it.provider == provider }
|
|
||||||
|
|
||||||
/** The pool an explicit model names, or the provider's generic pool for every other model. */
|
|
||||||
internal fun usagePoolFor(pools: List<UsageSnapshot>, model: String?): UsageSnapshot? {
|
|
||||||
if (pools.size == 1) return pools.first()
|
|
||||||
val normalizedModel = model?.normalizedPoolName()
|
|
||||||
val named = normalizedModel?.let { wanted ->
|
|
||||||
pools.firstOrNull { pool ->
|
|
||||||
val name = pool.limitName?.normalizedPoolName()
|
|
||||||
name == wanted || (wanted.contains("luna") && name == "gptreserve")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return named ?: pools.firstOrNull { it.limitId == "codex" }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The shortest cycle the selected pool actually reported. */
|
|
||||||
internal fun shortestUsageWindow(windows: List<UsageWindow>): UsageWindow? =
|
|
||||||
windows
|
|
||||||
.mapNotNull { window -> window.durationMinutes?.let { duration -> duration to window } }
|
|
||||||
.minByOrNull { it.first }
|
|
||||||
?.second
|
|
||||||
|
|
||||||
private fun String.normalizedPoolName(): String = lowercase().filter(Char::isLetterOrDigit)
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
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.layout.width
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import com.example.wgapplink.EnrollmentScanActivity
|
|
||||||
import com.google.zxing.client.android.Intents
|
|
||||||
import com.journeyapps.barcodescanner.ScanContract
|
|
||||||
import com.journeyapps.barcodescanner.ScanIntentResult
|
|
||||||
import com.journeyapps.barcodescanner.ScanOptions
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server address and token. The normal path is the "Scan QR code" button below, which decodes the
|
|
||||||
* server's terminal QR itself; these fields are the fallback for typing the same three values by
|
|
||||||
* hand. [onBack] is null on first run, when there is nothing to go back to.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SettingsScreen(
|
|
||||||
existing: ServerSettings?,
|
|
||||||
onSaved: (ServerSettings) -> Unit,
|
|
||||||
onBack: (() -> Unit)?,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") }
|
|
||||||
var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) }
|
|
||||||
// Never pre-filled from the stored token: this screen shouldn't be a way to read the credential
|
|
||||||
// back off the device.
|
|
||||||
var token by remember { mutableStateOf("") }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
val scanLauncher =
|
|
||||||
rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
|
|
||||||
// Null contents means the user backed out of the scanner -- not an error.
|
|
||||||
val contents = result.contents ?: return@rememberLauncherForActivityResult
|
|
||||||
val settings = parseEnrollmentUri(contents.toUri())
|
|
||||||
if (settings == null) {
|
|
||||||
error = "Not a valid enrollment code"
|
|
||||||
} else {
|
|
||||||
saveServerSettings(context, settings)
|
|
||||||
onSaved(settings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val requestCamera =
|
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
||||||
if (granted) {
|
|
||||||
scanLauncher.launch(enrollmentScanOptions())
|
|
||||||
} else {
|
|
||||||
error =
|
|
||||||
"Scanning needs the camera. Grant it in the system settings, " +
|
|
||||||
"or type the host, port and token in below."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
// Leading, where a back arrow points at what it returns to. Trailing it would put a
|
|
||||||
// left-pointing arrow at the right edge, aimed across the title it sits beside.
|
|
||||||
//
|
|
||||||
// Absent rather than disabled on first run, which is the one place this app lets a
|
|
||||||
// control come and go: there is no screen underneath yet, so a Back here would not be a
|
|
||||||
// capability being withheld but a promise it could not keep.
|
|
||||||
if (onBack != null) {
|
|
||||||
GlyphButton(BACK_GLYPH, "Back", onBack)
|
|
||||||
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
"Server",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
"The easy way: run ai-server on the backend and scan the QR it prints. " +
|
|
||||||
"Or type the same values here.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
// Hold the camera permission before the scanner starts. Letting its activity ask on
|
|
||||||
// our behalf is what the library does by default, and it opens the camera without
|
|
||||||
// waiting for the answer: the first-ever scan comes up as a live preview with
|
|
||||||
// "Sorry, the Android camera encountered a problem" over it, and works on the
|
|
||||||
// second try.
|
|
||||||
if (
|
|
||||||
context.checkSelfPermission(Manifest.permission.CAMERA) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
scanLauncher.launch(enrollmentScanOptions())
|
|
||||||
} else {
|
|
||||||
requestCamera.launch(Manifest.permission.CAMERA)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Scan QR code")
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = host,
|
|
||||||
onValueChange = { host = it },
|
|
||||||
label = { Text("Host") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = port,
|
|
||||||
onValueChange = { port = it },
|
|
||||||
label = { Text("Port") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = token,
|
|
||||||
onValueChange = { token = it },
|
|
||||||
label = { Text(if (existing != null) "Token (unchanged if left blank)" else "Token") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
|
|
||||||
error?.let {
|
|
||||||
Text(it, color = MaterialTheme.colorScheme.error)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val portNumber = port.trim().toIntOrNull()
|
|
||||||
val effectiveToken = token.trim().ifEmpty { existing?.token ?: "" }
|
|
||||||
when {
|
|
||||||
host.isBlank() -> error = "Host is required"
|
|
||||||
portNumber == null || portNumber !in 1..65535 -> error = "Port must be 1-65535"
|
|
||||||
effectiveToken.isEmpty() ->
|
|
||||||
error = "Token is required -- scan the server's QR or paste it"
|
|
||||||
else -> {
|
|
||||||
val settings = ServerSettings(host.trim(), portNumber, effectiveToken)
|
|
||||||
saveServerSettings(context, settings)
|
|
||||||
onSaved(settings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text("Save")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How the enrollment QR is scanned, in one place because two callers reach it -- straight from the
|
|
||||||
* button when the camera permission is already held, and from the permission result when it has
|
|
||||||
* just been granted.
|
|
||||||
*
|
|
||||||
* MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light
|
|
||||||
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a dark-
|
|
||||||
* themed terminal it comes out as a photographic negative the scanner silently never matches. The
|
|
||||||
* mixed decoder alternates normal and inverted frames, costing half the frame rate at each
|
|
||||||
* polarity.
|
|
||||||
*/
|
|
||||||
private fun enrollmentScanOptions(): ScanOptions =
|
|
||||||
ScanOptions()
|
|
||||||
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
|
||||||
.setCaptureActivity(EnrollmentScanActivity::class.java)
|
|
||||||
// Follow the phone, not the library's landscape pin.
|
|
||||||
.setOrientationLocked(false)
|
|
||||||
.addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN)
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.core.content.IntentCompat
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What another app handed this one through the share sheet, waiting to be attached to a session.
|
|
||||||
*
|
|
||||||
* Held as the URIs rather than uploaded on arrival, because an upload belongs to a session and the
|
|
||||||
* share arrives before anyone has said which. [serial] makes two shares of the same thing two
|
|
||||||
* requests, for the reason [SessionOpenRequest] carries one.
|
|
||||||
*/
|
|
||||||
data class ShareRequest(val uris: List<Uri>, val text: String?, val serial: Int)
|
|
||||||
|
|
||||||
/** The share in [intent], or null when it is some other intent. */
|
|
||||||
fun sharedContent(intent: Intent, serial: Int): ShareRequest? {
|
|
||||||
val uris =
|
|
||||||
when (intent.action) {
|
|
||||||
Intent.ACTION_SEND ->
|
|
||||||
listOfNotNull(
|
|
||||||
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
|
|
||||||
)
|
|
||||||
Intent.ACTION_SEND_MULTIPLE ->
|
|
||||||
IntentCompat.getParcelableArrayListExtra(
|
|
||||||
intent,
|
|
||||||
Intent.EXTRA_STREAM,
|
|
||||||
Uri::class.java,
|
|
||||||
)
|
|
||||||
.orEmpty()
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.takeIf { it.isNotBlank() }
|
|
||||||
if (uris.isEmpty() && text == null) return null
|
|
||||||
return ShareRequest(uris, text, serial)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What is waiting, for the banner that says so. */
|
|
||||||
fun ShareRequest.summary(): String =
|
|
||||||
when {
|
|
||||||
uris.size == 1 -> "1 file to attach"
|
|
||||||
uris.isNotEmpty() -> "${uris.size} files to attach"
|
|
||||||
else -> "Text to attach"
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A byte count at the coarsest unit that still says something, so rows stay comparable.
|
|
||||||
*
|
|
||||||
* Null at zero and below, because the screens that ask disagree about what nothing means and only
|
|
||||||
* the caller knows: a transcript of no bytes is a measurement that has not happened; a file of no
|
|
||||||
* bytes is a file with nothing in it, and the explorer says `0 B`; a session with no cached
|
|
||||||
* transcript says "nothing cached", because a figure of none would read as a measurement.
|
|
||||||
*
|
|
||||||
* Its own file rather than the import screen's, where it started: three screens now say a size, and
|
|
||||||
* a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B.
|
|
||||||
*/
|
|
||||||
fun humanSize(bytes: Long): String? =
|
|
||||||
when {
|
|
||||||
bytes <= 0L -> null
|
|
||||||
bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB"
|
|
||||||
bytes >= 1_000L -> "${bytes / 1_000L} kB"
|
|
||||||
else -> "$bytes B"
|
|
||||||
}
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
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.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
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.unit.dp
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The spawn screen: what to run, where to run it, and the per-kind fields.
|
|
||||||
*
|
|
||||||
* Providers and hosts both come from the server, so adding either to its config.ron shows up here
|
|
||||||
* with no app rebuild.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SpawnScreen(
|
|
||||||
settings: ServerSettings,
|
|
||||||
onSpawned: (SessionSummary) -> Unit,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
// What the form is made of, and whether we have it yet. A failure here is not the same as a
|
|
||||||
// server with nothing to offer, so it must not reach the pickers as empty lists.
|
|
||||||
var options by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
|
|
||||||
|
|
||||||
// Machine first, then one of its providers. Choosing a machine can invalidate the provider, so
|
|
||||||
// the
|
|
||||||
// provider is stored by name and resolved against the current machine rather than held as an
|
|
||||||
// object that could outlive the list it came from.
|
|
||||||
var machineName by remember { mutableStateOf<String?>(null) }
|
|
||||||
var providerName by remember { mutableStateOf<String?>(null) }
|
|
||||||
var title by remember { mutableStateOf("") }
|
|
||||||
var model by remember { mutableStateOf("") }
|
|
||||||
var providerModels by remember { mutableStateOf<List<OfferedModel>>(emptyList()) }
|
|
||||||
var providerModelsLoading by remember { mutableStateOf(false) }
|
|
||||||
var providerModelsError by remember { mutableStateOf<String?>(null) }
|
|
||||||
var cwd by remember { mutableStateOf("") }
|
|
||||||
// Set only after the selected provider reports its own default. An empty value is not sent.
|
|
||||||
var permissionMode by remember { mutableStateOf("") }
|
|
||||||
// Null until the server has been asked, and null again if it answers "no level chosen" -- the
|
|
||||||
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer
|
|
||||||
// arrives is one you can spawn at without having chosen it.
|
|
||||||
var effort by remember { mutableStateOf<String?>(null) }
|
|
||||||
var defaultsAsked by remember { mutableStateOf(false) }
|
|
||||||
var busy by remember { mutableStateOf(false) }
|
|
||||||
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
|
|
||||||
// form worth keeping, and that one leaves nothing to fill in.
|
|
||||||
var spawnError by remember { mutableStateOf<String?>(null) }
|
|
||||||
// Whatever the chosen provider says it takes, by key. Empty until something is typed: an
|
|
||||||
// absent key means the server's own default, which is what every field's placeholder says.
|
|
||||||
var params by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
// Separate from the machines fetch below and deliberately not fatal: failing to learn the
|
|
||||||
// default must leave a screen you can still spawn from, so the picker stays on "default"
|
|
||||||
// and says so rather than the whole form refusing to draw.
|
|
||||||
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
|
|
||||||
.onSuccess { effort = it }
|
|
||||||
defaultsAsked = true
|
|
||||||
options =
|
|
||||||
try {
|
|
||||||
val fetched = withContext(Dispatchers.IO) { fetchMachines(settings) }
|
|
||||||
val first = fetched.firstOrNull()
|
|
||||||
machineName = first?.name
|
|
||||||
providerName = first?.providers?.firstOrNull()?.name
|
|
||||||
LoadState.Loaded(fetched)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
"New session",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
TextButton(onClick = onBack) { Text("Cancel") }
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Nothing below is fillable until the options are here, and a failure to fetch them leaves
|
|
||||||
// no form worth showing -- so this reports and stops, rather than offering empty pickers
|
|
||||||
// under an error message.
|
|
||||||
val machines =
|
|
||||||
when (val state = options) {
|
|
||||||
is LoadState.Loading -> {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
return@Column
|
|
||||||
}
|
|
||||||
is LoadState.Error -> {
|
|
||||||
Text(state.message, color = MaterialTheme.colorScheme.error)
|
|
||||||
return@Column
|
|
||||||
}
|
|
||||||
is LoadState.Loaded -> state.value
|
|
||||||
}
|
|
||||||
val machine = machines.firstOrNull { it.name == machineName }
|
|
||||||
val current = machine?.providers?.firstOrNull { it.name == providerName }
|
|
||||||
// Coding CLIs take a working directory, model, permission mode and thinking level. Keying
|
|
||||||
// the extra fields on the kind rather than the provider name keeps a second installation
|
|
||||||
// from needing anything here.
|
|
||||||
val isClaude = current?.kind == "claude_cli"
|
|
||||||
val isCodex = current?.kind == "codex_cli"
|
|
||||||
val isCodingCli = isClaude || isCodex
|
|
||||||
val isLlama = current?.kind == "llama_cpp"
|
|
||||||
// Echo is the only kind with nothing to choose between.
|
|
||||||
val offersModels = isCodingCli || isLlama
|
|
||||||
// Where a session's tools act, which is the only thing a working directory decides.
|
|
||||||
val takesCwd = isCodingCli || isLlama
|
|
||||||
|
|
||||||
// Whichever machine and provider are chosen now, asked again when either changes. The
|
|
||||||
// previous answer is dropped first rather than left on screen: a model name from another
|
|
||||||
// machine looks exactly like one from this one.
|
|
||||||
LaunchedEffect(machine?.id, current?.name) {
|
|
||||||
model = ""
|
|
||||||
// A key from the previous provider would be a setting this one does not have, drawn
|
|
||||||
// by no control and sent at the spawn anyway.
|
|
||||||
params = emptyMap()
|
|
||||||
providerModels = emptyList()
|
|
||||||
providerModelsError = null
|
|
||||||
permissionMode = current?.defaultPermissionMode.orEmpty()
|
|
||||||
// Every kind that offers models at all, not only the coding CLIs: a llama provider
|
|
||||||
// answers with the GGUFs on the machine it runs on, through the same call. One
|
|
||||||
// question with one answer is what keeps the picker free of a branch on the kind.
|
|
||||||
if (machine == null || current == null || !offersModels) {
|
|
||||||
providerModelsLoading = false
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
providerModelsLoading = true
|
|
||||||
try {
|
|
||||||
providerModels =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
fetchProviderModels(settings, machine.id, current.name)
|
|
||||||
}
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
providerModelsError = e.message
|
|
||||||
} finally {
|
|
||||||
providerModelsLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The machine first, because it decides what can be run at all.
|
|
||||||
ChipGroup(
|
|
||||||
label = "Machine",
|
|
||||||
options = machines.map { it.name },
|
|
||||||
selected = machineName,
|
|
||||||
onSelect = { name ->
|
|
||||||
machineName = name
|
|
||||||
// The provider list changes with the machine, so a name carried over from the
|
|
||||||
// previous one would be a selection that isn't in the picker. Take that machine's
|
|
||||||
// first.
|
|
||||||
providerName =
|
|
||||||
machines.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
|
|
||||||
},
|
|
||||||
)
|
|
||||||
machine?.address?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
// The address belongs to the machine above it, not to the provider label below; without
|
|
||||||
// this they read as one block.
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only what this machine actually has. A machine with none says so rather than showing an
|
|
||||||
// empty row that reads as a failure.
|
|
||||||
if (machine != null && machine.providers.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
"\"${machine.name}\" has no providers configured.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ChipGroup(
|
|
||||||
label = "Provider",
|
|
||||||
options = machine?.providers?.map { it.name }.orEmpty(),
|
|
||||||
selected = providerName,
|
|
||||||
onSelect = { providerName = it },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = title,
|
|
||||||
onValueChange = { title = it },
|
|
||||||
label = { Text("Title") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (offersModels) {
|
|
||||||
when {
|
|
||||||
providerModelsLoading ->
|
|
||||||
Text(
|
|
||||||
"Loading model choices…",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
providerModelsError != null ->
|
|
||||||
Text(
|
|
||||||
"Model choices unavailable: $providerModelsError",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
// A llama session cannot start without one, so this says what to do about it
|
|
||||||
// rather than only that there is nothing -- the models it needs are on the
|
|
||||||
// machine that will serve them, which is not always this backend.
|
|
||||||
providerModels.isEmpty() && isLlama ->
|
|
||||||
Text(
|
|
||||||
"No models on ${machine.name}. The Models screen downloads " +
|
|
||||||
"to the backend; another machine needs the file put there itself.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
providerModels.isEmpty() ->
|
|
||||||
Text(
|
|
||||||
"This machine reported no selectable models.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
else -> {
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
ChipGroup(
|
|
||||||
label = "Model",
|
|
||||||
// The label, and the id is what is sent: for a llama model those differ,
|
|
||||||
// since it is chosen by path and named by what is inside the file.
|
|
||||||
options = providerModels.map { it.label },
|
|
||||||
selected = providerModels.firstOrNull { it.id == model }?.label,
|
|
||||||
onSelect = { chosen ->
|
|
||||||
val id = providerModels.first { it.label == chosen }.id
|
|
||||||
// A llama session has to have one, so choosing the same chip twice
|
|
||||||
// must not clear it -- there is nothing to fall back to.
|
|
||||||
model = if (model == id && !isLlama) "" else id
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
value = model,
|
|
||||||
onValueChange = { model = it },
|
|
||||||
label = { Text("Model (blank = the CLI's default)") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing is running yet, so nothing here waits for a restart -- every one of these is
|
|
||||||
// read by the process this form is about to start.
|
|
||||||
ProviderParamFields(
|
|
||||||
specs = current?.params.orEmpty(),
|
|
||||||
values = params,
|
|
||||||
onChange = { params = it },
|
|
||||||
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(
|
|
||||||
value = cwd,
|
|
||||||
onValueChange = { cwd = it },
|
|
||||||
label = { Text("Working directory") },
|
|
||||||
placeholder = { Text("/home/…") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offered wherever the provider has modes, rather than where this screen believes it
|
|
||||||
// does: the server is what knows, and llama.cpp grew them without this line changing.
|
|
||||||
if (current != null && current.permissionModes.isNotEmpty()) {
|
|
||||||
ChipGroup(
|
|
||||||
label = "Permissions",
|
|
||||||
options = current.permissionModes,
|
|
||||||
selected = permissionMode,
|
|
||||||
onSelect = { permissionMode = it },
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCodingCli) {
|
|
||||||
// Says what it does to *later* spawns as well, because it does: the level chosen here
|
|
||||||
// is stored as the default, which is the whole way that default is set. A picker that
|
|
||||||
// quietly changed a global would be the same control with the fact left out.
|
|
||||||
ChipGroup(
|
|
||||||
label = "Thinking (kept as the default for new sessions)",
|
|
||||||
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
|
||||||
// The CLI's own default is a level in the list, so this cannot be a one-way trip.
|
|
||||||
// Disabled-looking until the server has answered, for the reason above.
|
|
||||||
selected = if (defaultsAsked) effort ?: DEFAULT_EFFORT else null,
|
|
||||||
onSelect = { chosen -> effort = chosen.takeIf { it != DEFAULT_EFFORT } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
|
|
||||||
// Beside the button that produced it.
|
|
||||||
spawnError?.let {
|
|
||||||
Text(it, color = MaterialTheme.colorScheme.error)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val chosen = current ?: return@Button
|
|
||||||
busy = true
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val spawned =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
// Stored before the spawn and not after it: choosing a level is
|
|
||||||
// an intent about new sessions in general, so a spawn that then
|
|
||||||
// fails must not also lose the choice. Non-fatal for the same
|
|
||||||
// reason the fetch above is -- the session is what was asked for.
|
|
||||||
if (isCodingCli) {
|
|
||||||
runCatching { setDefaultEffort(settings, effort) }
|
|
||||||
}
|
|
||||||
spawnSession(
|
|
||||||
settings,
|
|
||||||
// The id, not the label: labels are editable and the server
|
|
||||||
// resolves by id. Non-null here, since `chosen` came from
|
|
||||||
// `machine`'s own provider list.
|
|
||||||
machine = machine.id,
|
|
||||||
provider = chosen.name,
|
|
||||||
title = title.trim(),
|
|
||||||
model = model.trim().takeIf { offersModels },
|
|
||||||
cwd = cwd.trim().takeIf { takesCwd },
|
|
||||||
permissionMode = permissionMode.takeIf { it.isNotEmpty() },
|
|
||||||
effort = effort.takeIf { isCodingCli },
|
|
||||||
// Already only the keys somebody set: a field left blank
|
|
||||||
// removes its key rather than sending an empty value, so
|
|
||||||
// "blank" reaches the server as "your default".
|
|
||||||
params = params,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onSpawned(spawned)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
spawnError = e.message
|
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A labeled row of choices that wraps onto as many lines as it needs.
|
|
||||||
*
|
|
||||||
* FlowRow rather than Row: a plain Row gives every chip an equal share of a single line, so once
|
|
||||||
* the options don't fit, the text inside each one wraps to one character per line instead of the
|
|
||||||
* row wrapping.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
fun ChipGroup(
|
|
||||||
label: String,
|
|
||||||
options: List<String>,
|
|
||||||
selected: String?,
|
|
||||||
onSelect: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
Text(label, style = MaterialTheme.typography.labelLarge)
|
|
||||||
FlowRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
options.forEach { option ->
|
|
||||||
FilterChip(
|
|
||||||
selected = selected == option,
|
|
||||||
onClick = { onSelect(option) },
|
|
||||||
label = { Text(option) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import java.io.IOException
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long to wait before opening a dropped stream again.
|
|
||||||
*
|
|
||||||
* Shared by every screen that follows one, so a reconnect is not paced differently depending on
|
|
||||||
* which stream dropped. Short enough that a tunnel coming back is not noticed, long enough that a
|
|
||||||
* server which is genuinely down is not being asked several times a second.
|
|
||||||
*/
|
|
||||||
const val RECONNECT_DELAY_MS = 1500L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One server-sent-events connection, framed.
|
|
||||||
*
|
|
||||||
* The framing is the part worth having once: `data:` and `event:` lines accumulate until a blank
|
|
||||||
* line ends the frame, comments start with `:`, and a frame is either named with no payload or a
|
|
||||||
* payload with no name. Two screens follow two different streams and neither should re-derive that.
|
|
||||||
*
|
|
||||||
* Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the
|
|
||||||
* cancellation path -- it disconnects the socket, which unblocks the read, and [run] then returns
|
|
||||||
* rather than throwing. Reconnecting belongs to the caller, which is the only one that knows where
|
|
||||||
* to resume from.
|
|
||||||
*/
|
|
||||||
class Sse(private val settings: ServerSettings) {
|
|
||||||
@Volatile private var connection: HttpURLConnection? = null
|
|
||||||
@Volatile private var closed = false
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
closed = true
|
|
||||||
connection?.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Follows the stream at [path], handing each frame to [onFrame] as its name (null for an
|
|
||||||
* ordinary data frame) and its payload. The path is given here rather than at construction
|
|
||||||
* because a caller that reconnects usually resumes from somewhere new.
|
|
||||||
*
|
|
||||||
* [onOpen] fires once the server has accepted the connection. That is the measured moment the
|
|
||||||
* stream is live, and the only honest thing to clear a previous failure on: clearing on the
|
|
||||||
* first *event* instead left an idle stream displaying an error it had already recovered from.
|
|
||||||
*/
|
|
||||||
fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) {
|
|
||||||
// Opening is inside the try, not before it. Everything this method can fail at owes the
|
|
||||||
// caller the same kind of failure, and a connection that could not even be constructed used
|
|
||||||
// to escape as a raw `IOException` from a line no `catch` covered.
|
|
||||||
var connection: HttpURLConnection? = null
|
|
||||||
try {
|
|
||||||
connection =
|
|
||||||
(URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection).also {
|
|
||||||
this.connection = it
|
|
||||||
}
|
|
||||||
connection.applyPinnedTls()
|
|
||||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
|
||||||
// No read timeout: between events there is nothing to read for as long as the thing
|
|
||||||
// being followed is idle; the server's keep-alives and a dead socket erroring out are
|
|
||||||
// the liveness story.
|
|
||||||
connection.readTimeout = 0
|
|
||||||
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
|
|
||||||
connection.setRequestProperty("Accept", "text/event-stream")
|
|
||||||
if (connection.responseCode != 200) {
|
|
||||||
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
|
|
||||||
throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream")
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpen()
|
|
||||||
val reader = connection.inputStream.bufferedReader()
|
|
||||||
val data = StringBuilder()
|
|
||||||
var name: String? = null
|
|
||||||
while (true) {
|
|
||||||
val line = reader.readLine() ?: break
|
|
||||||
when {
|
|
||||||
line.isEmpty() -> {
|
|
||||||
if (name != null || data.isNotEmpty()) onFrame(name, data.toString())
|
|
||||||
data.clear()
|
|
||||||
name = null
|
|
||||||
}
|
|
||||||
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
|
|
||||||
line.startsWith("event:") -> name = line.removePrefix("event:").trim()
|
|
||||||
else -> {} // id:, comments -- nothing to do
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: IOException) {
|
|
||||||
if (!closed) {
|
|
||||||
throw ApiException(
|
|
||||||
"Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})",
|
|
||||||
cause = e,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
connection?.disconnect()
|
|
||||||
this.connection = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.text.selection.TextSelectionColors
|
|
||||||
import androidx.compose.material3.ButtonColors
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Catppuccin Mocha, as published in `catppuccin/palette`.
|
|
||||||
*
|
|
||||||
* Named rather than used as literals at the point of need, so the mapping below reads as the
|
|
||||||
* decision it is -- "a card is Surface 0" -- and so a value can be checked against the upstream
|
|
||||||
* palette without reading the layout that uses it.
|
|
||||||
*/
|
|
||||||
private object Mocha {
|
|
||||||
val Rosewater = Color(0xFFF5E0DC)
|
|
||||||
val Mauve = Color(0xFFCBA6F7)
|
|
||||||
val Red = Color(0xFFF38BA8)
|
|
||||||
val Peach = Color(0xFFFAB387)
|
|
||||||
val Yellow = Color(0xFFF9E2AF)
|
|
||||||
val Green = Color(0xFFA6E3A1)
|
|
||||||
val Teal = Color(0xFF94E2D5)
|
|
||||||
val Sky = Color(0xFF89DCEB)
|
|
||||||
val Blue = Color(0xFF89B4FA)
|
|
||||||
val Lavender = Color(0xFFB4BEFE)
|
|
||||||
val Pink = Color(0xFFF5C2E7)
|
|
||||||
val Text = Color(0xFFCDD6F4)
|
|
||||||
val Subtext1 = Color(0xFFBAC2DE)
|
|
||||||
val Subtext0 = Color(0xFFA6ADC8)
|
|
||||||
val Overlay0 = Color(0xFF6C7086)
|
|
||||||
val Surface2 = Color(0xFF585B70)
|
|
||||||
val Surface1 = Color(0xFF45475A)
|
|
||||||
val Surface0 = Color(0xFF313244)
|
|
||||||
val Base = Color(0xFF1E1E2E)
|
|
||||||
val Mantle = Color(0xFF181825)
|
|
||||||
val Crust = Color(0xFF11111B)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's colour scheme: Catppuccin Mocha mapped onto Material's roles.
|
|
||||||
*
|
|
||||||
* Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link*
|
|
||||||
* -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike
|
|
||||||
* is a preference, not a contract.
|
|
||||||
*
|
|
||||||
* The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle,
|
|
||||||
* Base, Surface 0, Surface 1 -- so the page is Base, a component's outlined card stays Base beside
|
|
||||||
* it, and a project's card is Surface 0: one visible step up, which is the whole of what the
|
|
||||||
* nesting has to say.
|
|
||||||
*
|
|
||||||
* Accents on this palette are light, so anything filled with one takes Crust for its text.
|
|
||||||
*/
|
|
||||||
val AiAppColors =
|
|
||||||
darkColorScheme(
|
|
||||||
primary = Mocha.Mauve,
|
|
||||||
onPrimary = Mocha.Crust,
|
|
||||||
primaryContainer = Mocha.Surface1,
|
|
||||||
onPrimaryContainer = Mocha.Mauve,
|
|
||||||
secondary = Mocha.Lavender,
|
|
||||||
onSecondary = Mocha.Crust,
|
|
||||||
secondaryContainer = Mocha.Surface1,
|
|
||||||
onSecondaryContainer = Mocha.Lavender,
|
|
||||||
tertiary = Mocha.Rosewater,
|
|
||||||
onTertiary = Mocha.Crust,
|
|
||||||
background = Mocha.Base,
|
|
||||||
onBackground = Mocha.Text,
|
|
||||||
surface = Mocha.Base,
|
|
||||||
onSurface = Mocha.Text,
|
|
||||||
surfaceVariant = Mocha.Surface0,
|
|
||||||
onSurfaceVariant = Mocha.Subtext0,
|
|
||||||
surfaceContainerLowest = Mocha.Crust,
|
|
||||||
surfaceContainerLow = Mocha.Mantle,
|
|
||||||
surfaceContainer = Mocha.Base,
|
|
||||||
surfaceContainerHigh = Mocha.Surface0,
|
|
||||||
surfaceContainerHighest = Mocha.Surface0,
|
|
||||||
inverseSurface = Mocha.Text,
|
|
||||||
inverseOnSurface = Mocha.Base,
|
|
||||||
inversePrimary = Mocha.Mauve,
|
|
||||||
outline = Mocha.Overlay0,
|
|
||||||
outlineVariant = Mocha.Surface2,
|
|
||||||
error = Mocha.Red,
|
|
||||||
onError = Mocha.Crust,
|
|
||||||
errorContainer = Mocha.Surface1,
|
|
||||||
onErrorContainer = Mocha.Red,
|
|
||||||
scrim = Mocha.Crust,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a session is doing, said in colour.
|
|
||||||
*
|
|
||||||
* Here rather than beside each screen that shows a status. These were separate literals in two
|
|
||||||
* other files, so the same state was a slightly different colour depending which screen you looked
|
|
||||||
* at. A colour that carries meaning is part of the scheme, not a value typed where it was needed.
|
|
||||||
*/
|
|
||||||
val runningColor: Color
|
|
||||||
@Composable get() = Mocha.Green
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "This went wrong on its own": a session that fell over.
|
|
||||||
*
|
|
||||||
* The scheme's error colour, and deliberately not "the same red as a destructive button" even
|
|
||||||
* though it is the same red. They are the same red for different reasons, and a state is not an
|
|
||||||
* action.
|
|
||||||
*/
|
|
||||||
val failedColor: Color
|
|
||||||
@Composable get() = MaterialTheme.colorScheme.error
|
|
||||||
|
|
||||||
/**
|
|
||||||
* About the session rather than about the task: a command, and the compaction one of them starts.
|
|
||||||
*
|
|
||||||
* Its own colour because it is its own kind of work. Everything else a session does is progress
|
|
||||||
* through what was asked of it; this is the session acting on itself, and none of it appears in the
|
|
||||||
* transcript as an answer to anything. A reader who has learned that blue means "not stuck, but not
|
|
||||||
* replying to you either" has learned what distinguishes it from a session that has hung.
|
|
||||||
*/
|
|
||||||
val commandColor: Color
|
|
||||||
@Composable get() = Mocha.Blue
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A clear: the conversation taken out of what the session is given.
|
|
||||||
*
|
|
||||||
* Red because of what it does, not because anything went wrong -- somebody asked for this, and a
|
|
||||||
* deliberate choice is not a problem to report. The same red as [failedColor] and [stopColor] for a
|
|
||||||
* third reason: this is neither a fault nor a button, it is the mark left where something was taken
|
|
||||||
* away. No two of the three can appear as the same kind of thing.
|
|
||||||
*/
|
|
||||||
val clearedColor: Color
|
|
||||||
@Composable get() = Mocha.Red
|
|
||||||
|
|
||||||
/** Waiting on a person: a question, a permission, a turn that is theirs. */
|
|
||||||
val awaitingColor: Color
|
|
||||||
@Composable get() = Mocha.Peach
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Waiting on itself: the session's turn is over, but a subagent or a backgrounded command it
|
|
||||||
* started is still going, and it will speak again with nobody having typed anything.
|
|
||||||
*
|
|
||||||
* Its own colour rather than [awaitingColor], which is the opposite state -- that one means the
|
|
||||||
* reader has something to do, and this one means they specifically do not. Not [runningColor]
|
|
||||||
* either: nothing is being written, and a green "running" on a session that will say nothing for
|
|
||||||
* ten minutes is the wrong promise. Blue for the same reason [commandColor] is blue -- not stuck,
|
|
||||||
* but not replying to you either -- and a different blue because that one is the session acting on
|
|
||||||
* itself rather than getting on with what was asked.
|
|
||||||
*/
|
|
||||||
val waitingColor: Color
|
|
||||||
@Composable get() = Mocha.Sky
|
|
||||||
|
|
||||||
/** Approaching a limit -- still fine, worth seeing. */
|
|
||||||
val warningColor: Color
|
|
||||||
@Composable get() = Mocha.Yellow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fill of a progress bar that is only reporting how far along something is.
|
|
||||||
*
|
|
||||||
* Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary
|
|
||||||
* made it the loudest thing on a screen the reader opened to do something else. A download has no
|
|
||||||
* limit to be near: it finishes. Only a bar measuring a *quota* escalates -- that one is
|
|
||||||
* [quotaColor].
|
|
||||||
*/
|
|
||||||
val progressColor: Color
|
|
||||||
@Composable get() = Mocha.Blue
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red.
|
|
||||||
*
|
|
||||||
* One function rather than the same `when` written beside each bar, because the point of colouring
|
|
||||||
* by consequence is that the reader learns the step once. It reads as a difference in degree, which
|
|
||||||
* is all colour can carry: the states that differ in *kind* -- a window nobody could read, a
|
|
||||||
* machine that meters nothing -- are said in words elsewhere.
|
|
||||||
*
|
|
||||||
* [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent
|
|
||||||
* without one of them getting it wrong by a factor of a hundred.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun quotaColor(percent: Double): Color =
|
|
||||||
when {
|
|
||||||
percent >= OVER_LIMIT_PERCENT -> overLimitColor
|
|
||||||
percent >= WARNING_PERCENT -> warningColor
|
|
||||||
else -> progressColor
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Close enough to the limit to be worth seeing before starting something big. */
|
|
||||||
private const val WARNING_PERCENT = 75.0
|
|
||||||
|
|
||||||
/** Close enough that the next turn may be the one that is refused. */
|
|
||||||
private const val OVER_LIMIT_PERCENT = 90.0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The surface verbatim text sits on: a command, a tool's output, a code block in a reply.
|
|
||||||
*
|
|
||||||
* The darkest value in the palette rather than a step up from the page, and that is the point --
|
|
||||||
* everything else on this screen is somebody's prose, and this is what a machine was handed and
|
|
||||||
* what it said back, character for character. Crust sits *below* Base, so the same colour reads as
|
|
||||||
* one clear step down both on the page and on a card; a tint chosen upwards has to be picked twice
|
|
||||||
* and still collides with the card it lands on.
|
|
||||||
*
|
|
||||||
* One colour for all three, so "this is verbatim" is learnable once.
|
|
||||||
*/
|
|
||||||
val rawSurface: Color
|
|
||||||
@Composable get() = Mocha.Crust
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette].
|
|
||||||
*
|
|
||||||
* Here with the rest of the palette rather than beside the code that highlights: the colours a
|
|
||||||
* fence is drawn in are the same accents every other coloured thing already uses.
|
|
||||||
*
|
|
||||||
* Not a composable, because [highlight] runs off the drawing thread; these never vary with the
|
|
||||||
* theme.
|
|
||||||
*/
|
|
||||||
fun catppuccinSyntax(): SyntaxPalette =
|
|
||||||
SyntaxPalette(
|
|
||||||
addition = Mocha.Green,
|
|
||||||
deletion = Mocha.Red,
|
|
||||||
keyword = Mocha.Mauve,
|
|
||||||
string = Mocha.Green,
|
|
||||||
literal = Mocha.Peach,
|
|
||||||
comment = Mocha.Overlay0,
|
|
||||||
metadata = Mocha.Yellow,
|
|
||||||
punctuation = Mocha.Subtext0,
|
|
||||||
mark = Mocha.Sky,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The sixteen terminal colours, for what a Bash tool call printed; see [AnsiPalette].
|
|
||||||
*
|
|
||||||
* Catppuccin publishes its own ANSI mapping and this is it, rather than the eight accents picked by
|
|
||||||
* eye: a program printing in "colour 4" means blue, and which blue is a decision the palette has
|
|
||||||
* already made for every other blue on the screen.
|
|
||||||
*
|
|
||||||
* Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which
|
|
||||||
* is upstream's choice and not an omission here.
|
|
||||||
*
|
|
||||||
* The background is [rawSurface] because that is what a tool's output is drawn on, and reverse
|
|
||||||
* video needs to know what it is reversing against.
|
|
||||||
*/
|
|
||||||
fun ansiPalette(): AnsiPalette =
|
|
||||||
AnsiPalette(
|
|
||||||
colours =
|
|
||||||
listOf(
|
|
||||||
Mocha.Surface1,
|
|
||||||
Mocha.Red,
|
|
||||||
Mocha.Green,
|
|
||||||
Mocha.Yellow,
|
|
||||||
Mocha.Blue,
|
|
||||||
Mocha.Pink,
|
|
||||||
Mocha.Teal,
|
|
||||||
Mocha.Subtext1,
|
|
||||||
Mocha.Surface2,
|
|
||||||
Mocha.Red,
|
|
||||||
Mocha.Green,
|
|
||||||
Mocha.Yellow,
|
|
||||||
Mocha.Blue,
|
|
||||||
Mocha.Pink,
|
|
||||||
Mocha.Teal,
|
|
||||||
Mocha.Subtext0,
|
|
||||||
),
|
|
||||||
foreground = Mocha.Text,
|
|
||||||
background = Mocha.Crust,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a selection looks like, stated rather than left to Material's default.
|
|
||||||
*
|
|
||||||
* The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app
|
|
||||||
* draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a
|
|
||||||
* code block, on Crust, the same 40% composites to a barely-there smudge, so selecting a line of
|
|
||||||
* code looks like nothing happened even though it copies correctly.
|
|
||||||
*
|
|
||||||
* Fixed and stronger, because "this is selected" is a meaning rather than decoration. Raised only
|
|
||||||
* as far as it takes to read on the darkest of them -- past this the fill starts competing with the
|
|
||||||
* syntax colours it sits behind.
|
|
||||||
*/
|
|
||||||
val AiAppSelectionColors =
|
|
||||||
TextSelectionColors(
|
|
||||||
handleColor = Mocha.Mauve,
|
|
||||||
backgroundColor = Mocha.Mauve.copy(alpha = 0.55f),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A link. Blue is what a link is on every Catppuccin surface, and the one colour to leave alone.
|
|
||||||
*/
|
|
||||||
val linkColor: Color
|
|
||||||
@Composable get() = Mocha.Blue
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A list's markers: the bullets and numbers down its left edge.
|
|
||||||
*
|
|
||||||
* The scheme's secondary accent rather than the text colour, because a marker is structure rather
|
|
||||||
* than words: coloured, the items of a list can be counted without reading them. Lavender is not
|
|
||||||
* one of the colours that mean something here, and it is the same at every depth, since depth is
|
|
||||||
* said by the glyph and the indent -- a colour per depth would make a difference in degree look
|
|
||||||
* like one in kind.
|
|
||||||
*/
|
|
||||||
val listMarkerColor: Color
|
|
||||||
@Composable get() = Mocha.Lavender
|
|
||||||
|
|
||||||
/** Past a limit. The scheme's error colour, for the reason [failedColor] gives. */
|
|
||||||
val overLimitColor: Color
|
|
||||||
@Composable get() = MaterialTheme.colorScheme.error
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The composer's buttons, coloured by what pressing one does rather than by where it sits.
|
|
||||||
*
|
|
||||||
* Green makes something happen now, blue makes it happen later, orange takes back what is in
|
|
||||||
* flight, red ends the process. The near-collisions with the states above are deliberate: those are
|
|
||||||
* *states*, and these are *actions*. A reader never has to tell them apart, because nothing here is
|
|
||||||
* a state and nothing there is pressable.
|
|
||||||
*/
|
|
||||||
val sendColor: Color
|
|
||||||
@Composable get() = Mocha.Green
|
|
||||||
|
|
||||||
/** Sending while a turn runs: the message waits rather than starting one. See [sendColor]. */
|
|
||||||
val queueColor: Color
|
|
||||||
@Composable get() = Mocha.Blue
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interrupting the running turn: the work stops and the session stays.
|
|
||||||
*
|
|
||||||
* Orange rather than red because of how much it takes: only what is in flight. The process is still
|
|
||||||
* there holding the conversation. Red is spent on [stopColor], which is the same button in the same
|
|
||||||
* place when what it would end is the session's process.
|
|
||||||
*/
|
|
||||||
val pauseColor: Color
|
|
||||||
@Composable get() = Mocha.Peach
|
|
||||||
|
|
||||||
/** Ending the session's process -- the one button here that takes something away. */
|
|
||||||
val stopColor: Color
|
|
||||||
@Composable get() = Mocha.Red
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starting the process again, on the conversation it left.
|
|
||||||
*
|
|
||||||
* The same green as [sendColor] on purpose: both mean "this happens now", and they are never the
|
|
||||||
* same button -- the process button only offers to start when there is nothing running to stop.
|
|
||||||
*/
|
|
||||||
val startColor: Color
|
|
||||||
@Composable get() = Mocha.Green
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A filled button in one of the action colours above.
|
|
||||||
*
|
|
||||||
* The content colour is stated here beside the fill rather than inherited. A semantic colour has to
|
|
||||||
* carry its own contrast: these fills are fixed whatever the surface under them does, so the theme
|
|
||||||
* will not change to rescue a foreground that stops being readable on one of them.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun actionButtonColors(fill: Color): ButtonColors =
|
|
||||||
ButtonDefaults.buttonColors(containerColor = fill, contentColor = Mocha.Crust)
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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,172 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A tool call's input, read rather than dumped.
|
|
||||||
*
|
|
||||||
* Every tool's input arrives as JSON, and showing it raw makes the reader parse
|
|
||||||
* `{"command":"…","timeout":120000}` themselves to find the one line they care about. So the fields
|
|
||||||
* that carry the meaning are pulled out, and anything left over is still shown, because dropping a
|
|
||||||
* field would be claiming the tool has no other input when it might.
|
|
||||||
*/
|
|
||||||
data class ToolInput(
|
|
||||||
/** The thing that will actually be run or read, if this tool has one. */
|
|
||||||
val subject: String?,
|
|
||||||
/** The language [subject] is written in, for highlighting. */
|
|
||||||
val language: Language?,
|
|
||||||
/** The tool's own one-line summary, when it wrote one. */
|
|
||||||
val description: String?,
|
|
||||||
/**
|
|
||||||
* How long the call may take, in the largest units it fits. Shown apart because it is a limit
|
|
||||||
* on the call rather than part of what the call does.
|
|
||||||
*/
|
|
||||||
val timeout: String?,
|
|
||||||
/** Everything else, as `name: value` lines. Never dropped. */
|
|
||||||
val rest: List<String>,
|
|
||||||
) {
|
|
||||||
/** The one line to show when there is only room for one: what this call is for. */
|
|
||||||
val title: String?
|
|
||||||
get() = description ?: subject
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which field of which tool is the subject.
|
|
||||||
*
|
|
||||||
* A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them
|
|
||||||
* from being the special case that gets its own code path. Unknown tools fall through to "no
|
|
||||||
* subject, everything is rest".
|
|
||||||
*/
|
|
||||||
private val SUBJECTS: Map<String, Pair<String, Language?>> =
|
|
||||||
mapOf(
|
|
||||||
"Bash" to ("command" to Language.SHELL),
|
|
||||||
"Shell" to ("command" to Language.SHELL),
|
|
||||||
"Patch" to ("diff" to Language.DIFF),
|
|
||||||
"Read" to ("file_path" to null),
|
|
||||||
"Write" to ("file_path" to null),
|
|
||||||
"Edit" to ("file_path" to null),
|
|
||||||
"Glob" to ("pattern" to null),
|
|
||||||
"Grep" to ("pattern" to null),
|
|
||||||
"WebFetch" to ("url" to null),
|
|
||||||
"WebSearch" to ("query" to null),
|
|
||||||
// Persisted transcripts keep the provider vocabulary they were written with.
|
|
||||||
"web_search" to ("query" to null),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Fields that are the tool's own prose about itself rather than input to it. */
|
|
||||||
private val DESCRIPTIONS = listOf("description", "prompt")
|
|
||||||
|
|
||||||
fun parseToolInput(tool: String, input: String): ToolInput {
|
|
||||||
if (input.trim() == "null") return ToolInput(null, null, null, null, emptyList())
|
|
||||||
val json =
|
|
||||||
try {
|
|
||||||
JSONObject(input)
|
|
||||||
} catch (_: org.json.JSONException) {
|
|
||||||
// Not an object: older transcripts and some tools send a bare string. It is still the
|
|
||||||
// input, so it is still shown. JSON null is the one exception: it means the call had
|
|
||||||
// no input, and drawing the word makes an absent value look like an instruction.
|
|
||||||
return ToolInput(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
input.takeIf { it.isNotBlank() }?.let { listOf(it) }.orEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val (subjectKey, language) = SUBJECTS[tool] ?: (null to null)
|
|
||||||
val subject =
|
|
||||||
subjectKey
|
|
||||||
?.let { json.text(it) }
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?.let { if (tool == "Bash") renderedBashScript(it) ?: it else it }
|
|
||||||
val description = DESCRIPTIONS.firstNotNullOfOrNull {
|
|
||||||
json.text(it)?.takeIf { value -> value.isNotBlank() }
|
|
||||||
}
|
|
||||||
val timeout = json.text("timeout")?.takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
|
|
||||||
val rest =
|
|
||||||
json
|
|
||||||
.keys()
|
|
||||||
.asSequence()
|
|
||||||
.filterNot(json::isNull)
|
|
||||||
.filter { it != subjectKey || subject == null }
|
|
||||||
.filter { it !in DESCRIPTIONS || description == null }
|
|
||||||
.filter { it != "timeout" || timeout == null }
|
|
||||||
.sorted()
|
|
||||||
.map { key -> "$key: ${json.get(key)}" }
|
|
||||||
.toList()
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* On the dark surface every verbatim thing in the app sits on. Drawn as nothing at all when the
|
|
||||||
* call carried neither, rather than as an empty block: a tinted rectangle with nothing in it is a
|
|
||||||
* rendering fault.
|
|
||||||
*
|
|
||||||
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
|
|
||||||
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
|
||||||
val parsed = remember(tool, input) { parseToolInput(tool, input) }
|
|
||||||
if (parsed.subject == null && parsed.rest.isEmpty()) return
|
|
||||||
RawBlock(modifier) {
|
|
||||||
parsed.subject?.let { subject ->
|
|
||||||
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
|
|
||||||
// one being read closely. The sideways scroll that makes that readable is the block's,
|
|
||||||
// shared with the lines below -- see [RawBlock].
|
|
||||||
Text(
|
|
||||||
// Not cached: a tool's subject is one command line, which lexes in microseconds --
|
|
||||||
// the cache exists for a fence with two hundred lines in it.
|
|
||||||
remember(subject, parsed.language) { highlight(subject, parsed.language) },
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
softWrap = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
parsed.rest.forEach {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
softWrap = false,
|
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,528 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
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.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CornerBasedShape
|
|
||||||
import androidx.compose.foundation.shape.CornerSize
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.layout.onPlaced
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row as the transcript draws it: a run of consecutive tool calls, or anything else.
|
|
||||||
*
|
|
||||||
* Grouping is decided here rather than when events are folded, because it is a display decision:
|
|
||||||
* the transcript's own order is what paging and the event stream depend on, and one screen's idea
|
|
||||||
* of "these belong together" must not reach back into it.
|
|
||||||
*
|
|
||||||
* Immutable, and said so, because Compose cannot tell: a row is rebuilt from the transcript rather
|
|
||||||
* than edited, and two rows describing the same events are equal. Compose infers stability from a
|
|
||||||
* class's fields, and a `List` field -- which several of these carry -- makes it assume the worst,
|
|
||||||
* so a page of history landing recomposed all 148 loaded rows including the markdown inside them,
|
|
||||||
* measured as 701 compositions for 148 rows in one scroll.
|
|
||||||
*
|
|
||||||
* The promise this makes is real and has to stay true: nothing here is mutated after it is built.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
sealed class TranscriptRow {
|
|
||||||
/**
|
|
||||||
* This row's identity in the list, which must survive everything that can happen to the row.
|
|
||||||
*
|
|
||||||
* The list is keyed by this so that inserting a new message at one end, or a page of history at
|
|
||||||
* the other, moves the rows and not the reader. When a key changes, the list loses its anchor
|
|
||||||
* and the transcript steps under whoever is reading it.
|
|
||||||
*
|
|
||||||
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
|
|
||||||
* and it is the *same* value whether the run is drawn as one card or as a group. Which value
|
|
||||||
* 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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where this row starts in the transcript: the sequence number of the oldest event behind it.
|
|
||||||
*
|
|
||||||
* Separate from [key], and deliberately so. [key] is the list's identity and is a display
|
|
||||||
* decision; a seq is the server's own numbering, assigned once and meaning the same thing to
|
|
||||||
* every device. So anything that has to point at a place in the conversation and still find it
|
|
||||||
* later -- a saved scroll position -- points with this.
|
|
||||||
*/
|
|
||||||
abstract val startSeq: Long
|
|
||||||
|
|
||||||
data class Single(val item: TranscriptItem, override val key: Any = item.key) :
|
|
||||||
TranscriptRow() {
|
|
||||||
override val startSeq: Long
|
|
||||||
get() = item.seq
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Two or more calls with nothing between them; drawn as one collapsed card. */
|
|
||||||
data class Tools(val calls: List<TranscriptItem.ToolRun>, override val key: String) :
|
|
||||||
TranscriptRow() {
|
|
||||||
override val startSeq: Long
|
|
||||||
get() = calls.first().seq
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs of adjacent tool calls become one row; everything else passes through.
|
|
||||||
*
|
|
||||||
* A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words,
|
|
||||||
* and the run this exists for is the burst of five greps nobody wants to scroll past.
|
|
||||||
*
|
|
||||||
* The last call is left alone too, and so is one still running wherever in its run it sits. What
|
|
||||||
* the session is doing, or did last, is the one thing worth seeing without opening anything, and a
|
|
||||||
* heading counting it hides it. What folds a call back into its run is therefore not finishing but
|
|
||||||
* being overtaken: anything arriving behind it, a reply included, makes it history.
|
|
||||||
*
|
|
||||||
* [heldOut] is the one thing being read can change, and only in that direction: a call standing on
|
|
||||||
* its own that somebody is reading is not overtaken while they read it. Opening a call *already*
|
|
||||||
* inside a group does not pull it out (2026-09-16, after it briefly did) -- it is visible where it
|
|
||||||
* is, and grouping is what gives a row its identity, so a rule that reads the open set both ways
|
|
||||||
* makes the reader's own tap rebuild the rows around it: three rows became one the moment a call
|
|
||||||
* was closed, and no anchor survives a row that no longer exists -- the list jumped by 450px and
|
|
||||||
* took the closed card with it. Which calls are held out is [SessionScreen]'s to say, since being
|
|
||||||
* inside a group once is what settles it.
|
|
||||||
*/
|
|
||||||
fun groupToolRuns(
|
|
||||||
items: List<TranscriptItem>,
|
|
||||||
heldOut: Set<String> = emptySet(),
|
|
||||||
): List<TranscriptRow> = DebugStats.timed("grouped tool runs") { groupRuns(items, heldOut) }
|
|
||||||
|
|
||||||
private fun groupRuns(items: List<TranscriptItem>, heldOut: Set<String>): List<TranscriptRow> {
|
|
||||||
val rows = mutableListOf<TranscriptRow>()
|
|
||||||
var run = mutableListOf<TranscriptItem.ToolRun>()
|
|
||||||
// A run can occupy more than one non-adjacent piece, so claimed keys span the whole transcript
|
|
||||||
// rather than resetting at each piece.
|
|
||||||
var runId: String? = null
|
|
||||||
val claimedKeys = mutableSetOf<String>()
|
|
||||||
|
|
||||||
fun flush() {
|
|
||||||
val first = run.firstOrNull() ?: return
|
|
||||||
// The first piece keeps the run's name, which survives a page landing in front of it
|
|
||||||
// ([adoptRun]). Later pieces qualify that name with their first call; the suffix is the
|
|
||||||
// final guard because a duplicate LazyColumn key takes down the whole screen.
|
|
||||||
var key = first.runId
|
|
||||||
if (!claimedKeys.add(key)) {
|
|
||||||
key = "${first.runId}/${first.id}"
|
|
||||||
var suffix = 2
|
|
||||||
while (!claimedKeys.add(key)) {
|
|
||||||
key = "${first.runId}/${first.id}/${suffix++}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows +=
|
|
||||||
if (run.size == 1) TranscriptRow.Single(first, key)
|
|
||||||
else TranscriptRow.Tools(run.toList(), key)
|
|
||||||
run = mutableListOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
items.forEachIndexed { index, item ->
|
|
||||||
// Grouped by the run each call says it belongs to, not by adjacency worked out here.
|
|
||||||
// Adjacency is the same answer most of the time and a worse one at the edges: a call
|
|
||||||
// arriving next to an existing run, or a page of history arriving in front of one, both
|
|
||||||
// change which call is *first*.
|
|
||||||
val call = item as? TranscriptItem.ToolRun
|
|
||||||
if (call == null || call.runId != runId) {
|
|
||||||
flush()
|
|
||||||
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()
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Several calls under one heading, closed until somebody asks.
|
|
||||||
*
|
|
||||||
* What says the calls belong together is the surface behind them, which is the one cue rather than
|
|
||||||
* two half-cues -- rounded to the same corner every other card in the app has, so a group reads as
|
|
||||||
* one object rather than as a square patch behind round things. The calls sit on it inset by
|
|
||||||
* [GROUP_INSET], which is the container's own padding rather than an indent.
|
|
||||||
*
|
|
||||||
* Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so
|
|
||||||
* the run reads as one thing broken into its parts; see [connectedShape].
|
|
||||||
*
|
|
||||||
* It closes from either end. A long group's header scrolls off while its last call is still on
|
|
||||||
* screen, and the reader who wants it shut is looking at the bottom. The bar at the foot is the
|
|
||||||
* same height as the heading at the top.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ToolGroup(
|
|
||||||
group: TranscriptRow.Tools,
|
|
||||||
expanded: Boolean,
|
|
||||||
/**
|
|
||||||
* Where it was pressed is the row's business rather than the control's -- a group has a control
|
|
||||||
* at each end, and only the row knows where its own ends are.
|
|
||||||
*/
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
isToolExpanded: (String) -> Boolean,
|
|
||||||
/**
|
|
||||||
* Toggles one call, and says where in the group it was drawn: how far down the group's own top
|
|
||||||
* edge its card begins, and how tall that card is now.
|
|
||||||
*
|
|
||||||
* The screen anchors on *rows*, and a call is not one -- but what the reader is opening or
|
|
||||||
* shutting is the call, and keeping it under their finger needs its place inside the row. Only
|
|
||||||
* the group knows that, so only the group can say it. See `SessionScreen`'s `toggleAnchored`.
|
|
||||||
*/
|
|
||||||
onToolToggle: (id: String, top: Int, height: Int) -> Unit,
|
|
||||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
|
||||||
image: @Composable (String) -> Unit,
|
|
||||||
) {
|
|
||||||
val heading = "Called ${group.calls.size} tools"
|
|
||||||
if (!expanded) {
|
|
||||||
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
|
|
||||||
Text(
|
|
||||||
heading,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
modifier = Modifier.padding(GROUP_INSET_LARGE),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val placed = remember { Placed() }
|
|
||||||
Column(
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.onPlaced { placed.top = it.positionInRoot().y }
|
|
||||||
.clip(MaterialTheme.shapes.medium)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
|
||||||
) {
|
|
||||||
val barHeight = groupBarHeight()
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth().height(barHeight).clickable(onClick = onToggle),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
heading,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
modifier = Modifier.padding(horizontal = GROUP_INSET_LARGE),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
Modifier.padding(horizontal = GROUP_INSET),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
|
|
||||||
) {
|
|
||||||
group.calls.forEachIndexed { index, call ->
|
|
||||||
val card = remember(call.id) { Placed() }
|
|
||||||
ToolCard(
|
|
||||||
tool = call,
|
|
||||||
expanded = isToolExpanded(call.id),
|
|
||||||
onToggle = {
|
|
||||||
onToolToggle(call.id, (card.top - placed.top).toInt(), card.height)
|
|
||||||
},
|
|
||||||
onAnswer = onAnswer,
|
|
||||||
image = image,
|
|
||||||
shape = connectedShape(index, group.calls.size),
|
|
||||||
modifier =
|
|
||||||
Modifier.onPlaced { card.top = it.positionInRoot().y }
|
|
||||||
.onSizeChanged { card.height = it.height },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Shutting it from here anchors the other end: the reader is at the bottom of a long group,
|
|
||||||
// and what they are looking at is what follows it.
|
|
||||||
CollapseBar(barHeight, onToggle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* Derived from the type the heading is set in rather than written down, because the two have to
|
|
||||||
* match and a pair of numbers chosen to look equal stops being equal the moment the density
|
|
||||||
* changes.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun groupBarHeight(): Dp {
|
|
||||||
val line = MaterialTheme.typography.titleSmall.lineHeight
|
|
||||||
return with(LocalDensity.current) { line.toDp() } + GROUP_INSET_LARGE * 2
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The bottom half of a group's toggle: an arrow back up to its heading. Given the heading's height
|
|
||||||
* rather than padded to something that looks close, so the surface the calls sit on is the same
|
|
||||||
* thickness at both ends.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
|
|
||||||
val colour = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth().height(height).clickable(onClick = onToggle).semantics {
|
|
||||||
contentDescription = "Collapse these tool calls"
|
|
||||||
},
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Chevron(Pointing.Up, colour = colour)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The shape of one card in a stack of [count]: square where it faces a neighbour, rounded where it
|
|
||||||
* does not.
|
|
||||||
*
|
|
||||||
* Written once and given an index rather than branched at each end, because a stack has three cases
|
|
||||||
* that are one rule -- and the middle one is what a hand-written first/last pair gets wrong.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun connectedShape(index: Int, count: Int): CornerBasedShape {
|
|
||||||
val shape = MaterialTheme.shapes.medium
|
|
||||||
val square = CornerSize(0.dp)
|
|
||||||
return shape.copy(
|
|
||||||
topStart = if (index == 0) shape.topStart else square,
|
|
||||||
topEnd = if (index == 0) shape.topEnd else square,
|
|
||||||
bottomStart = if (index == count - 1) shape.bottomStart else square,
|
|
||||||
bottomEnd = if (index == count - 1) shape.bottomEnd else square,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The padding inside a card, and so the height a bar of one line of text comes to. */
|
|
||||||
private val GROUP_INSET_LARGE = 12.dp
|
|
||||||
|
|
||||||
/** How far the stack of calls is held off the edge of the surface it sits on. */
|
|
||||||
private val GROUP_INSET = 4.dp
|
|
||||||
|
|
||||||
/** Enough to read the join as a join rather than as one tall card. */
|
|
||||||
private val GROUP_GAP = 2.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One tool call.
|
|
||||||
*
|
|
||||||
* Closed, it is a single line: the tool's name and what the call is for. The command itself is not
|
|
||||||
* on it, because a wrapped command turns one row into four and a run of them into a wall.
|
|
||||||
*
|
|
||||||
* Open, it shows the command, whatever else the input carried, and the output. The timeout sits at
|
|
||||||
* the top right: it is a limit on the call rather than part of what the call does.
|
|
||||||
*
|
|
||||||
* A call waiting on permission is shown open whatever the reader last chose, since the command is
|
|
||||||
* the thing being decided and a row saying only "Bash" cannot be decided on.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ToolCard(
|
|
||||||
tool: TranscriptItem.ToolRun,
|
|
||||||
expanded: Boolean,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
|
||||||
image: @Composable (String) -> Unit = {},
|
|
||||||
/** Square where this card faces another in a group; see [connectedShape]. */
|
|
||||||
shape: Shape = CardDefaults.shape,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
|
|
||||||
val name = toolDisplayName(tool.tool)
|
|
||||||
val output = toolDisplayOutput(tool.tool, tool.output)
|
|
||||||
val deciding = tool.asks.any { it.answers.isEmpty() }
|
|
||||||
val open = expanded || deciding
|
|
||||||
Card(modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) {
|
|
||||||
Column(Modifier.padding(GROUP_INSET_LARGE)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(name, style = MaterialTheme.typography.titleSmall)
|
|
||||||
if (open) {
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
parsed.timeout?.let {
|
|
||||||
Text(
|
|
||||||
"timeout $it",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parsed.title?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f).padding(start = 8.dp),
|
|
||||||
)
|
|
||||||
} ?: Spacer(Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
// A spinner says the machine is working. While this call is waiting on an answer
|
|
||||||
// the machine is doing nothing at all -- the turn is stopped on the person reading
|
|
||||||
// it -- so it says whose move it is instead.
|
|
||||||
if (deciding) {
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
"your turn",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = awaitingColor,
|
|
||||||
)
|
|
||||||
} else if (!tool.done) {
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.width(16.dp).height(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (open) {
|
|
||||||
parsed.description?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Everything AskUserQuestion carries is the questions, and those are drawn below as
|
|
||||||
// something answerable; dumping the same JSON above them would be the decision
|
|
||||||
// stated twice, once unreadably.
|
|
||||||
if (tool.tool != ASK_USER_QUESTION) {
|
|
||||||
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
|
|
||||||
}
|
|
||||||
if (output.isNotEmpty()) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text("Output", style = MaterialTheme.typography.labelSmall)
|
|
||||||
// What the tool printed, on the surface everything verbatim gets and in the
|
|
||||||
// face it was written for: this is column-aligned far more often than it is
|
|
||||||
// prose, and a proportional font silently destroys the alignment that carried
|
|
||||||
// the meaning. Unwrapped for the same reason, and scrolled sideways by the
|
|
||||||
// block around it -- see [RawBlock].
|
|
||||||
//
|
|
||||||
// Its terminal styling applied and the rest of the escapes taken out: colour is
|
|
||||||
// often the whole of what a diff or a test run is saying. Remembered against
|
|
||||||
// the text, so a card that is open through a scroll parses once.
|
|
||||||
val palette = remember { ansiPalette() }
|
|
||||||
val styled = remember(output, palette) { ansiStyled(output, palette) }
|
|
||||||
RawBlock(Modifier.padding(top = 2.dp)) {
|
|
||||||
Text(
|
|
||||||
styled,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
softWrap = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Shown open or closed. A call that produced a picture is one whose result *is* the
|
|
||||||
// picture, and a row that hides it says less than the one line it replaced.
|
|
||||||
tool.images.forEach { ref -> image(ref) }
|
|
||||||
if (tool.asks.isNotEmpty()) {
|
|
||||||
if (tool.tool == ASK_USER_QUESTION) {
|
|
||||||
AskUserQuestionBody(tool.asks, onAnswer)
|
|
||||||
} else {
|
|
||||||
tool.asks.forEach { ask -> PermissionAsk(ask, onAnswer) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* Only the question, not the prompt's second half: the backend sends the tool's input with it so
|
|
||||||
* the ask can stand alone, and here it does not have to -- the card above is showing exactly that.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun PermissionAsk(
|
|
||||||
ask: TranscriptItem.QuestionCard,
|
|
||||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
|
||||||
) {
|
|
||||||
// What was pressed, before the answer has been round-tripped. Two bare words with no submit
|
|
||||||
// step -- unlike a question card, where the answer is worth reviewing -- so the press has to be
|
|
||||||
// its own acknowledgement or the row sits unchanged for a round trip. Cleared when the request
|
|
||||||
// settles: by then either the answer is in `ask.answers`, or it failed and the buttons come
|
|
||||||
// back.
|
|
||||||
var pressed by remember(ask.id) { mutableStateOf<String?>(null) }
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
ask.prompt.substringBefore('\n'),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = awaitingColor,
|
|
||||||
)
|
|
||||||
// Answered or not, the options stay and the one that was taken is marked -- see
|
|
||||||
// [AskedQuestion], which is the same rule on the question card. A permission is where it
|
|
||||||
// matters most: "Answered: Deny" alone does not say that Allow was the alternative.
|
|
||||||
val settled = ask.answers.isNotEmpty()
|
|
||||||
AnswerOptions(
|
|
||||||
ask.options,
|
|
||||||
if (settled) ask.answers else listOfNotNull(pressed),
|
|
||||||
onPick =
|
|
||||||
if (settled || pressed != null) null
|
|
||||||
else
|
|
||||||
{ label ->
|
|
||||||
pressed = label
|
|
||||||
onAnswer(listOf(QuestionAnswer(ask.id, listOf(label)))) { pressed = null }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The tool whose input is a question rather than a command; see [AskUserQuestionBody].
|
|
||||||
*
|
|
||||||
* Also what [runIdFor] breaks a run of calls on, so the row a reader answered is never folded
|
|
||||||
* inside a collapsed group.
|
|
||||||
*/
|
|
||||||
const val ASK_USER_QUESTION = "AskUserQuestion"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where one transcript lives: a session's own, or one of its subagents'.
|
|
||||||
*
|
|
||||||
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
|
|
||||||
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
|
|
||||||
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
|
|
||||||
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
|
|
||||||
*/
|
|
||||||
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
|
|
||||||
/** The URL segment naming this transcript, before `/transcript` or `/events`. */
|
|
||||||
val urlPath: String
|
|
||||||
get() =
|
|
||||||
if (subagentId == null) "sessions/$sessionId"
|
|
||||||
else "sessions/$sessionId/subagents/$subagentId"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where this transcript's cache lives on the phone, relative to the cache root.
|
|
||||||
*
|
|
||||||
* A subagent's nests under its session's directory rather than sitting beside it, so deleting a
|
|
||||||
* session's cache directory takes its subagents' with it -- the same one-way door the server's
|
|
||||||
* own storage describes.
|
|
||||||
*/
|
|
||||||
val cachePath: String
|
|
||||||
get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId"
|
|
||||||
}
|
|
||||||
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user