Compare commits
516
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6450615434 | ||
|
|
d24ff83d6b | ||
|
|
ea66fc3f71 | ||
|
|
e397680a19 | ||
|
|
b788cd1382 | ||
|
|
338dd00f76 | ||
|
|
a7da12a8ff | ||
|
|
5f01dc65c8 | ||
|
|
17423a57b8 | ||
|
|
ddaf9b37d6 | ||
|
|
992a4a2e97 | ||
|
|
e6a035d06e | ||
|
|
048738bb9d | ||
|
|
4c7072e62f | ||
|
|
ab8b05b762 | ||
|
|
149a6838af | ||
|
|
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 | ||
|
|
cf10b17c5b | ||
|
|
7ae53ad797 | ||
|
|
d17040b601 | ||
|
|
bf5087a598 | ||
|
|
9fa09b0af1 | ||
|
|
aa3d11471f | ||
|
|
78aff64844 | ||
|
|
9a33cb5384 | ||
|
|
45ced405f3 | ||
|
|
62199aa3a7 | ||
|
|
b133d85943 | ||
|
|
ba6817fee5 | ||
|
|
73ee63bc1b | ||
|
|
8f0aec449a | ||
|
|
6d5fd64bb0 | ||
|
|
e5880c33f4 | ||
|
|
a853eb5a4d | ||
|
|
eff5c8b0c0 | ||
|
|
7b63330aaa | ||
|
|
22d5c6585a | ||
|
|
b063fbd7f9 | ||
|
|
3f25e7ebca | ||
|
|
0af4c88d08 | ||
|
|
ceabd00805 | ||
|
|
32a5256a0d | ||
|
|
4cfe0ef6e6 | ||
|
|
c9b273ff16 | ||
|
|
8adda94a7a | ||
|
|
3a9208f38b | ||
|
|
e898370bf4 | ||
|
|
6e0bd06e4d | ||
|
|
03da47e550 | ||
|
|
a2cd119985 | ||
|
|
19c36e37f2 | ||
|
|
6bdec6e785 | ||
|
|
e2873df92e | ||
|
|
ea13889a21 | ||
|
|
6317685d1a | ||
|
|
f79bd7ca71 | ||
|
|
9c935f8ce8 | ||
|
|
982449293d | ||
|
|
288853c094 | ||
|
|
fba572427d | ||
|
|
85ec5416b6 | ||
|
|
643daf5637 | ||
|
|
8db0184384 | ||
|
|
1a6599e1b2 | ||
|
|
0a2f4fa1fe | ||
|
|
237886c11e | ||
|
|
e8dbcaa7db | ||
|
|
26163b25b2 | ||
|
|
762c1290a1 | ||
|
|
62dd6b7912 | ||
|
|
bc3db183e3 | ||
|
|
e0a473e090 | ||
|
|
1c937e2f48 | ||
|
|
d194d73439 | ||
|
|
4400966928 | ||
|
|
4821a02bd3 | ||
|
|
6e49ce8c92 | ||
|
|
1ff662c7c3 | ||
|
|
79b9cd789a | ||
|
|
c70a670356 | ||
|
|
43743ba171 | ||
|
|
1a97d0ef5c | ||
|
|
ff7e9c0435 | ||
|
|
68a7f41ed0 | ||
|
|
9b331a5e93 | ||
|
|
3fc224b584 | ||
|
|
10500ae8aa | ||
|
|
8d441d3d59 | ||
|
|
e4f0935f98 | ||
|
|
3c0214ece8 | ||
|
|
e0ee7d6e94 | ||
|
|
b6b0928087 | ||
|
|
12221ea025 | ||
|
|
5e23c8b0c0 | ||
|
|
caaa733caa | ||
|
|
127b25e60a | ||
|
|
4ab26f068e | ||
|
|
0f8ba49f4a | ||
|
|
1fcaa2d72d | ||
|
|
edc39c7371 | ||
|
|
79682f03a7 | ||
|
|
e3e02d55f7 | ||
|
|
a802522039 | ||
|
|
74110b4d72 | ||
|
|
45e631ab96 | ||
|
|
7997eeb7f8 | ||
|
|
68c5180260 | ||
|
|
a401e6a7e3 | ||
|
|
7b08a71e64 | ||
|
|
457907087c | ||
|
|
a074975d6f | ||
|
|
ffc266bf3e | ||
|
|
121a47da6e | ||
|
|
2c12274285 | ||
|
|
9c4d33273b | ||
|
|
db55ed4a8f | ||
|
|
8881a40919 |
No files matched your search
@@ -0,0 +1,244 @@
|
|||||||
|
---
|
||||||
|
name: ai-app-rigs
|
||||||
|
description: ai-app's test rigs, harness scripts and reference measurements - ui-sandbox.sh, debug-transcript.sh, transcript-bench.sh, stream-bench.sh, trace-draw.sh, the /usage fixture vocabulary, the fake CLI, the rule that no UI-driving script may tap a coordinate, how to test llama.cpp and ssh on this machine, how importing behaves, and the scroll/stream/explorer numbers not worth re-measuring. Read before running or writing a benchmark, driving the app's UI from a script, exercising the session lifecycle, testing a llama or remote session, or touching the import screen.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ai-app: rigs, harnesses and measurements
|
||||||
|
|
||||||
|
Moved out of `AGENTS.md` on 2026-09-04 so it is read when it is relevant
|
||||||
|
rather than sent with every request in this repo -- it was 12 KB of the 35 KB
|
||||||
|
that file cost on every one. Unchanged in the move, and still the only copy.
|
||||||
|
|
||||||
|
## The rigs
|
||||||
|
|
||||||
|
Each exists because something was invisible without it.
|
||||||
|
|
||||||
|
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
|
||||||
|
and data directory, holding eight invented Claude Code transcripts and a
|
||||||
|
`claude` that is two lines of shell. **That isolation is the point**: the
|
||||||
|
import screen lists whatever is in `~/.claude/projects`, which in this VM is
|
||||||
|
real agent transcripts, so exercising *delete* against the ordinary server
|
||||||
|
deletes somebody's conversation and exercising *import* starts a real
|
||||||
|
`--resume` on the owner's account.
|
||||||
|
Its port and root derive from the checkout's name, so two checkouts'
|
||||||
|
sandboxes cannot reach each other, and its token is generated once into
|
||||||
|
`~/.config/ai-app/sandbox-token` and carried across restarts along with any
|
||||||
|
the enrolment flow appended — so the emulator app is enrolled **once** (the
|
||||||
|
start banner prints the command) and stays enrolled. It shares the real TLS
|
||||||
|
certificates, because the installed APK pins that CA.
|
||||||
|
Driving verbs, so none of this is re-derived per session:
|
||||||
|
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
|
||||||
|
`./ui-sandbox.sh send SID text|@file`, and
|
||||||
|
`./ui-sandbox.sh api /path [curl args]`.
|
||||||
|
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
|
||||||
|
enrolment already there — for when the fixture under test was expensive to
|
||||||
|
build; plain `start` wipes them, which is right for the list-screen
|
||||||
|
fixtures and wrong for that.
|
||||||
|
It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large
|
||||||
|
transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the
|
||||||
|
fake CLI slow to start. Both exist because operations that finish in
|
||||||
|
milliseconds have states on the way that nothing can observe, and an
|
||||||
|
unobservable state is one where broken and working look identical.
|
||||||
|
It also builds a fixture tree at the sandbox home's `~/files` for the
|
||||||
|
explorer, holding the states otherwise only reachable by finding a real
|
||||||
|
machine in one: an empty directory, a name with a tab and one with an
|
||||||
|
apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a
|
||||||
|
symlink to a directory and a broken one, a source file per language, and
|
||||||
|
the three sizes the limits were measured against (`edit-32k.rs`,
|
||||||
|
`edit-128k.rs`, `big-source.rs`). Point a session at it with
|
||||||
|
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
|
||||||
|
The explorer's 409 is produced by editing the file on the machine
|
||||||
|
(`printf … > file`) between pressing the pencil and pressing save.
|
||||||
|
- **`app/debug-transcript.sh`** — a real conversation on the emulator. The
|
||||||
|
echo driver is the right rig for most things and the wrong one for anything
|
||||||
|
whose cost scales with what was actually written: a real reply is longer,
|
||||||
|
is real markdown, and carries tool calls whose input and output are
|
||||||
|
kilobytes. Two faults were invisible until a real transcript was loaded — a
|
||||||
|
page of history landing mid-fling threw the reader back to the newest end,
|
||||||
|
and parsing one real reply took 51ms against 4.6ms for a synthetic one.
|
||||||
|
`-b` takes the biggest conversation on the machine rather than the newest,
|
||||||
|
which is what a scrolling test wants; `--stop` takes it down.
|
||||||
|
It copies the transcript into `/tmp` and gives the server a `HOME` of its
|
||||||
|
own, so the import can only see the copy — importing spawns `claude
|
||||||
|
--resume`, and against the real file that is a second CLI writing to a
|
||||||
|
conversation somebody may still be in. **A transcript never goes in this
|
||||||
|
repository**: they hold whatever was said, read and written in that
|
||||||
|
session, and `~/repos` is shared with the host besides.
|
||||||
|
- **`/usage` in an echo session puts up an invented meter**, which is how the
|
||||||
|
rate-limit screens' states are reached without spending quota: `/usage 42`,
|
||||||
|
`/usage 95 20` (minutes left), `/usage 42 never` (the between-blocks window
|
||||||
|
with no reset time), `/usage 42 unreadable`, `/usage notloggedin`,
|
||||||
|
`/usage unreachable`, `/usage failed`, `/usage off`. The vocabulary is
|
||||||
|
`usage::Fixture`'s, since those are its states. With none set an echo
|
||||||
|
session meters nothing, which is the ordinary case and draws no bar.
|
||||||
|
- **A fake CLI exercises the process lifecycle without a token.** Point a
|
||||||
|
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
|
||||||
|
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
|
||||||
|
it holds the fifo open, records a real pid, writes nothing, and dies on a
|
||||||
|
signal. So adopt, stop, restart and start are all drivable without a real
|
||||||
|
`--resume` and without spending a turn on somebody's account. Reach for
|
||||||
|
this when what is under test is *whether a process is running*, and for
|
||||||
|
`debug-transcript.sh` when it is *what the transcript draws*.
|
||||||
|
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
|
||||||
|
the first session (or `-k` keeps the current screen), scrolls a fixed
|
||||||
|
gesture loop, and prints the app's render report — the same one the in-app
|
||||||
|
copy button produces, whose `on screen:` line names what the viewport was
|
||||||
|
holding. Compare two runs with the same gestures; the emulator's absolute
|
||||||
|
frame times transfer nothing, the report's accounting does. Run it either
|
||||||
|
side of any change under `Markdown*.kt`, `Transcript*.kt` or
|
||||||
|
`SessionScreen.kt`'s list, and put the report in the commit. The numbers
|
||||||
|
that move first are the worst `record: one block`, the reparse mean while
|
||||||
|
streaming, and the draw phase's accounting line.
|
||||||
|
- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still
|
||||||
|
arriving. It taps "Jump to latest" so the list is pinned to the newest end,
|
||||||
|
resets the report, sends FILE, waits for the transcript to stop growing,
|
||||||
|
and prints. Both of those are corrections to a first version that measured
|
||||||
|
nothing: a transcript parked further back never redraws while a reply
|
||||||
|
streams into it, and a session is idle at *both* ends of a turn, so polling
|
||||||
|
for idle answers before the turn has started.
|
||||||
|
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
|
||||||
|
framework, from `atrace` text output with no trace processor needed. It is
|
||||||
|
how the cost of a layout node per link was attributed to the framework
|
||||||
|
rather than guessed at.
|
||||||
|
|
||||||
|
### Driving the UI
|
||||||
|
|
||||||
|
**No script that drives this app's UI presses a coordinate.** Every control
|
||||||
|
is found by the name it already carries for assistive technology —
|
||||||
|
`ui-trace record --do "tap 'Session settings'"` — which resolves the label
|
||||||
|
against the screen at the moment of the gesture and fails the whole run when
|
||||||
|
it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A
|
||||||
|
coordinate is a position measured once by hand, and anything that moves the
|
||||||
|
control makes the tap land on whatever now sits there — the bench then
|
||||||
|
reports a number that was never measured, which reads exactly like a result.
|
||||||
|
Both bench scripts pressed the render report at `tap 723 205` until that
|
||||||
|
button moved into the session settings dialog on 2026-09-03. The check that
|
||||||
|
none has crept back:
|
||||||
|
|
||||||
|
grep -n "tap [0-9]" app/*.sh
|
||||||
|
|
||||||
|
Swipes are still coordinates, deliberately: a gesture across a scrolling area
|
||||||
|
is a distance rather than a control.
|
||||||
|
|
||||||
|
**Two traps in the emulator bench loop**, each of which cost a run.
|
||||||
|
`adb shell pm clear` removes the enrolment and the notification permission
|
||||||
|
along with the saved anchors, so the next run measures a permission dialog —
|
||||||
|
re-enrol with the command `ui-sandbox.sh` prints, and
|
||||||
|
`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id,
|
||||||
|
so the only way two builds start a scroll from the same place is a *fresh
|
||||||
|
session for each*.
|
||||||
|
|
||||||
|
**The emulator is `~/repos/emulator-tools`' business, not this repo's.**
|
||||||
|
`emu up` creates and boots the AVD named after this checkout — whatever `emu
|
||||||
|
name` prints, never a name typed out here, since this file is the same in
|
||||||
|
every clone. `run-android.sh` is that plus a build and an install. The `adb`
|
||||||
|
on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills
|
||||||
|
in `-s` from the same rule. Gradle does not go through it, so a Gradle init
|
||||||
|
script from `emulator-tools` runs `emu check` before `installDebug`,
|
||||||
|
`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out
|
||||||
|
to every attached device; when it refuses, say which device you mean at the
|
||||||
|
moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
|
||||||
|
|
||||||
|
### Testing llama.cpp and ssh here
|
||||||
|
|
||||||
|
**Both are set up here as of 2026-09-04** and need nothing typed. The
|
||||||
|
prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp`
|
||||||
|
(the 15 MB `ubuntu-x64` release asset) and is symlinked as
|
||||||
|
`/usr/local/bin/llama-server`, which is what makes **discovery find it over
|
||||||
|
ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets.
|
||||||
|
It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is
|
||||||
|
needed. One model is downloaded — `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`,
|
||||||
|
639 MB under `~/.local/share/ai-app/models` — and answers at usable speed on
|
||||||
|
this VM's 8 cores. **Do not test with a 2-bit quant**: the
|
||||||
|
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
|
||||||
|
broken driver — `llama-cli` produces the same from the file directly, which
|
||||||
|
is how to tell the two apart in a hurry.
|
||||||
|
|
||||||
|
There is no second machine, so **ssh this VM to itself**. That is set up
|
||||||
|
too: the key is `~/.config/ai-app/ssh-self` (its public half is in
|
||||||
|
`~/.ssh/authorized_keys`, labelled removable), and the real config carries a
|
||||||
|
setup called **"this vm over ssh"** — `bob@127.0.0.1` with that
|
||||||
|
`identityFile` plus
|
||||||
|
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
|
||||||
|
so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the
|
||||||
|
whole rig for "does a remote llama session work", since the far machine is
|
||||||
|
this one and the model file is the same file. For a throwaway setup of your
|
||||||
|
own, point a provider's `command` at something harmless like `/bin/echo`
|
||||||
|
rather than at `claude`: the transport is what is under test, the process
|
||||||
|
exiting immediately is the signal, and it costs no tokens. The remote login
|
||||||
|
shell here is **fish**; the
|
||||||
|
remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in
|
||||||
|
both, but that is luck rather than design, and a shell that is neither is the
|
||||||
|
thing to suspect first if a remote spawn ever mangles an argument.
|
||||||
|
|
||||||
|
## Importing
|
||||||
|
|
||||||
|
The import list reports each session's **size as well as its line count**,
|
||||||
|
because the two disagree in the way that matters: these transcripts embed
|
||||||
|
screenshots as base64, so one line can be a megabyte. On this machine a 69 MB
|
||||||
|
session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line
|
||||||
|
count tells you what continuing a session will cost. Shown, not warned about;
|
||||||
|
importing a large session is a choice somebody is entitled to make.
|
||||||
|
|
||||||
|
**Never import a Claude Code session that is open in a terminal.** The app
|
||||||
|
refuses it — see PLAN.md for the incident that made that a refusal rather
|
||||||
|
than a warning.
|
||||||
|
|
||||||
|
**One Claude Code session id can name two files, and the listing offers it
|
||||||
|
once.** Resuming from a different working directory makes the CLI write a
|
||||||
|
second transcript with the same id under that directory's project folder — an
|
||||||
|
ordinary state of a machine, not corruption. Everything downstream addresses
|
||||||
|
a session by id, and the phone keyed its list on it, so two rows sharing one
|
||||||
|
**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps
|
||||||
|
the copy with the most lines, because the other is usually a few-hundred-byte
|
||||||
|
stub and is often the *newer* of the two, so recency is the wrong key.
|
||||||
|
Deleting removes every copy rather than the first, or the row came back after
|
||||||
|
a delete that reported success. The phone's half is `uniqueItems`, which
|
||||||
|
every list keyed on a server-chosen id goes through: a repeat there must
|
||||||
|
never be able to close the app, whatever produced it.
|
||||||
|
|
||||||
|
**Deleting a session offers to take the machine's own transcript with it** —
|
||||||
|
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
|
||||||
|
confirmation, and only where the driver keeps a record of its own
|
||||||
|
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
|
||||||
|
because leaving that copy is what makes an ordinary delete recoverable — and
|
||||||
|
the dialog's paragraph is rewritten when it is on rather than appended to,
|
||||||
|
since the sentence promising the conversation "should still be there to
|
||||||
|
import again" is exactly the one the switch makes false. The server deletes
|
||||||
|
the machine's copy *first*, so a machine it cannot reach leaves the session
|
||||||
|
where it was instead of half-deleted.
|
||||||
|
|
||||||
|
## Measurements worth not re-taking
|
||||||
|
|
||||||
|
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
|
||||||
|
emulator against a real imported transcript with the server at
|
||||||
|
`--delay 120`. Settled and flinging fast, both into fresh history and back
|
||||||
|
through rows already drawn: **5.2–5.9% janky frames, 99th percentile
|
||||||
|
29–32ms, 0–2 slow UI-thread frames.** The stock Settings app on the same
|
||||||
|
device is 3.3% and 38ms, so this is at the platform floor. The number that
|
||||||
|
is *not* at the floor is the first few seconds after opening a session,
|
||||||
|
where every row on the way is being composed for the first time; that is
|
||||||
|
inherent to a lazy list and it is why a measurement taken before the screen
|
||||||
|
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
|
||||||
|
- **The reset path is not reachable by reopening a session.** Measured
|
||||||
|
2026-09-04 against a session streaming at 20 events a second: reopening one
|
||||||
|
with an anchor 1,800 events back connects **87–119 events behind**, well
|
||||||
|
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
|
||||||
|
opening page, then one span covering the whole distance. To exercise the
|
||||||
|
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
|
||||||
|
the app takes the reset on a live connection, clears, refills and carries
|
||||||
|
on without reconnecting.
|
||||||
|
- **The session screen's stream survives backgrounding here** — 20 seconds at
|
||||||
|
the launcher while 415 events were produced brought no reconnect at all,
|
||||||
|
which is not what the comment above that loop expects, and is most likely
|
||||||
|
this emulator being headless rather than the phone's behaviour.
|
||||||
|
- **Reopening a cached session costs one request for one event** (the probe),
|
||||||
|
and scrolling the whole conversation back costs nothing more; a cold open
|
||||||
|
of the same 500-event session is two pages, 100 events. Measured
|
||||||
|
2026-09-04 on the emulator against the sandbox.
|
||||||
|
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
|
||||||
|
28,000-line file because it draws one row per line; the editor is one
|
||||||
|
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
|
||||||
|
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
|
||||||
|
screen. If you make the editor faster, that number is what to move.
|
||||||
|
EXPLORER.md's "What the measurements said" has the rest.
|
||||||
+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,840 +1,183 @@
|
|||||||
# ai-app
|
# ai-app
|
||||||
|
|
||||||
A phone interface to AI coding sessions (Claude Code and llama.cpp via pi),
|
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.
|
||||||
|
|
||||||
**`TRANSCRIPT_RENDERING.md` is the record of the transcript work** --
|
`docs/PLAN.md` is the design source of truth. Read it before structural work
|
||||||
measurements, techniques, the harness, and the ordered list of what is
|
and update it when a decision changes. `docs/HANDOFF.md` is where the work in
|
||||||
next. Read it before touching anything under `Markdown*.kt`,
|
flight stands; read it first in a fresh session and keep it current. Working
|
||||||
`Transcript*.kt` or `SessionScreen.kt`'s list.
|
documents are pruned as work lands: preserve current invariants, measurements,
|
||||||
|
and failed hypotheses, not a chronicle of completed tasks. Do not create a
|
||||||
|
decisions log.
|
||||||
|
|
||||||
**`PLAN.md` is the design source of truth.** Read it before building or
|
## Architecture
|
||||||
changing anything structural. It records every decision with its date, its
|
|
||||||
rationale, and the alternatives that were rejected and why — keep that habit
|
|
||||||
when a decision changes: update the plan in place, don't let this file and
|
|
||||||
the plan drift into two versions of the truth. This file is the working notes
|
|
||||||
layer: conventions, commands, and things that have bitten.
|
|
||||||
|
|
||||||
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 speaking JSONL over stdio, translated into one common event
|
model. A new session type is a new driver, never a session-type branch in
|
||||||
model.** Claude Code (stream-json) and pi (RPC mode) are two translators
|
shared routes, transcripts, or screens.
|
||||||
behind one `Driver` trait; the transcript, the SSE stream, the phone UI, and
|
|
||||||
SSH spawning (the same command wrapped in `ssh host …`) all work purely in
|
Android and desktop share `app/src/client` and `app/src/ui`. Platform modules
|
||||||
the common model. A new session type is a new driver — never a
|
own only what the platform forces: JNI, lifecycle, insets and IME on one side;
|
||||||
session-type branch in shared code (routes, transcript, app screens).
|
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,
|
- `event-model/` — the wire contract shared by server and app.
|
||||||
single `:androidApp` module), same cert scheme, same registry pattern (every
|
- `app/` — the `ai-app` crate. `client` is platform/UI independent; `ui`
|
||||||
session mutation funnels through the manager so in-memory and on-disk state
|
contains Iris widget trees; `android` and `desktop` are thin hosts.
|
||||||
can't come apart). Read dev-updater's `README.md` and `AGENTS.md` for the
|
`android-project/` packages the Rust cdylib. The `bench` feature and
|
||||||
conventions before diverging from them; module-by-module intent for this
|
`bench-fixture/` are retained performance rigs, not a second app.
|
||||||
repo is in PLAN.md's "Backend layout" section.
|
- `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/src/session/import.rs` — continuing a Claude Code session the
|
Clone with `--recurse-submodules` or run `git submodule update --init` to
|
||||||
machine already has. Claude Code keeps each one as JSONL under
|
populate both `iris/` and `wg-app-link/`.
|
||||||
`~/.claude/projects/`, and the CLI resumes one with `--resume <id>` —
|
|
||||||
which `claude.rs` already does for crash recovery, so an import is that
|
|
||||||
same path with the token written up front rather than a second way to
|
|
||||||
start a session. The phone picks an **id**, never a path: the server
|
|
||||||
resolves which file that is, so an enrolled token cannot become "read me
|
|
||||||
an arbitrary file" — the same rule that keeps a command out of
|
|
||||||
`POST /setups`. Only the tail is replayed (`REPLAY_LINES`) because these
|
|
||||||
files reach tens of megabytes and the CLI reads the real one itself; what
|
|
||||||
crosses the tunnel is what a person reads, not what the model is given.
|
|
||||||
Images in the replayed tail are written into the session's `files/` by
|
|
||||||
the same function the live translator uses, so a screenshot looks the
|
|
||||||
same whether it was watched happening or replayed afterwards, and the
|
|
||||||
phone fetches the bytes only when it draws one.
|
|
||||||
An imported session then **keeps itself level with that file**, so work
|
|
||||||
done at a terminal appears without anyone pressing anything. Which new
|
|
||||||
lines came from *here* is answered by counting the events this session
|
|
||||||
has recorded, **not** by looking at its status — a turn that starts and
|
|
||||||
finishes between two polls reads as idle at both, and its own output
|
|
||||||
gets replayed on top of itself. That bug was visible on screen as
|
|
||||||
`donedone`.
|
|
||||||
- `server/src/files.rs` — the file explorer's half of the backend:
|
|
||||||
listing a directory, reading a file, writing one, creating a file or a
|
|
||||||
directory, on whichever machine a setup names. Each is one small POSIX
|
|
||||||
script run through `Transport`, so the local and the ssh case are the
|
|
||||||
same code and a machine the backend cannot reach fails with ssh's own
|
|
||||||
message. The path is a **positional argument**, never text spliced into
|
|
||||||
the script; `PATH_PRELUDE` is the one line that gives a leading `~` its
|
|
||||||
meaning, since a shell expands a tilde in text and not in an argument.
|
|
||||||
A read has four answers — `text`, `binary`, `tooBig`, or the machine's
|
|
||||||
own error — because a binary file drawn as text and a big one cut off
|
|
||||||
silently are both wrong in ways the reader cannot see. A write carries
|
|
||||||
the sha256 the read reported and is refused (409) when the file has
|
|
||||||
moved on, which is the ordinary case when an agent is editing the same
|
|
||||||
file. `EXPLORER.md` is the design.
|
|
||||||
- `server/src/usage.rs` — rate-limit windows, asked **of each machine that
|
|
||||||
can run Claude**, not of the backend. Credentials are read through the
|
|
||||||
session `Transport`, so a remote setup is an ssh round trip and the local
|
|
||||||
one is unchanged; the HTTP call stays here. A machine with no Claude
|
|
||||||
provider is never asked. The four states (`ok`, `notLoggedIn`,
|
|
||||||
`unreachable`, `failed`) exist because a machine nobody logged in on is a
|
|
||||||
choice rather than a fault, and one `error` string made it look like one.
|
|
||||||
- `server/src/models.rs` — downloaded GGUF models and the HuggingFace
|
|
||||||
browsing behind them. Downloads are keyed by the model rather than by
|
|
||||||
who asked, so any device can watch one; they resume through HTTP Range,
|
|
||||||
refuse to resume onto a partial from a different revision, and are
|
|
||||||
checked against HuggingFace's published sha256 before the file gets its
|
|
||||||
real name.
|
|
||||||
- **Attachments** are one list on a user message (`attachments`, the
|
|
||||||
ref the files route serves), in two shapes. An image is `<hex>.<ext>`
|
|
||||||
and goes to the model as an image block. Anything else is
|
|
||||||
`<hex>-<name>` -- the name it was shared or picked under, cleaned by
|
|
||||||
`safe_file_name` -- and the Claude driver appends `Attached file:
|
|
||||||
/abs/path` to the message text, since the CLI reads files by path and
|
|
||||||
a model cannot be shown a trace. `media::media_type_for` on the server
|
|
||||||
and `isImageRef` on the phone tell the two apart; keep those lists
|
|
||||||
level. The phone attaches from the photo picker, the file chooser and
|
|
||||||
Android's share sheet (`Share.kt`; the manifest's SEND filter), all
|
|
||||||
through one `attach` path in `SessionScreen`, streamed both from the
|
|
||||||
phone and onto disk. A file for a session on another machine is also
|
|
||||||
copied there during the upload (setup's `attachmentsDir`, else the
|
|
||||||
session's cwd, else home) and the driver names that path, read from
|
|
||||||
the `<name>.remote` marker beside the file -- PLAN.md's "Transport" has
|
|
||||||
the reasoning.
|
|
||||||
- `server/` — Rust backend (`ai-server`). `main.rs` bootstraps (TLS, the
|
|
||||||
auth layer, token/QR enrollment, wg0 binding), `routes.rs` has the HTTP
|
|
||||||
table in its module doc comment, `auth.rs` the bearer-token middleware,
|
|
||||||
`config.rs` the persisted schema (written in the shared RON house rules),
|
|
||||||
`session/` the manager (registry pattern), `Driver` trait + event model,
|
|
||||||
`EchoDriver`, and transcripts.
|
|
||||||
- `app/` — Compose Android app, single `:androidApp` module, package
|
|
||||||
`com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation
|
|
||||||
`when`; `MainScreen.kt` the root's four tabs (sessions, import, models,
|
|
||||||
setups) with settings and refresh on the title row; `Api.kt`/`EventStream.kt`
|
|
||||||
the REST + SSE clients; `Events.kt` the event model mirror;
|
|
||||||
`ServerConfig.kt` settings + Keystore-sealed token; screens in
|
|
||||||
`SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`.
|
|
||||||
`Notifications.kt` is the foreground service holding the notification
|
|
||||||
stream and the one place that decides where a notification is said --
|
|
||||||
nothing for the session on screen, a `SessionAlerts` banner while the app
|
|
||||||
is up, Android's drawer otherwise, never two of them. See PLAN.md's
|
|
||||||
"Notifications: two places, never both".
|
|
||||||
**Icons are Nerd Fonts glyphs from a committed subset**, not vector assets
|
|
||||||
and not ordinary Unicode — `NerdIcons.kt` declares each codepoint and
|
|
||||||
`app/build-icon-font.sh` subsets the font. The two lists have to agree: 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 — that is
|
|
||||||
what makes two icon buttons the same width without either being given
|
|
||||||
one, and it is why `GLYPH_SIZE` is smaller than it looks like it should
|
|
||||||
be.
|
|
||||||
- `.dev-updater.ron` — what Dev Updater is asked to do with this checkout:
|
|
||||||
the server (built in `server/`, run as `service: Managed(...)`) and the
|
|
||||||
APK (built in `app/`), built in parallel. The project it serves is the
|
|
||||||
repository, not either half of it, which is why this sits at the root
|
|
||||||
rather than in `app/`.
|
|
||||||
It points at `resources.ron` beside it, which says this project keeps its
|
|
||||||
state as `ai-app` — so the Uninstall dialog offers `~/.local/share/ai-app`
|
|
||||||
and `~/.config/ai-app` instead of saying it cannot tell. That file is
|
|
||||||
*ours*, not Dev Updater's: it ignores keys it doesn't know, so anything
|
|
||||||
else worth keeping in one place belongs there too. Note what deleting the
|
|
||||||
config directory takes with it — the CA under `certs`, which is the
|
|
||||||
one-way door described below.
|
|
||||||
`Managed` means Dev Updater supervises `ai-server` with its own built-in
|
|
||||||
service implementation rather than a script kept here. ai-app had such a
|
|
||||||
script until 2026-08-28 and it was the generic case exactly — no
|
|
||||||
arguments, no environment — so the two projects were maintaining one
|
|
||||||
behaviour twice, including the OpenRC branch neither can test from a
|
|
||||||
systemd machine.
|
|
||||||
Worth knowing before pressing it: **Stop** on the server card stops the
|
|
||||||
server that a phone reaches through the tunnel, so on that phone it stays
|
|
||||||
down until someone starts it again from Dev Updater. Dev Updater reaches
|
|
||||||
it over its own port and is unaffected, which is what makes the button
|
|
||||||
safe to press and easy to regret.
|
|
||||||
- `wg-app-link/` — a **git submodule**, and the half of this backend that
|
|
||||||
dev-updater also needed: 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`). Both projects had written all five and they had drifted; see
|
|
||||||
that repo's `README.md` for the diff that decided each one. Clone with
|
|
||||||
`git clone --recurse-submodules`, or `git submodule update --init` in an
|
|
||||||
existing checkout — `server/` will not build without it, since it is a
|
|
||||||
path dependency rather than a registry one, which is what keeps the two
|
|
||||||
projects version-locked to the commit this repo pins.
|
|
||||||
The certificates are the one-way door: the CA is generated once on first
|
|
||||||
start into `$XDG_CONFIG_HOME/ai-app/certs` and regenerating it strands
|
|
||||||
the installed app.
|
|
||||||
What deliberately did **not** move is the API surface and the config
|
|
||||||
*schema* — routes, drivers, sessions and setups are what makes this
|
|
||||||
project itself.
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Phases 1–3 done 2026-08-24 (PLAN.md's phase list says what each verified):
|
Nerd Font icons are an app-owned committed subset. `app/build-icon-font.sh`
|
||||||
the skeleton pipe, the full Claude driver (streaming, tools, permission +
|
produces `app/assets/fonts/nerd_icons.ttf`; its codepoints must match
|
||||||
AskUserQuestion cards, steering, interrupt, `--resume` crash recovery,
|
`app/src/ui/icon.rs`. The app registers it with Iris at startup. Body and
|
||||||
images both ways), and the usage screen.
|
monospace fonts come from the platform; Iris ships no font assets.
|
||||||
|
|
||||||
**Phase 5 (SSH)** is written and exercised (2026-08-28): a session names a
|
## Checking work
|
||||||
host, `session::transport` turns that into an `ssh host …` invocation, and
|
|
||||||
the driver never learns which it got.
|
|
||||||
|
|
||||||
**Phase 4 (llama.cpp)** works end to end, phone included (2026-08-28).
|
Commit each coherent, warning-clean slice and push it.
|
||||||
Models are browsed and downloaded from HuggingFace (`models.rs`, resumable
|
|
||||||
and verified), and `session::llama` runs one through `llama-server` over
|
|
||||||
its OpenAI-compatible streaming endpoint. Two things are deliberate and
|
|
||||||
easy to undo by accident: the conversation is rebuilt from the
|
|
||||||
**transcript** rather than kept in the driver, because driver memory is
|
|
||||||
invisible to a second device; and a llama session is refused on an ssh
|
|
||||||
host, because the model is reached over HTTP and forwarding that port is
|
|
||||||
not built.
|
|
||||||
|
|
||||||
Setups — machines, each carrying what it can run — are added, renamed,
|
- Whole product: `./scripts/run-tests.sh`.
|
||||||
re-probed and removed from the app; providers are **discovered by asking
|
- Framework: `cd iris && cargo fmt --all --check && cargo clippy --all-targets
|
||||||
the machine**, never typed, so the enrolled token cannot introduce a
|
-- -D warnings && cargo test`.
|
||||||
command. What is left is real-phone/WireGuard bring-up, which is
|
- App: `cd app && cargo fmt --all --check && cargo clippy --all-targets --
|
||||||
operational rather than code.
|
-D warnings && cargo test`.
|
||||||
|
- Android: `cd app && ./build-apk.sh debug --abi x86_64` for this machine's
|
||||||
|
emulator, or `./build-apk.sh release` for a phone. The script builds with
|
||||||
|
cargo-ndk, packages with Gradle, and verifies the APK. Never infer phone
|
||||||
|
frame times from a debug emulator build.
|
||||||
|
|
||||||
**`command -v` follows PATH under a non-interactive ssh session**, which is
|
`app/`, `iris/`, and `scripts/rigs/ui-profile/` use rolling nightly through
|
||||||
not the PATH a login shell shows, so a binary somewhere unusual is
|
per-directory toolchain files. `server/` and `event-model/` use stable.
|
||||||
invisible to discovery — llama.cpp unpacked into `~/.local/opt` needs a
|
|
||||||
symlink into `~/.local/bin` before a setup finds it. The escape hatch for
|
|
||||||
anything odder is editing `config.ron` on the backend, deliberately the one
|
|
||||||
authority the phone does not have.
|
|
||||||
|
|
||||||
**Testing llama.cpp here:** the prebuilt CPU build lives outside the repo
|
The release signing key lives at `~/.config/ai-app/release.jks`, never in the
|
||||||
at `~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It
|
checkout. `build-apk.sh` creates it once. Normal builds use application id
|
||||||
needs its own directory on `LD_LIBRARY_PATH`, so start the server as
|
`com.example.aiapp`; benchmark builds add `.bench` and are built explicitly:
|
||||||
`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's
|
|
||||||
`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at
|
|
||||||
usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the
|
|
||||||
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
|
|
||||||
broken driver — `llama-cli` produces the same from the file directly, which
|
|
||||||
is how to tell the two apart in a hurry.
|
|
||||||
|
|
||||||
**How to test SSH here, since there is no second machine:** ssh this VM to
|
./build-apk.sh release --features "screens bench"
|
||||||
itself. Generate a throwaway key, append the public half to
|
|
||||||
`~/.ssh/authorized_keys`, and configure a host of `bob@127.0.0.1` with
|
|
||||||
`identityFile` pointing at it plus
|
|
||||||
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it
|
|
||||||
touches nothing real. Point a provider's `command` at something harmless
|
|
||||||
like `/bin/echo` rather than at `claude`: the transport is what is under
|
|
||||||
test, the process exiting immediately is the signal, and it costs no
|
|
||||||
tokens. **Take the key back out afterwards.** Note the remote login shell
|
|
||||||
here is **fish**; the remote script (`cd '…' && exec '…'`) and `ssh.rs`'s
|
|
||||||
POSIX quoting happen to mean the same thing in both, but that is luck
|
|
||||||
rather than design, and a shell that isn't either is the thing to suspect
|
|
||||||
first if a remote spawn ever mangles an argument.
|
|
||||||
|
|
||||||
## Checking your work
|
## Running the server
|
||||||
|
|
||||||
- Server: `./run-tests.sh` from the repo root (or `cargo test` from
|
Use `--bind 127.0.0.1` for emulator development. Without it the server binds
|
||||||
`server/`) +
|
wg0, which the emulator cannot reach. Use scratch state:
|
||||||
`cargo clippy --all-targets` + `cargo fmt`. The build stays
|
|
||||||
warning-clean and rustfmt-clean at the defaults — there is no
|
|
||||||
`rustfmt.toml` and there should not be one.
|
|
||||||
- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
|
||||||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
|
||||||
:androidApp:testDebugUnitTest` — format, typecheck, lint and test, the
|
|
||||||
app-side equivalent of the line above. The unit tests are JVM-only and
|
|
||||||
cover the syntax highlighter's scanner, which is the app's one piece of
|
|
||||||
pure logic with no Android in it. Then `./build-apk.sh`
|
|
||||||
to produce 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.
|
|
||||||
The emulator scripts stay on the debug build; a debuggable build runs
|
|
||||||
Compose at a fraction of release speed, so never read a frame time from
|
|
||||||
one as the app's -- the render report now says which build it came from.
|
|
||||||
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.
|
|
||||||
- **A row something is happening to is dimmed, drained of colour, inert,
|
|
||||||
and says which operation in a word** -- `BusyItem`, used by both the
|
|
||||||
session list and the import list so the appearance is learned once. The
|
|
||||||
word rather than a bare spinner because "deleting" and "importing" differ
|
|
||||||
in kind. It dims and desaturates but 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.
|
|
||||||
- **Importing and deleting run on the server, not in the request, and a
|
|
||||||
batch is handed over in one call.** `POST
|
|
||||||
/setups/{id}/importable/delete` and `POST /setups/{id}/importable/import`
|
|
||||||
each take a list of session ids, answer 202, and do the work in spawned
|
|
||||||
tasks -- because the phone that asked is free to leave and used to cancel
|
|
||||||
its own batch by doing so. A list rather than a route per session because
|
|
||||||
one request per row made a handover only as atomic as the network: some
|
|
||||||
rows started and the rest were never asked for, and a row nobody asked
|
|
||||||
for looks exactly like a row nobody picked. Every id is registered as in
|
|
||||||
flight before the 202 goes back. Only the *registering* is atomic; the
|
|
||||||
work itself settles per row, since six deletes that all roll back
|
|
||||||
together is not something a filesystem offers. What replaces the reply is
|
|
||||||
`session::pending`: every row of the listing carries `pending` and
|
|
||||||
`error`, and `GET /setups/{id}/importable/events` streams the changes.
|
|
||||||
**Both, not either.** The stream is a broadcast with no memory, so an
|
|
||||||
operation that starts and finishes while it is still connecting is one
|
|
||||||
nothing will ever be said about -- that left a row marked "waiting" for
|
|
||||||
ever, and the listing is what repairs it. So the screen fetches again
|
|
||||||
after a handover when anything still looks outstanding, and takes the row
|
|
||||||
states from the answer rather than from what it remembers.
|
|
||||||
- **A single tap still waits.** "Continue this and take me to it" needs the
|
|
||||||
session it made, and 202 does not carry one. The batch and the tap share
|
|
||||||
`spawn` on the server so the two cannot drift about what importing means.
|
|
||||||
- **The import screen selects in batches: hold to enter, tap to add.** The
|
|
||||||
options that act on a selection appear along the bottom, and are Delete
|
|
||||||
and Import only. Submitting clears the selection immediately and marks
|
|
||||||
every chosen row -- the one in flight as "importing" or "deleting", the
|
|
||||||
rest as "waiting" -- so the bar goes away and the affected set is what
|
|
||||||
says the work is happening. Rows are taken out as each one lands rather
|
|
||||||
than all at the end: a finished row still sitting there looks exactly
|
|
||||||
like one that has not been imported, and tapping it starts a second CLI
|
|
||||||
on the same transcript. What that costs is that the rows below slide up
|
|
||||||
under the reader's finger, so a row that has just moved ignores taps for
|
|
||||||
half a second (`SETTLE_MS`).
|
|
||||||
- **An answered question keeps its options and marks the one that was
|
|
||||||
taken**, in the same purple that says "picked" while it is still open --
|
|
||||||
it does not collapse into a line repeating the answer. The options are
|
|
||||||
what the question *was*, and "Deny" alone does not say that Allow was the
|
|
||||||
alternative. One rule in two places (`AskedQuestion` and `PermissionAsk`),
|
|
||||||
since a permission is a question with two bare options rather than a
|
|
||||||
different kind of thing. An answer typed into **Other** matches no option,
|
|
||||||
so that one is still written out -- the state the marking cannot say.
|
|
||||||
- **Anything that is a note *about* the conversation rather than a turn in
|
|
||||||
it is closed by default**: a tool call, a peer message, and now a memory
|
|
||||||
note (`<cc-memory>`). Open-ness is the screen's, never the card's -- a
|
|
||||||
card that remembered for itself forgets the moment the lazy list stops
|
|
||||||
composing it, so a note opened and scrolled past would shut behind the
|
|
||||||
reader.
|
|
||||||
- **The full-screen image lives on the screen, not in the row that drew the
|
|
||||||
thumbnail** (`SessionImageViewer`). A `Read` whose result is an image is a
|
|
||||||
row of one call until the next call arrives and makes it a group -- a
|
|
||||||
different composable in a different part of the tree, so the old subtree
|
|
||||||
and everything it remembered goes, the open dialog included. Somebody
|
|
||||||
looking at a screenshot was thrown back to the transcript because the
|
|
||||||
session made another tool call. `/tools n gap` puts an image on its first
|
|
||||||
call so this is reproducible: open it, wait a gap, watch the row regroup.
|
|
||||||
- **All transcript text is selectable, from one `SelectionContainer` around
|
|
||||||
the whole list** (`TranscriptList.kt`). Not per row: a transcript is one
|
|
||||||
body of text to a reader, so a selection has to be able to run from a
|
|
||||||
reply into the tool output under it -- and a container per row leaves
|
|
||||||
whatever was drawn without one silently unselectable, which nothing on
|
|
||||||
screen reports. Rows keep their tap handlers; selection is a long press.
|
|
||||||
**An inline code chip is drawn behind the text** rather than as the
|
|
||||||
renderer's span background, because a span background is part of the
|
|
||||||
text's own drawing and hid the selection under it -- see
|
|
||||||
`appendCodeChip` in `MarkdownLinks.kt` and TRANSCRIPT_RENDERING.md.
|
|
||||||
- **A session can be moved to another directory** from the settings dialog
|
|
||||||
(`POST /sessions/{id}/cwd`). It stops the process, because a working
|
|
||||||
directory is settled at spawn; the next message starts it in the new one.
|
|
||||||
**`claude --resume <id>` finds a session from any directory** -- measured
|
|
||||||
on 2.1.237 -- so nothing of Claude Code's is relocated, and should you ever
|
|
||||||
be tempted, its project directory is the path with every non-alphanumeric
|
|
||||||
character replaced by `-`, cut at 200 characters with a hash appended, and
|
|
||||||
overridable besides.
|
|
||||||
- **A message from another agent reaches a live session on the turn's
|
|
||||||
`result`, not before.** Measured on CLI 2.1.237 by sending a real
|
|
||||||
cross-session message to a real stream-json session: no `user` record, and
|
|
||||||
nothing in the partial-message stream -- the whole of it is an `origin`
|
|
||||||
object on the `result`, the same shape the session file records, which is
|
|
||||||
why `import::peer_message` reads both. So it is *recorded* after the reply
|
|
||||||
it caused, and cannot be recorded anywhere else in an append-only log --
|
|
||||||
which is why the event carries `turnStart`, the seq of the status that
|
|
||||||
opened its turn, and the phone draws the note at that seq instead of where
|
|
||||||
it arrived. Exercise it with the echo driver's `/peer-turn`; plain `/peer`
|
|
||||||
is the in-place shape an import replays. See PLAN.md.
|
|
||||||
- **A queued message can be tapped to take it back**, which is
|
|
||||||
`POST /sessions/{id}/unqueue` and a `messageDropped` event -- see PLAN.md's
|
|
||||||
"Taking a queued message back". On a **Claude** session it always refuses,
|
|
||||||
and that is correct rather than broken: the driver writes a steer into the
|
|
||||||
CLI the moment it arrives, so what the bubble is waiting for is the CLI
|
|
||||||
*reading* it, not this server sending it. The refusal is drawn on the
|
|
||||||
bubble. The echo driver really does hold its queue, so that is the rig for
|
|
||||||
the case where the drop succeeds.
|
|
||||||
- **Deleting a session offers to take the machine's own transcript with
|
|
||||||
it.** `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
|
|
||||||
confirmation, and only where the driver keeps a record of its own
|
|
||||||
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
|
|
||||||
because leaving that copy is what makes an ordinary delete recoverable --
|
|
||||||
and the dialog's paragraph is rewritten when it is on rather than
|
|
||||||
appended to, since the sentence promising the conversation "should still
|
|
||||||
be there to import again" is exactly the one the switch makes false. The
|
|
||||||
server deletes the machine's copy *first*, so a machine it cannot reach
|
|
||||||
leaves the session where it was instead of half-deleted.
|
|
||||||
- **One Claude Code session id can name two files, and the listing offers
|
|
||||||
it once.** Resuming a session from a different working directory makes
|
|
||||||
the CLI write a second transcript with the same id under that
|
|
||||||
directory's project folder -- an ordinary state of a machine, not
|
|
||||||
corruption. Everything downstream addresses a session by id (`--resume`,
|
|
||||||
the delete glob, the in-flight registry) and the phone keyed its list on
|
|
||||||
it, so two rows sharing one *closed the app* on a Compose duplicate-key
|
|
||||||
throw. `parse_listing` keeps the copy with the most lines, because the
|
|
||||||
other is usually a few-hundred-byte stub and is often the *newer* of the
|
|
||||||
two -- so recency is the wrong key. Deleting removes every copy rather
|
|
||||||
than the first, or the row came back after a delete that reported
|
|
||||||
success. The phone's half is `uniqueItems`, which every list keyed on a
|
|
||||||
server-chosen id goes through: a repeat there must never be able to
|
|
||||||
close the app, whatever produced it.
|
|
||||||
- **A reply is drawn as pieces of one parse, never as re-parsed
|
|
||||||
substrings.** `MarkdownPieces.kt`: a `Piece` addresses a top-level block
|
|
||||||
of the message's tree, or one item of a top-level list, and every piece
|
|
||||||
is drawn from the same `State.Success` that `ParsedReplies` cached and
|
|
||||||
`warm` made. That is what bounds a lazy-list item (one paragraph, one
|
|
||||||
bullet) without parsing a message more than once, and it is why a
|
|
||||||
forty-item list of sources is forty units rather than one. The renderer
|
|
||||||
is still the parser and the environment: `MarkdownRoot` provides its
|
|
||||||
locals and `MarkdownElement` dispatches a whole block through our
|
|
||||||
component table, so paragraphs, headings and table cells are span-linked
|
|
||||||
`LinkedText` (links as spans with one tap detector per text, not a layout
|
|
||||||
node per link -- the cost that made a list of sources bumpy) and lists
|
|
||||||
are ours wherever the dispatch meets one. A heading's words are its
|
|
||||||
`ATX_CONTENT`/`SETEXT_CONTENT` child; the inline builder draws nothing
|
|
||||||
for a node type it does not know, so hand it the child.
|
|
||||||
- **A markdown table wraps its cells and never cuts one off.** The
|
|
||||||
renderer's own defaults draw every cell at one line with an ellipsis,
|
|
||||||
which on a phone loses most of a table -- and an elided cell looks
|
|
||||||
exactly like a short one, so nothing on screen says anything was cut.
|
|
||||||
`Markdown.kt` supplies its own rows (`LinkedTableRow`): as many lines as
|
|
||||||
a cell needs, cells aligned to the top of the row so a two-line cell
|
|
||||||
does not re-centre its neighbours, and each cell a `LinkedText`. Width is
|
|
||||||
the other half: a column narrows to 136dp and no further, and past that
|
|
||||||
the whole table scrolls sideways rather than squeezing -- 136 because it
|
|
||||||
is the widest floor that still fits three columns across a phone, which
|
|
||||||
is the commonest table there is. Exercise it with the echo driver's
|
|
||||||
`/table N` (default six columns), which writes long cells on purpose:
|
|
||||||
a fixture of tidy one-word values renders fine whether or not the
|
|
||||||
truncation is fixed.
|
|
||||||
- **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. It is 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.
|
|
||||||
- **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) and
|
|
||||||
generated into a constant. 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.
|
|
||||||
- Run the server for development with `--bind 127.0.0.1`. Without it the
|
|
||||||
server binds wg0, which exists here but is unreachable from the emulator
|
|
||||||
(it dials 10.0.2.2). First run prints the enrollment QR/URI with the
|
|
||||||
token — capture it from the log. `ai-server --enroll-link` (same
|
|
||||||
`--config`/`--bind`/`--port`) mints one more device's link while the
|
|
||||||
server keeps running and prints only the URI; the server adopts that
|
|
||||||
token on its first use. It is what Dev Updater's Enroll button runs.
|
|
||||||
- **`app/debug-transcript.sh` puts a real conversation on the emulator.**
|
|
||||||
The echo driver stays the right rig for most things and is the wrong one
|
|
||||||
for anything whose cost scales with what was actually written: a real
|
|
||||||
reply is longer, is real markdown, and carries tool calls whose input and
|
|
||||||
output are kilobytes rather than a word. Two faults were invisible until
|
|
||||||
a real transcript was loaded — a page of history landing mid-fling threw
|
|
||||||
the reader back to the newest end, and parsing one real reply took 51ms
|
|
||||||
against 4.6ms for a synthetic one. `-b` takes the biggest conversation on
|
|
||||||
the machine rather than the newest, which is what a scrolling test wants;
|
|
||||||
`--stop` takes it all down again.
|
|
||||||
It copies the transcript into `/tmp` and gives the server a `HOME` of its
|
|
||||||
own, so the import can only see the copy — importing spawns `claude
|
|
||||||
--resume`, and against the real file that is a second CLI writing to a
|
|
||||||
conversation somebody may still be in. **A transcript never goes in this
|
|
||||||
repository**: they hold whatever was said, read and written in that
|
|
||||||
session, and `~/repos` is shared with the host besides.
|
|
||||||
- **`app/ui-sandbox.sh` is the rig for driving the UI against invented
|
|
||||||
sessions.** It starts a second `ai-server` with its own `$HOME`, config
|
|
||||||
and data directory, holding eight invented Claude Code transcripts and a
|
|
||||||
`claude` that is two lines of shell. That isolation is the point: the
|
|
||||||
import screen lists whatever is in `~/.claude/projects`, which in this VM
|
|
||||||
is real agent transcripts, so exercising *delete* against the ordinary
|
|
||||||
server deletes somebody's conversation and exercising *import* starts a
|
|
||||||
real `--resume` on the owner's account. Neither is a price worth paying to
|
|
||||||
look at a list. It shares the real TLS certificates, because the
|
|
||||||
installed APK pins that CA.
|
|
||||||
Its port and root are derived from the checkout's name, so two checkouts'
|
|
||||||
sandboxes (and the emulators enrolled against them) cannot reach each
|
|
||||||
other, and its token is generated once into
|
|
||||||
`~/.config/ai-app/sandbox-token` and carried across restarts along with
|
|
||||||
any tokens the server's own enrolment flow appended -- so the emulator app
|
|
||||||
is enrolled **once** (the start banner prints the command) and stays
|
|
||||||
enrolled. It also carries the driving verbs every UI investigation needs,
|
|
||||||
so none of this is re-derived per session:
|
|
||||||
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
|
|
||||||
`./ui-sandbox.sh send SID text|@file`, and
|
|
||||||
`./ui-sandbox.sh api /path [curl args]` for everything else.
|
|
||||||
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
|
|
||||||
enrolment already there -- for when the fixture under test was expensive to
|
|
||||||
build (a long delta-heavy transcript, say) and should survive a rebuild of
|
|
||||||
the server binary; plain `start` wipes them, which is right for the
|
|
||||||
list-screen fixtures and wrong for that.
|
|
||||||
It passes `--delay` by default for the reason the next entry gives, and
|
|
||||||
`AI_SANDBOX_BIG_MB` puts one large transcript among the small ones --
|
|
||||||
`AI_SANDBOX_SPAWN_DELAY` makes the fake CLI slow to start. Both exist
|
|
||||||
because operations that finish in milliseconds have states on the way that
|
|
||||||
nothing can observe, and an unobservable state is one where broken and
|
|
||||||
working look identical.
|
|
||||||
- **`app/transcript-bench.sh` is the standard scroll measurement.** It
|
|
||||||
opens the first session (or `-k` keeps the current screen), scrolls a
|
|
||||||
fixed gesture loop, and prints the app's render report -- the same one
|
|
||||||
the in-app copy button produces, whose `on screen:` line names what the
|
|
||||||
viewport was actually holding. Compare two runs of it with the same
|
|
||||||
gestures; the emulator's absolute frame times transfer nothing, the
|
|
||||||
report's accounting does.
|
|
||||||
- **`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.
|
|
||||||
- **A fake CLI exercises the process lifecycle without a token.** Point a
|
|
||||||
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
|
|
||||||
`cat > /dev/null` — and it behaves the way the lifecycle code cares
|
|
||||||
about: it holds the fifo open, records a real pid, writes nothing, and
|
|
||||||
dies on a signal. So adopt, stop, restart and start are all drivable
|
|
||||||
without a real `--resume` and without spending a turn on somebody's
|
|
||||||
account. Sibling to `debug-transcript.sh`, and the two cover different
|
|
||||||
halves: reach for this when what is under test is *whether a process is
|
|
||||||
running*, and for the script when it is *what the transcript draws*.
|
|
||||||
(From the ai-app-2 session, 2026-08-30, which found a clock bug with it
|
|
||||||
that the tests did not have.)
|
|
||||||
- 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` (`~/.config` when that is unset), 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 it with
|
|
||||||
`adb -s "$SERIAL" shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"`
|
|
||||||
(quote so the device shell doesn't eat the `&`s).
|
|
||||||
- **The emulator is `~/repos/emulator-tools`' business, not this repo's.**
|
|
||||||
`emu up` creates and boots the AVD named after this checkout — whatever
|
|
||||||
`emu name` prints, never a name typed out here, since this file is the same
|
|
||||||
in every clone — refusing when the machine has no room for one; `emu list`
|
|
||||||
says what is attached and what it costs; `emu down` stops it.
|
|
||||||
`run-android.sh` is that plus a build and an install. Run that repo's
|
|
||||||
`install.sh` once if `emu` is missing.
|
|
||||||
The `adb` on `PATH` after sourcing `android-env.sh` is that repo's wrapper,
|
|
||||||
which fills in `-s` from the same rule — so a bare `adb shell` reaches this
|
|
||||||
checkout's emulator and refuses to reach another one's. That defaulting is
|
|
||||||
what makes the old advice unnecessary rather than wrong: with two attached
|
|
||||||
and no `-s`, a bare `adb shell pm list packages` comes back **empty**,
|
|
||||||
which reads as the app having been uninstalled rather than as the question
|
|
||||||
being ambiguous.
|
|
||||||
**Gradle does not go through that wrapper**, so it had the same hole until
|
|
||||||
2026-08-31: `installDebug`, `uninstallDebug` and `connectedAndroidTest` ask
|
|
||||||
the adb server for every attached device and act on all of them, which is
|
|
||||||
how one session's debug build landed on another's emulator. A Gradle init
|
|
||||||
script from `emulator-tools` now runs `emu check` before those tasks and
|
|
||||||
fails the build rather than fanning out. When it refuses, say which device
|
|
||||||
you mean at the moment you use it — `ANDROID_SERIAL=$(emu serial)
|
|
||||||
./gradlew …` — rather than exporting a serial into the shell, which goes
|
|
||||||
stale the next time an emulator restarts and another checkout's takes the
|
|
||||||
port.
|
|
||||||
|
|
||||||
## Where things run (host vs this VM)
|
ai-server --bind 127.0.0.1 --config /tmp/ai-config.ron \
|
||||||
|
--data-dir /tmp/ai-sessions --port 8444
|
||||||
|
|
||||||
Established 2026-08-25. The machine itself — the two boxes, the shared
|
The emulator reaches the host at `10.0.2.2`. `ai-server --enroll-link` mints
|
||||||
`~/repos` mount, and why the VM is untrusted — is described once in
|
another device link while the server runs. `--delay MS` is important for UI
|
||||||
`~/.claude/MACHINE.md`; what follows is only what that means here.
|
states that disappear too quickly on loopback. `RUST_LOG=ai_server=debug`
|
||||||
|
logs transcript page bounds and SSE catch-up/reset decisions.
|
||||||
|
|
||||||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
Exercise the server directly when possible:
|
||||||
address the phone can reach is, and where WireGuard terminates.
|
|
||||||
`wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run
|
|
||||||
it 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 —
|
|
||||||
`ai-server` with no `--bind` — is exercisable during development. It has
|
|
||||||
no reachable peer and doesn't need one. Consequence: **with no `--bind`
|
|
||||||
the emulator can't reach the server** (it dials 10.0.2.2), so keep using
|
|
||||||
`--bind 127.0.0.1` for app work.
|
|
||||||
- `./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's how to verify the wg0-only posture.
|
|
||||||
- **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: a configured host,
|
|
||||||
and a session that names it.
|
|
||||||
- **Nothing secret goes in the repo**, which is shared with the host and
|
|
||||||
attacker-writable under this project's threat model (PLAN.md's security
|
|
||||||
section). State lives outside it: `$XDG_CONFIG_HOME/ai-app/config.ron`
|
|
||||||
and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only.
|
|
||||||
- Certificates are generated **by the server, on first start**, into
|
|
||||||
`$XDG_CONFIG_HOME/ai-app/certs` (`--certs` overrides). The CA is created
|
|
||||||
once and left alone; the leaf is reissued every start, so covering a new
|
|
||||||
address is a restart. Starting the server in the VM therefore makes a
|
|
||||||
separate throwaway dev CA — never install a build pinning that on the
|
|
||||||
real phone.
|
|
||||||
- Point development at a scratch state directory rather than the real one:
|
|
||||||
`--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`.
|
|
||||||
|
|
||||||
## Sessions outlive the backend
|
curl --cacert ~/.config/ai-app/certs/ca.pem \
|
||||||
|
-H "Authorization: Bearer …" https://127.0.0.1:8443/sessions
|
||||||
|
|
||||||
Since 2026-08-29 a session's process is **deliberately left running when
|
`./scripts/test-wg-tunnel.sh up|test|down` builds a real WireGuard tunnel
|
||||||
`ai-server` stops**, and adopted again when it starts — so restarting the
|
between network namespaces and verifies pinned TLS against 10.66.0.1.
|
||||||
backend does not end a turn. PLAN.md has the design; what matters day to
|
|
||||||
day:
|
|
||||||
|
|
||||||
- **Stopping the server no longer stops the sessions.** After `pkill
|
## Rigs
|
||||||
ai-server` the `claude` processes are still there, on purpose, and the
|
|
||||||
next start picks them up (`reattaching to the claude-cli it left
|
|
||||||
running` in the log). To end one, either `POST /sessions/{id}/stop` —
|
|
||||||
which keeps the session and its transcript, and `POST .../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.** `POST
|
|
||||||
.../message`, `.../command` and `.../compact` go through
|
|
||||||
`SessionManager::send_message` and `::run_command`, which start a process
|
|
||||||
first when the session is known to have exited and then hand the thing to
|
|
||||||
the driver that has one behind it. Only on `exited`: `unknown` has a
|
|
||||||
process that may well be reading its fifo. `/rename` starts one too, and
|
|
||||||
for a sharper reason than the rest: the CLI keeps its own copy of the
|
|
||||||
name, that copy is what its session picker and other agents' session
|
|
||||||
lists show, and a session is only ever *given* a name at birth — every
|
|
||||||
later start is a `--resume` — so a rename that reached no process would
|
|
||||||
leave the two lists disagreeing for good. Its save happens before the
|
|
||||||
telling, so a failure there says the telling failed rather than the
|
|
||||||
rename. 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** (2026-08-30). It picks up
|
|
||||||
the processes still running and leaves every other session as it found
|
|
||||||
it: listed, with its transcript and its stream, reporting `exited`, with
|
|
||||||
no process and no driver until somebody asks for one. Restarting the
|
|
||||||
server used to relaunch a driver for every session, which started a CLI
|
|
||||||
for each one that had none — so a session stopped on purpose came back at
|
|
||||||
the next rebuild, and the `Idle` the new driver announced stamped every
|
|
||||||
row as active just now. If you are looking for a stopped session's
|
|
||||||
process after a restart, there is deliberately none; press Start, or send
|
|
||||||
it anything.
|
|
||||||
- **A launch never moves a session's clock.** A status it has to correct is
|
|
||||||
written at the time of the last thing the session actually did, not at
|
|
||||||
`now()`, and a session that has never done anything reports
|
|
||||||
`SessionConfig::created` rather than the clock — its transcript is empty,
|
|
||||||
since a driver announcing the state it starts in is not news, so there is
|
|
||||||
no line to read a time off. Both are the same rule as
|
|
||||||
`Transcript::last_activity`: a restart has been told nothing, so it must
|
|
||||||
not claim anything happened.
|
|
||||||
- **A session spawned while testing cleans itself up: `--throwaway-sessions`**
|
|
||||||
(2026-08-30), which a **debug build defaults to on**. Every session
|
|
||||||
spawned by such a server is marked `throwaway: true` in `config.ron`, and
|
|
||||||
its process is stopped — SIGTERM, then SIGKILL after
|
|
||||||
`process::STOP_GRACE` — when the server exits or is sent SIGTERM/SIGINT.
|
|
||||||
Sessions outliving the backend is right for the ones somebody is using
|
|
||||||
and wrong for the ones a test made: those leave a `claude` behind that
|
|
||||||
every later server adopts, and they pile up unnoticed (twelve on this
|
|
||||||
machine in a day, each holding a conversation open).
|
|
||||||
Two things worth knowing. The flag decides only what **new** sessions are
|
|
||||||
marked as; what happens on the way out is decided by the **mark**, which
|
|
||||||
is the session's own — so a session you spawned deliberately keeps
|
|
||||||
running whichever server is up when one exits, and a throwaway one is
|
|
||||||
cleaned away even by a server started without the flag. And the waiting
|
|
||||||
is not optional: `process::stop` leaves its SIGKILL on a tokio timer,
|
|
||||||
which a runtime that is shutting down never runs, so
|
|
||||||
`process::wait_gone` does the waiting on the way out. Pass
|
|
||||||
`--throwaway-sessions=false` to keep what a development server spawns.
|
|
||||||
- **A process that has exited but not been reaped reads as dead**, not
|
|
||||||
alive. `/proc/<pid>/stat` keeps the entry — same pid, same start time —
|
|
||||||
until the status is collected, so a zombie used to answer "still there",
|
|
||||||
which made `exited` unsayable: the session showed `unknown`, its Start
|
|
||||||
button never appeared, and stopping it said there was nothing to stop.
|
|
||||||
`process::stat_of` reads the state field alongside the start time.
|
|
||||||
- **Each session directory now 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.
|
|
||||||
- **`--resume` only ever runs when nothing is running.** That check is the
|
|
||||||
fix for the incident below, and the reason there is one entry point
|
|
||||||
(`ClaudeDriver::launch`) rather than a spawn and an attach. The status a
|
|
||||||
launch reports obeys the same rule: a session recorded as `exited` whose
|
|
||||||
launch has just started a process reports `idle`, because `exited` is the
|
|
||||||
word that refuses every command and offers a phone the chance to start a
|
|
||||||
second CLI on a live conversation.
|
|
||||||
- **`exited` is never taken on trust; it is checked against the process
|
|
||||||
record** (`corrected` in `session/mod.rs`). It is the one status that draws
|
|
||||||
the phone's Start button and lets `start_session` build a driver, so a
|
|
||||||
record that is not known to be dead makes it false and the session reports
|
|
||||||
`unknown` instead. Without that, a session adopted at a backend start kept
|
|
||||||
the transcript's `exited` while its CLI was running, Start was accepted
|
|
||||||
every press, and each press left another reader on the same process —
|
|
||||||
which reads on screen as one reply written several times, interleaved
|
|
||||||
(`GotGotGot it — it — it —`), not as anything to do with a button.
|
|
||||||
A driver that `start_session` replaces gets `Driver::detach` for the same
|
|
||||||
reason: swapping the `Arc` does not end the tasks the old one is running.
|
|
||||||
- Remote sessions are adopted too. The pid recorded for one is the **`ssh`
|
|
||||||
client's**, on this machine — that is the process the backend owns, and it
|
|
||||||
lives as long as the remote command does. (This said "local only" until
|
|
||||||
2026-08-29; the code never had that branch.) Note the far `claude` always
|
|
||||||
has an sshd pipe on stdin whichever version started it, since the fifo is
|
|
||||||
on the backend's side — so you cannot tell a backend's version by looking
|
|
||||||
at a remote session's stdin.
|
|
||||||
|
|
||||||
The import list reports each session's **size as well as its line count**,
|
- `app/ui-sandbox.sh` runs an isolated delayed server with invented
|
||||||
because the two disagree in the way that matters: these transcripts embed
|
transcripts, a fake CLI, stable enrollment, and a file-explorer fixture.
|
||||||
screenshots as base64, so one line can be a megabyte. On this machine a
|
Its HOME and data are disposable; never point import/delete tests at real
|
||||||
69 MB session has 3,427 lines and a 44 MB one has 6,792 — nothing about a
|
`~/.claude/projects`.
|
||||||
line count tells you what continuing a session will cost. Shown, not warned
|
- A two-line fake CLI (`#!/bin/sh`, `cat > /dev/null`) exercises adoption,
|
||||||
about; importing a large session is a choice somebody is entitled to make.
|
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.
|
||||||
|
|
||||||
**Never import a Claude Code session that is open in a terminal.** The app
|
The checked-in benchmark transcript is synthetic. Never put a real transcript
|
||||||
refuses it now — it reads `~/.claude/sessions/<pid>.json`, which Claude
|
in this repository; it contains conversation text, tool input, and file data.
|
||||||
Code keeps for every live session, and checks the pid's start time so a
|
|
||||||
descriptor left by a crashed CLI doesn't count. Refused rather than warned
|
|
||||||
about, because on 2026-08-29 an agent imported the session it was *itself*
|
|
||||||
running in. That put two `claude --resume` processes on one file: the whole
|
|
||||||
65 MB conversation, 154 embedded screenshots included, was re-appended to
|
|
||||||
the transcript under a new prompt id, both copies replayed each other's
|
|
||||||
writes as work done elsewhere, and the adopted one was billed for re-reading
|
|
||||||
all of it. It ended at the account's session limit, with three `claude`
|
|
||||||
processes running against one checkout.
|
|
||||||
|
|
||||||
## Things that have bitten
|
The emulator is a GLES rig. Its Vulkan implementation is SwiftShader, while
|
||||||
|
GLES is host-accelerated through virgl. Let Iris's runtime fallback select
|
||||||
|
GLES; do not pass `force-gles`. Verify the `iris renderer:` log line before
|
||||||
|
interpreting a measurement. Vulkan is verified on desktop and a real phone.
|
||||||
|
|
||||||
Project-specific only — a lesson that would bite any project on this
|
## Driving Android UI
|
||||||
machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or
|
|
||||||
`~/.claude/MACHINE.md` (the machine itself) instead.
|
|
||||||
|
|
||||||
- **tracing caches callsite interest process-wide.** A test that hits a
|
Read the installed `this-machine-android` skill before using Gradle, adb, an
|
||||||
`tracing::warn!` with no subscriber installed can poison the interest
|
AVD, screenshots, or UI traces. This checkout gets its own AVD; resolve it
|
||||||
cache for a concurrent test that captures logs (flaky "nothing was
|
with `emu serial` rather than typing a device name.
|
||||||
logged" failures). Keep every exercise of a logging code path under the
|
|
||||||
one capturing subscriber — that's why the auth middleware has a single
|
Scripts tap controls by accessibility label, never by coordinate. Coordinates
|
||||||
combined gating+logging test.
|
are allowed for swipes because a swipe describes a distance across a scrolling
|
||||||
- **The composer can get stuck floating above the bottom of the screen after
|
surface. A coordinate tap can silently hit a different control and turn a
|
||||||
the keyboard closes, while a reply is streaming.** The composer's position
|
failed run into a plausible-looking result.
|
||||||
and the transcript's bottom padding are both driven by the raw, animated
|
|
||||||
`WindowInsets.ime` value read inside a `graphicsLayer` block, to avoid
|
## Host and VM boundary
|
||||||
recomposing the whole screen every frame of the keyboard's animation (see
|
|
||||||
the layout note above it). That animation is carried by a
|
Production `ai-server` runs on the host, where the phone can reach WireGuard.
|
||||||
`WindowInsetsAnimationCallback`, and a callback interrupted mid-flight
|
The Claude CLI is in this VM, so the host reaches it as a remote provider.
|
||||||
leaves whatever it was carrying frozen at its last value with nothing
|
The VM's wg0 is useful for development but has no reachable phone peer. A dev
|
||||||
left to correct it, since no further keyboard movement will fire it
|
server in the VM creates a throwaway CA; never install an APK enrolled against
|
||||||
again. A streaming reply invalidates the view every frame, which is
|
that CA on the real phone.
|
||||||
exactly the condition known to starve that callback of its `onEnd`.
|
|
||||||
`WindowInsets.isImeVisible` (`ExperimentalLayoutApi`) does not share the
|
For llama.cpp tests, the CPU build is at `~/.local/opt/llama.cpp`; add that
|
||||||
failure mode -- it is set once, from the platform's own start/end of the
|
directory to `LD_LIBRARY_PATH`. Avoid 2-bit quants for driver diagnosis because
|
||||||
transition over a different path -- so it is read once per keyboard
|
their fluent nonsense resembles a broken integration.
|
||||||
toggle and used to force both places back to zero the moment the
|
|
||||||
platform says the keyboard is gone, whatever the animated value still
|
For SSH transport tests, SSH this VM to itself with a throwaway key and a
|
||||||
claims.
|
harmless command. Remove the key afterwards. The remote login shell is fish,
|
||||||
**The guard is a boolean; the inset itself must never be read in the
|
so POSIX-quoting assumptions require explicit verification.
|
||||||
composable body.** That correction first shipped as a `padding(bottom =
|
|
||||||
... imeInsets.getBottom(this) ...)` computed in `SessionScreen`, which
|
## Session invariants
|
||||||
subscribes the whole screen to a value that changes every frame of the
|
|
||||||
animation: measured on the emulator at **16 full recompositions of
|
Sessions deliberately outlive `ai-server`. Shutdown leaves marked processes
|
||||||
`SessionScreen` per keyboard open, against 1**, and it put the
|
running; restart adopts their process records without starting stopped
|
||||||
transcript's position behind a recomposition while the composer's stayed
|
sessions. Sending a message to a stopped session starts it. Use
|
||||||
a draw-phase read of the same frame, so the two were only together while
|
`--throwaway-sessions` for test-created sessions.
|
||||||
that recomposition kept landing inside the frame. It is `.then(if (imeVisible) Modifier.imePadding() else
|
|
||||||
Modifier)` instead -- `imePadding` reads the inset in the layout phase,
|
Each session directory contains `process.json`, `stdin.fifo`, `stdout.log`,
|
||||||
which is what the comment above the transcript box means by "the whole of
|
and `stderr.log`. Do not edit or remove them while live: the stdout byte offset
|
||||||
what the keyboard re-measures", and dropping the modifier is the same
|
in `process.json` prevents replay and loss.
|
||||||
coercion to zero that the boolean was added for. The counter to check is
|
|
||||||
`session screen recomposed` in the debug button's report, which should
|
Never import a Claude Code session open in a terminal. One Claude session id
|
||||||
move by one across a keyboard open, not by the number of frames it took.
|
may occur in multiple project directories; import listing deduplicates by id
|
||||||
- **The keyboard pans the window unless the activity opts into resize.**
|
and prefers the copy with more lines, while deletion removes every copy.
|
||||||
Without `android:windowSoftInputMode="adjustResize"`, opening the IME
|
|
||||||
slides the whole window up (top bar off screen) instead of resizing —
|
Deleting an app session only deletes the provider's transcript when
|
||||||
`imePadding()` alone doesn't fix it and the transcript looks empty.
|
`deleteForeign=true`. The server deletes the foreign transcript first so an
|
||||||
- **A PEM constant must start at the opening quotes.** A generated
|
unreachable machine cannot leave a half-deleted session.
|
||||||
`"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory`
|
|
||||||
its preamble sniff, so it tries DER instead and fails at runtime with
|
## Known traps
|
||||||
`ASN.1 ... DECODE_ERROR` — nowhere near the code that produced it.
|
|
||||||
- **A reconnecting phone used to be sent the entire backlog.** The SSE
|
- `tracing` caches callsite interest process-wide. Logging tests must install
|
||||||
stream replayed everything after the client's cursor, unbounded, while
|
their capturing subscriber before any tested callsite runs.
|
||||||
*opening* a session was bounded to a page — so a long disconnect
|
- `serde_json` needs `float_roundtrip`: transcript pages and SSE must preserve
|
||||||
delivered thousands of events one frame at a time. Past
|
identical timestamp bytes.
|
||||||
`CATCH_UP_LIMIT` the stream now sends a `reset` frame and the newest
|
- Import lookup must use `import::find`, not list every transcript. Validate
|
||||||
window instead, and the client rebuilds from it exactly as it does when
|
ids before putting them in a glob.
|
||||||
the screen opens. The reset is not optional: without it the window is
|
- Transcript sequence numbers increase, so page edges are found by bisection.
|
||||||
spliced onto rows that are no longer adjacent to it, which reads as
|
Do not replace indexed window reads with whole-transcript parsing.
|
||||||
ordinary output.
|
- A page's event count has no fixed relationship to visible rows because
|
||||||
- **The five-hour window has no reset time between blocks, and that is not
|
deltas and tool calls fold together. History cushions are measured in
|
||||||
a missing value.** The usage API anchors it to the block it started in --
|
viewports, not row counts.
|
||||||
measured 2026-08-31, the reset came back as exactly five hours after work
|
- Android generic motion is separate from touch. Keep hover, wheel, and mouse
|
||||||
resumed, and the weekly windows in the same response carried the identical
|
button handling in `iris::android`; product UI consumes the same pointer
|
||||||
microsecond, so both are computed from one `now()` at request time. When
|
state on desktop and Android.
|
||||||
no block is running there is nothing to reset and `resets_at` is `null`;
|
|
||||||
the same response shows other idle windows with the same shape. The weekly
|
|
||||||
ones always have a reset because a week is always running, which is why
|
|
||||||
"the others seem fine".
|
|
||||||
So `resets_at` absent means **not running**, and only a timestamp that
|
|
||||||
arrives and cannot be parsed is unknown. The app collapsed both into one
|
|
||||||
null and the session bar said "reset time unknown" for a machine behaving
|
|
||||||
perfectly -- while the usage dialog, reading the same field, quietly drew
|
|
||||||
nothing. `WindowEnd` in `ResetCountdown.kt` is now the one rule both go
|
|
||||||
through.
|
|
||||||
- **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, and `delete` resolves the
|
|
||||||
path itself: 78ms. Ids are checked (`is_session_id`) before they reach
|
|
||||||
that glob, since a `/` or `..` in one walks it out of the projects
|
|
||||||
directory and `delete` removes what it lands on.
|
|
||||||
- **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:
|
|
||||||
on a 21 MB, 24,000-event transcript one page took ~500ms of server time to
|
|
||||||
return 620 KB, and took the same 500ms whichever page was asked for. A
|
|
||||||
phone scrolling back paid it per page and every stream reconnect paid it
|
|
||||||
again to find out nothing had happened. 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 and only the
|
|
||||||
window is built. 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.
|
|
||||||
`RUST_LOG=ai_server=debug` logs each page with what was asked and what
|
|
||||||
came back, which is how to see a phone paging back in real time.
|
|
||||||
- **Paging back has two failures that look like "there is simply no more
|
|
||||||
history", and neither says anything on screen.** Both fixed 2026-08-31,
|
|
||||||
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 history 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, and 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. Measured at the server, which is
|
|
||||||
the one number here that does not depend on how the emulator renders:
|
|
||||||
against a 24,000-event transcript, ten swipes asked for ten pages before
|
|
||||||
and three after.
|
|
||||||
- **What the transcript screen costs to scroll, for whoever measures it
|
|
||||||
next.** Taken 2026-08-30 on the GPU emulator (`emu up` provides one; a
|
|
||||||
frame number from the software rasteriser means nothing -- see
|
|
||||||
`~/.claude/MACHINE.md`), against a real imported transcript with the debug
|
|
||||||
server at `--delay 120`. Settled and flinging fast, both into fresh
|
|
||||||
history and back through rows already drawn: **5.2-5.9% janky frames, 99th
|
|
||||||
percentile 29-32ms, 0-2 slow UI-thread frames.** The stock Settings app on
|
|
||||||
the same device is 3.3% and 38ms, so this is at the platform floor and
|
|
||||||
what is left is the emulator rather than the app. The number that is *not*
|
|
||||||
at the floor is the first few seconds after opening a session, where every
|
|
||||||
row on the way is being composed for the first time; that is inherent to a
|
|
||||||
lazy list and it is why a measurement taken before the screen settles
|
|
||||||
reads three times worse. **Settle first, then reset `gfxinfo`.**
|
|
||||||
- **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`, over every assistant message loaded, on
|
|
||||||
every page. Both are off it now. The shape to watch for is a
|
|
||||||
`withContext` that wraps the *fetch* and leaves the work done with the
|
|
||||||
result outside 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. `EnrollmentScanActivity` also
|
|
||||||
turns off the library's 10% framing-rect inset (it decodes only what is
|
|
||||||
inside it) and its laser/result-point decorations.
|
|
||||||
-453
@@ -1,453 +0,0 @@
|
|||||||
# The file explorer
|
|
||||||
|
|
||||||
Asked for by Bryan on 2026-09-03: replace the session screen's debug
|
|
||||||
button with a folder icon that opens a file and directory viewer for the
|
|
||||||
machine the session runs on. Browse directories, open files with the
|
|
||||||
existing syntax highlighting, line numbers, no wrapping; edit a file behind
|
|
||||||
a pencil icon; create files through a modal like the ones the app already
|
|
||||||
has; work over ssh; open at the session's working directory.
|
|
||||||
|
|
||||||
This is the plan. Like PLAN.md it records each decision with the reason and
|
|
||||||
what was rejected, so that when one changes it is changed here rather than
|
|
||||||
re-argued. Once built, the operational notes (how to test it, what bit)
|
|
||||||
move to AGENTS.md and this file keeps only the design.
|
|
||||||
|
|
||||||
## What it is, in one paragraph
|
|
||||||
|
|
||||||
A machine's filesystem, seen from the phone through the backend. The
|
|
||||||
explorer belongs to a **setup** (a machine), not to a session: a session
|
|
||||||
only says where to start. Every operation -- list, read, write, create --
|
|
||||||
is one shell script run through `Transport`, exactly the way the import
|
|
||||||
listing and the usage fetch already work, so the local and the ssh case
|
|
||||||
are one implementation and a machine the backend cannot reach fails with
|
|
||||||
ssh's own message. The phone draws what came back: a listing, a file with
|
|
||||||
its lines coloured by the scanner in `Highlighter.kt`, or an editor over
|
|
||||||
the same text.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### 1. Keyed on the machine, opened from the session
|
|
||||||
|
|
||||||
Routes live under `/setups/{id}/…`, beside `importable`, because a
|
|
||||||
filesystem is a property of a machine. The session screen's folder button
|
|
||||||
opens the explorer with the session's setup and its `cwd` as the starting
|
|
||||||
directory; a session with no `cwd` opens at the machine's home, which the
|
|
||||||
machine resolves (`cd` with no argument and `pwd -P`), never a path the
|
|
||||||
phone guessed. Nothing in the explorer knows what a session is, so a later
|
|
||||||
entry point from the setups tab is one more caller and no new code.
|
|
||||||
|
|
||||||
Rejected: routes under `/sessions/{id}/`. The session would be a detour to
|
|
||||||
find the setup, and "browse this machine" from anywhere but a session would
|
|
||||||
need a session to exist first.
|
|
||||||
|
|
||||||
### 2. One shell script per operation, over `Transport`, on both transports
|
|
||||||
|
|
||||||
Each operation is a small POSIX shell script handed to `sh -c script sh
|
|
||||||
"$path" …` through `Transport::capture` (or the stdin-carrying variant
|
|
||||||
below). The path and every other value cross as **positional arguments**,
|
|
||||||
never interpolated into the script -- the same rule `import::find` follows
|
|
||||||
with `"$1"`, and the same reason `ssh::quote` exists: a path is
|
|
||||||
attacker-adjacent input in a server whose job is running commands. A `~`
|
|
||||||
prefix is handled by the same `quote_path`/`expand_home` pair every other
|
|
||||||
path goes through; nothing new is invented for it.
|
|
||||||
|
|
||||||
The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`,
|
|
||||||
`sha256sum`, `chmod --reference`). That is already what `import.rs`
|
|
||||||
assumes (`stat -c`, `/proc`), and both machines that exist are Linux. A
|
|
||||||
machine without them fails with that tool's own message, which names what
|
|
||||||
is missing.
|
|
||||||
|
|
||||||
Rejected: `std::fs` for the local transport and scripts for ssh. Two
|
|
||||||
implementations of "list a directory" drift -- the ordering of entries,
|
|
||||||
what a symlink reports, how a permission error reads -- and the local one
|
|
||||||
is the one that gets tested, so the remote one ships broken. The transport
|
|
||||||
design exists so that a driver never learns which machine it got; the
|
|
||||||
explorer is held to the same rule. The cost is a `sh` process per
|
|
||||||
operation locally, which is under a millisecond.
|
|
||||||
|
|
||||||
Rejected: a Rust SSH or SFTP library. PLAN.md rule 23 -- the system `ssh`
|
|
||||||
inherits `~/.ssh/config`, agents and jump hosts, and there is one place to
|
|
||||||
configure a connection. SFTP would need a second one.
|
|
||||||
|
|
||||||
### 3. The token can now name a path, and that is written down
|
|
||||||
|
|
||||||
AGENTS.md says of the import route: "the phone picks an **id**, never a
|
|
||||||
path: the server resolves which file that is, so an enrolled token cannot
|
|
||||||
become 'read me an arbitrary file'." The explorer's whole purpose is the
|
|
||||||
path, so it takes one. This is recorded in PLAN.md's Security section as a
|
|
||||||
change to the threat model paragraph, in these terms: the token already
|
|
||||||
gates spawning a bypass-permissions agent in any directory on any machine
|
|
||||||
a setup names, and that agent can already read and write every file its
|
|
||||||
user can. The explorer is a shorter path to authority the token already
|
|
||||||
holds, not new authority. The import route's rule stands where it is,
|
|
||||||
because there a path was unnecessary and refusing it cost nothing.
|
|
||||||
|
|
||||||
What is *not* changed: no route accepts a command. Listing, reading and
|
|
||||||
writing are fixed scripts; the phone chooses only the path and the bytes.
|
|
||||||
|
|
||||||
### 4. Paths are absolute or `~`-prefixed, and the machine answers with the real one
|
|
||||||
|
|
||||||
Same rule as `POST /sessions/{id}/cwd`: a relative path is refused with
|
|
||||||
the same wording, because where it would be depends on where nothing the
|
|
||||||
reader can see. Every listing answers with `pwd -P` of the directory it
|
|
||||||
listed, so the phone navigates on a resolved absolute path -- the parent
|
|
||||||
of `/home/bob/repos/ai-app` is a string operation on that, and a `~` the
|
|
||||||
session was spawned with is shown as what it turned out to be. The phone
|
|
||||||
never resolves `..` itself.
|
|
||||||
|
|
||||||
### 5. A read is capped and typed, and every state it can be in has a word
|
|
||||||
|
|
||||||
`GET /setups/{id}/file` answers with one of:
|
|
||||||
|
|
||||||
- `text` -- the content, with its size, mtime and sha256.
|
|
||||||
- `binary` -- the content is not UTF-8. Size reported, nothing shown.
|
|
||||||
- `tooBig` -- over `FILE_LIMIT` (1 MiB to start; see "Numbers to
|
|
||||||
measure"). Size reported so the reader knows what they are looking at.
|
|
||||||
- an error -- no such file, permission denied, machine unreachable --
|
|
||||||
carrying the machine's message.
|
|
||||||
|
|
||||||
Four outcomes rather than content-or-error, because a binary file drawn as
|
|
||||||
text and a big file cut off silently are both wrong in ways the reader
|
|
||||||
cannot see, and "couldn't read it" must not look like "it is empty". An
|
|
||||||
empty file is `text` with empty content and is drawn as one empty line
|
|
||||||
numbered 1, which is what it is.
|
|
||||||
|
|
||||||
Not in the first cut: showing images (the phone has `isImageRef` and a
|
|
||||||
viewer already; the route would serve bytes). Listed under "later".
|
|
||||||
|
|
||||||
### 6. A write is conditional on what the reader saw
|
|
||||||
|
|
||||||
`PUT /setups/{id}/file` carries the sha256 the read reported. The script
|
|
||||||
compares it against the file as it is now and refuses with a distinct exit
|
|
||||||
code if it differs; the server answers **409** with "changed on the machine
|
|
||||||
since you opened it". Agents edit files while people read them; this is
|
|
||||||
the common case, not the exotic one, and silently overwriting an agent's
|
|
||||||
edit with a stale copy is the worst available outcome. The phone offers
|
|
||||||
three ways out and says what each costs: **Overwrite** (theirs is lost),
|
|
||||||
**Reload** (yours is lost), **Cancel** (keep editing, decide later).
|
|
||||||
|
|
||||||
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1"
|
|
||||||
"$1.ai-app-tmp" && mv -f -- "$1.ai-app-tmp" "$1"`, with the bytes on
|
|
||||||
stdin. A temp file and a rename, so a connection dropped mid-write leaves
|
|
||||||
the old file whole rather than a truncated one; `chmod --reference` keeps
|
|
||||||
the mode, which a fresh file would otherwise lose (an executable script
|
|
||||||
would stop being one). What this trades away: the inode changes, so a hard
|
|
||||||
link elsewhere stops being the same file. Accepted; editors do the same.
|
|
||||||
The check-then-write is not atomic against a writer landing between the
|
|
||||||
two -- a window of microseconds on the same machine -- and that is accepted
|
|
||||||
too, and noted at the script.
|
|
||||||
|
|
||||||
The response carries the new size, mtime and sha256, so the editor's
|
|
||||||
precondition is fresh without a second read.
|
|
||||||
|
|
||||||
### 7. Create refuses to overwrite
|
|
||||||
|
|
||||||
`POST /setups/{id}/file {path}` runs under `set -C` (noclobber) and
|
|
||||||
`: > "$1"`, so a name that exists fails with the shell's own message rather
|
|
||||||
than truncating somebody's file. `POST /setups/{id}/dir {path}` is `mkdir
|
|
||||||
--` with the same property. The modal names one thing in the current
|
|
||||||
directory and has a switch for "directory"; a created file opens straight
|
|
||||||
into edit mode, because an empty file is not something to look at.
|
|
||||||
|
|
||||||
Rejected: create-with-content in one request. The editor is the place
|
|
||||||
content is typed, and a modal with a text area is a second editor.
|
|
||||||
|
|
||||||
### 8. The viewer is a list of lines, coloured once
|
|
||||||
|
|
||||||
The file is scanned once, off the main thread, by `scan` in
|
|
||||||
`Highlighter.kt` with `rulesOf(language)`; the spans are bucketed per line
|
|
||||||
in one pass, and each line's `AnnotatedString` is built when that line is
|
|
||||||
composed. A `LazyColumn` of lines, not one `Text`: text layout is linear
|
|
||||||
in the text, and a 20,000-line file in one `Text` measures all of it to
|
|
||||||
draw a screenful. Lines are drawn with `softWrap = false` inside one
|
|
||||||
shared `horizontalScroll` state, so the whole file scrolls sideways as a
|
|
||||||
block and a line never wraps.
|
|
||||||
|
|
||||||
Line numbers are a gutter in each row, right-aligned, with the gutter
|
|
||||||
width taken from the digit count of the line count in the same monospace
|
|
||||||
style -- so a 9-line file and a 12,000-line file each get exactly the
|
|
||||||
width they need and nothing is measured by hand. Because nothing wraps, a
|
|
||||||
logical line is one visual line, and the gutter cannot drift from the text
|
|
||||||
it numbers. Gutter numbers take `onSurfaceVariant`; the text takes the
|
|
||||||
scanner's palette on `rawSurface`, the surface every verbatim thing in the
|
|
||||||
app already sits on.
|
|
||||||
|
|
||||||
The language comes from the file's extension through the same table
|
|
||||||
`fenceLanguage` reads (`FENCE_LANGUAGES` already keys on `kt`, `rs`,
|
|
||||||
`py`, …). One function, `fileLanguage(name)`, takes the part after the
|
|
||||||
last dot and asks that table; it is one table, not two, so a language
|
|
||||||
added for fences is added for files. A file with no entry is drawn plain,
|
|
||||||
for the reason the table's comment gives.
|
|
||||||
|
|
||||||
Selection: the lines sit inside one `SelectionContainer`, as the
|
|
||||||
transcript does, so a selection can run across lines.
|
|
||||||
|
|
||||||
### 9. The editor is the legacy text field with a highlighting transformation
|
|
||||||
|
|
||||||
Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the
|
|
||||||
same monospace style, inside the same horizontal scroll so it does not
|
|
||||||
wrap, with a `VisualTransformation` that returns the text unchanged and
|
|
||||||
the scanner's spans as styles (`OffsetMapping.Identity`, since no
|
|
||||||
character moves). This is the one Compose API that colours a field's text
|
|
||||||
without replacing the field; the newer `TextFieldState` API has no hook
|
|
||||||
for styles. The gutter is one `Text` of `1\n2\n…` in the same style beside
|
|
||||||
the field, aligned for the same reason as the viewer: no wrap, one line
|
|
||||||
each.
|
|
||||||
|
|
||||||
Save is a glyph in the header, **disabled** until the text differs from
|
|
||||||
what was loaded (never hidden -- a control that comes and goes makes its
|
|
||||||
own absence the signal), and a `GlyphSpinner` while the write is out.
|
|
||||||
Back with unsaved changes asks; the question says the edits will be lost.
|
|
||||||
The keyboard: the explorer draws over the session, which deliberately has
|
|
||||||
no `imePadding` (see `SessionScreen`'s layout note), so the explorer's own
|
|
||||||
box adds it.
|
|
||||||
|
|
||||||
Re-scanning on every keystroke is the cost to watch. For a file under
|
|
||||||
`FILE_LIMIT` it is expected to be a few milliseconds (the scanner replaced
|
|
||||||
a library that took 174ms on 200 lines; ours has not been measured on a
|
|
||||||
1 MiB file). Measure before deciding whether edit mode needs a size below
|
|
||||||
which highlighting is on -- see "Numbers to measure".
|
|
||||||
|
|
||||||
### 10. The explorer draws over the session, and back closes it first
|
|
||||||
|
|
||||||
`Screen.Session` in `AppRoot` gains a `files: FilesTarget?`. When set, the
|
|
||||||
`FilesScreen` is composed **on top of** the session in the same `Box`, and
|
|
||||||
the session stays composed under it: its event stream keeps flowing, its
|
|
||||||
scroll position and draft stay where they were, and returning from a file
|
|
||||||
costs nothing. Back -- the button, the platform gesture and `swipeBack` --
|
|
||||||
clears `files` when it is set and goes to the list otherwise. Inside the
|
|
||||||
explorer the same back steps one level: editor → viewer (with the unsaved
|
|
||||||
question), viewer → listing, listing → parent directory it came from, and
|
|
||||||
only from the starting directory does it close. "Back returns; it does not
|
|
||||||
exit."
|
|
||||||
|
|
||||||
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from
|
|
||||||
a leaf screen goes to Main today, and a session disposed and re-created on
|
|
||||||
each return refetches its transcript over the tunnel -- exactly the flip
|
|
||||||
between "what did it change" and "what is it saying" this feature is for.
|
|
||||||
The image viewer already made the same choice for the same reason.
|
|
||||||
|
|
||||||
### 11. The listing is drawn as it came, sorted at display time
|
|
||||||
|
|
||||||
Entries carry name, kind (`directory`, `file`, `other`), size, mtime, and
|
|
||||||
whether the entry is a symlink (with the kind being the *target's*, from
|
|
||||||
`find -printf '%Y'`, so a link to a directory navigates). Sorted on the
|
|
||||||
phone, stably: directories first, then case-insensitive name. Dotfiles are
|
|
||||||
shown -- in a repository they are half of what matters. A row is the
|
|
||||||
glyph, the name, and the size for a file; tapping a directory descends,
|
|
||||||
tapping a file opens it. Each directory's entries are kept for as long as
|
|
||||||
the explorer is open, keyed by path, so returning to one does not refetch
|
|
||||||
it; the header's refresh glyph refetches the current one on purpose, and a
|
|
||||||
create refetches the directory it created into, since that is what the
|
|
||||||
operation changed.
|
|
||||||
|
|
||||||
An empty directory says "Nothing here". A listing that failed says why,
|
|
||||||
in the machine's words, where the rows would be -- never an empty list.
|
|
||||||
|
|
||||||
Entries are separated by `\0` in the script's output and by `\t` within a
|
|
||||||
line (`find -printf '%y\t%Y\t%s\t%T@\t%f\0'`), so a filename with a
|
|
||||||
newline or a tab in it survives; `parse_entries` is a unit test with
|
|
||||||
exactly those names in it.
|
|
||||||
|
|
||||||
### 12. Icons
|
|
||||||
|
|
||||||
Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script
|
|
||||||
rerun and its output committed (it needs network):
|
|
||||||
|
|
||||||
- `md-folder` U+F024B -- the header button, and directory rows. The same
|
|
||||||
codepoint dev-updater uses, and it must not drift from it, as the cog
|
|
||||||
and the refresh arrow already must not.
|
|
||||||
- `md-plus` U+F0415 -- create. Also dev-updater's.
|
|
||||||
- `md-pencil` -- edit.
|
|
||||||
- `md-content_save` -- save.
|
|
||||||
- `md-file_outline` -- file rows.
|
|
||||||
|
|
||||||
The last three are verified against the Nerd Fonts cheat sheet when they
|
|
||||||
are added, not copied from memory.
|
|
||||||
|
|
||||||
### 13. The render report moves, and the benches move with it
|
|
||||||
|
|
||||||
The speedometer goes. The report it copies is the standard measurement
|
|
||||||
`transcript-bench.sh` and `stream-bench.sh` read from logcat, so it stays
|
|
||||||
reachable: a "Copy render timings" row in `SessionSettingsDialog`, which
|
|
||||||
is where the session's other about-the-session controls already are.
|
|
||||||
|
|
||||||
**No script that drives the UI taps by coordinate, and moving this
|
|
||||||
button is where that rule gets enforced** (Bryan, 2026-09-03). Both bench
|
|
||||||
scripts press the button today as `ui-trace record --do 'tap 723 205'`, a
|
|
||||||
position measured once by hand. Anything that moves the header -- this
|
|
||||||
change, a font size, a density, another emulator -- makes that tap land on
|
|
||||||
whatever now sits there, and the script then reports a number that was
|
|
||||||
never measured, which reads exactly like a result. A control is found by
|
|
||||||
the name it already carries for assistive technology (`GlyphButton`'s
|
|
||||||
`label`, a row's text) and pressed at the bounds the screen reports at
|
|
||||||
that moment.
|
|
||||||
|
|
||||||
That belongs in the tool, not in each script: `ui-trace` in
|
|
||||||
`~/repos/emulator-tools` gains a tap-by-label action (`tap 'Session
|
|
||||||
settings'`, resolving the element's box from the same uiautomator tree
|
|
||||||
`elements` already reads, at the moment of the gesture), and both benches
|
|
||||||
move onto it in the same commit as the button -- cog, then "Copy render
|
|
||||||
timings" -- so the measurement is never unavailable and never wrong
|
|
||||||
quietly. `grep -n "tap [0-9]" app/*.sh` is the check that no coordinate
|
|
||||||
tap is left, and it goes in the emulator-tools README beside the action.
|
|
||||||
Once the action exists, this rule applies to every script that presses
|
|
||||||
something on an Android screen, not only these two.
|
|
||||||
|
|
||||||
## HTTP surface
|
|
||||||
|
|
||||||
Added to the table in `routes.rs`'s module doc:
|
|
||||||
|
|
||||||
```text
|
|
||||||
GET /setups/{id}/dir?path=P entries of directory P, and P resolved
|
|
||||||
GET /setups/{id}/file?path=P content of file P, or why not
|
|
||||||
PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
|
|
||||||
(409 when the file no longer matches ifSha256)
|
|
||||||
POST /setups/{id}/file {path} create empty; refused if it exists
|
|
||||||
POST /setups/{id}/dir {path} create; refused if it exists
|
|
||||||
```
|
|
||||||
|
|
||||||
Bodies use `deny_unknown_fields` like every other body here. Paths in the
|
|
||||||
query string are URL-encoded by `Api.kt`'s existing helper.
|
|
||||||
|
|
||||||
```json
|
|
||||||
GET dir -> {"path":"/home/bob/repos/ai-app",
|
|
||||||
"entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false},
|
|
||||||
{"name":"README.md","kind":"file","size":1234,"modified":1756900000,"link":false}]}
|
|
||||||
GET file -> {"path":"/…/x.rs","kind":"text","size":1234,"modified":…,"sha256":"…","content":"…"}
|
|
||||||
| {"path":"/…/a.png","kind":"binary","size":45678,"modified":…}
|
|
||||||
| {"path":"/…/big.log","kind":"tooBig","size":12345678,"modified":…}
|
|
||||||
PUT file -> {"size":1240,"modified":…,"sha256":"…"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Errors: `BadRequest` with the machine's message for a path that is not
|
|
||||||
there, not allowed or not absolute; the existing 409 variant for the
|
|
||||||
precondition; `Internal` only for the server's own faults. The message is
|
|
||||||
what the phone shows, in place, so it is written to be read there.
|
|
||||||
|
|
||||||
## Server work (`server/src/files.rs`)
|
|
||||||
|
|
||||||
One module, with the same shape as `setups.rs`: the scripts as constants,
|
|
||||||
one `pub async fn` per operation taking `&Transport`, and the parsing as
|
|
||||||
pure functions with tests.
|
|
||||||
|
|
||||||
1. `Transport::capture_with_input(launch, stdin)` -- `capture` with bytes
|
|
||||||
on stdin. `ship_attachment` in `routes.rs` builds this by hand today
|
|
||||||
(an `ssh::command`, a `File` on stdin, `output().await`); it moves onto
|
|
||||||
the new helper in the same change, so there is one description of
|
|
||||||
"run this there with this on stdin" rather than two.
|
|
||||||
2. `list(transport, path) -> Listing`: `cd -- "$1" && pwd -P && find .
|
|
||||||
-mindepth 1 -maxdepth 1 -printf '%y\t%Y\t%s\t%T@\t%f\0'`. First line is
|
|
||||||
the resolved path; the rest is entries. `parse_entries` tested with
|
|
||||||
names containing a tab, a newline, a leading dash and a `'`.
|
|
||||||
3. `read(transport, path) -> Read`: `stat -c '%s %Y' -- "$1"`, refuse
|
|
||||||
above `FILE_LIMIT` before `cat` so a 2 GB log never crosses the
|
|
||||||
tunnel, then `sha256sum -- "$1"` and `cat -- "$1"`, header lines then
|
|
||||||
bytes; the server splits at the header and decides `text`/`binary` by
|
|
||||||
`String::from_utf8`.
|
|
||||||
4. `write(transport, path, expected_sha256, bytes) -> Written`: the
|
|
||||||
script in decision 6, with a distinct exit code for the precondition
|
|
||||||
(`exit 3`) that the route maps to 409; anything else is the machine's
|
|
||||||
stderr.
|
|
||||||
5. `create_file`, `create_dir`: decision 7.
|
|
||||||
6. Routes in `routes.rs`, each resolving the setup with `setup_by_id` and
|
|
||||||
`Transport::for_setup` as `set_cwd` does. The path check (absolute or
|
|
||||||
`~`) is one function shared with `set_cwd`, which has it inline today.
|
|
||||||
7. Tests: the parsers; the quoting (a path that tries to close the quote
|
|
||||||
ends up as one absurd argument -- `ssh.rs` has the pattern); and an
|
|
||||||
integration test running each script through `Transport::Here`
|
|
||||||
against a `tempfile` tree, which is cheap because `sh` is there
|
|
||||||
wherever `cargo test` runs. The precondition test writes the file
|
|
||||||
between the read and the write and asserts the 409 path.
|
|
||||||
8. PLAN.md: the Security paragraph from decision 3, and an "Explorer"
|
|
||||||
section pointing here. AGENTS.md: the layout bullet for `files.rs`.
|
|
||||||
|
|
||||||
## App work
|
|
||||||
|
|
||||||
1. `Api.kt`: `fetchDir`, `fetchFile`, `writeFile`, `createFile`,
|
|
||||||
`createDir`, and the three data classes (`DirEntry`, `FileContent`
|
|
||||||
as a sealed class with the four kinds, `Written`).
|
|
||||||
2. `NerdIcons.kt` + `build-icon-font.sh`: decision 12.
|
|
||||||
3. `Languages.kt` (or `CodeFence.kt`, wherever `FENCE_LANGUAGES` sits):
|
|
||||||
`fileLanguage(name)`.
|
|
||||||
4. `FileLines.kt`: the pure half of the viewer -- spans bucketed per line,
|
|
||||||
`lineOf(index) -> AnnotatedString` -- so it has a JVM unit test beside
|
|
||||||
`HighlighterTest`, the app's one existing test suite, covering a block
|
|
||||||
comment that spans lines and a file with no trailing newline.
|
|
||||||
5. `FilesScreen.kt`: the listing, the navigation stack, the per-directory
|
|
||||||
cache, the create dialog (modelled on `AddSetupDialog`: fields, a busy
|
|
||||||
state, the failure shown inside the dialog beside the button that
|
|
||||||
caused it), and the header. `LoadState` for the listing.
|
|
||||||
6. `FileViewer.kt`: decision 8. `FileEditor.kt`: decision 9, including
|
|
||||||
the conflict dialog.
|
|
||||||
7. `AppRoot.kt`: decision 10. `SessionScreen.kt`: the folder glyph where
|
|
||||||
the speedometer was, `onFiles(setup, cwd)` out to the root.
|
|
||||||
8. `SessionSettingsDialog.kt`: the render-report row. In
|
|
||||||
`~/repos/emulator-tools`, `ui-trace`'s tap-by-label action; then the
|
|
||||||
two bench scripts onto it, with no coordinate tap left in `app/*.sh`.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- **Server**: `./run-tests.sh`, `cargo clippy --all-targets`, `cargo fmt`.
|
|
||||||
- **Local transport, by hand**: `./ui-sandbox.sh api
|
|
||||||
"/setups/<id>/dir?path=~"` against the sandbox, whose `$HOME` is a
|
|
||||||
throwaway tree it is fine to write into. The sandbox gets a small
|
|
||||||
fixture directory with the states worth seeing: an empty directory, a
|
|
||||||
file with a tab in its name, a binary file, one over `FILE_LIMIT`, an
|
|
||||||
unreadable one (`chmod 000`), a symlink to a directory, and a source
|
|
||||||
file in each of a few languages.
|
|
||||||
- **Remote transport**: the ssh-to-this-VM recipe in AGENTS.md ("How to
|
|
||||||
test SSH here"). The point of the exercise is the quoting and the
|
|
||||||
stdin path: write a file whose name has a `'` in it, and read it back.
|
|
||||||
- **Phone**: `ui-trace`, not screenshots, for the things this feature is
|
|
||||||
made of -- that the gutter's number and its line share a baseline at
|
|
||||||
the first and the last row, that a long line's row is wider than the
|
|
||||||
viewport and does not grow the row height, that the editor's gutter
|
|
||||||
stays put while the text scrolls sideways. Screenshots for colour and
|
|
||||||
contrast on `rawSurface`.
|
|
||||||
- **States to produce on purpose**, since the default state is the one
|
|
||||||
everybody looks at: a directory that fails to list (permission),
|
|
||||||
an unreachable machine (a setup pointing at a dead address), `binary`,
|
|
||||||
`tooBig`, the 409 conflict (edit the file with `sed -i` on the machine
|
|
||||||
between opening and saving), creating a name that exists, back with
|
|
||||||
unsaved edits, and the keyboard up over the editor.
|
|
||||||
|
|
||||||
## Numbers to measure, before deciding
|
|
||||||
|
|
||||||
- Scan time for a 1 MiB source file on the emulator, and on the phone
|
|
||||||
through the render report. That decides whether `FILE_LIMIT` is right
|
|
||||||
and whether edit mode highlights every keystroke or only below a size.
|
|
||||||
- Time to first line for a 1 MiB file over the tunnel: the read, the
|
|
||||||
transfer, the scan, the first composition. If the transfer dominates,
|
|
||||||
the route gains nothing from streaming; if the scan does, it moves to
|
|
||||||
a worker with the plain text drawn first.
|
|
||||||
- The `BasicTextField` at 20,000 lines: whether typing stays responsive.
|
|
||||||
If not, edit mode gets a lower cap than the viewer, stated in the
|
|
||||||
editor rather than discovered by a stuck keyboard.
|
|
||||||
|
|
||||||
## Order of work
|
|
||||||
|
|
||||||
Each step leaves the app working and is one commit.
|
|
||||||
|
|
||||||
1. Server: `files.rs` with `list` and `read`, routes, tests. Half a day.
|
|
||||||
2. App: icons, `Api.kt`, `FilesScreen` listing, `FileViewer`, the root
|
|
||||||
and session wiring, the render-report move with the benches. A day.
|
|
||||||
3. Server: `write`, `create_file`, `create_dir`, the stdin helper and
|
|
||||||
`ship_attachment` onto it. Half a day.
|
|
||||||
4. App: `FileEditor`, the create dialog, the conflict dialog. Half a day.
|
|
||||||
5. Measurements above, the sandbox fixture, PLAN.md and AGENTS.md. Half a
|
|
||||||
day.
|
|
||||||
|
|
||||||
## Later, deliberately not now
|
|
||||||
|
|
||||||
- Delete, rename and move. Destructive controls belong here eventually,
|
|
||||||
shown and confirmed rather than hidden, but none of them is needed to
|
|
||||||
read or change a file.
|
|
||||||
- Images in the viewer, through the existing `SessionImageViewer`.
|
|
||||||
- Following an agent's edits live: a file open in the viewer refreshing
|
|
||||||
when a `Write`/`Edit` tool call on the same path lands in the
|
|
||||||
transcript. The transcript already knows the path.
|
|
||||||
- Remembering the last directory per session.
|
|
||||||
- Uploading from the phone into a directory. Attachments already do the
|
|
||||||
upload half; this would be the same route with a chosen destination.
|
|
||||||
- Search within a file, and find-in-files.
|
|
||||||
@@ -1,35 +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
|
|
||||||
|
|
||||||
- [ ] 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.
|
|
||||||
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
# Transcript rendering: what was learned, and what is next
|
|
||||||
|
|
||||||
Written 2026-09-03 at the end of a week of work on the session screen's
|
|
||||||
transcript, so the next session can start from here rather than from a
|
|
||||||
compacted context. Work that is finished lives in "the architecture, as
|
|
||||||
built"; the running log of how each piece got there has been dropped. `AGENTS.md` holds the one-paragraph conventions; this is
|
|
||||||
the longer record: the measurements that drove each decision, the
|
|
||||||
techniques that worked, the ones that did not, and the order to do the rest
|
|
||||||
in. `PLAN.md` remains the design source of truth; nothing here contradicts
|
|
||||||
it.
|
|
||||||
|
|
||||||
## The goal, and where it stands
|
|
||||||
|
|
||||||
A reply of any length must scroll at the phone's 120Hz without a bump, and
|
|
||||||
must keep doing so while the reply is still streaming in. Measured on the
|
|
||||||
Pixel 9 Pro XL by Bryan, the transcript went from visible stalls at long
|
|
||||||
replies and at lists of links to "I have to actually try to feel any
|
|
||||||
bumps". The remaining work is finish and extensibility rather than
|
|
||||||
performance.
|
|
||||||
|
|
||||||
## The architecture, as built
|
|
||||||
|
|
||||||
Everything below lives under `app/androidApp/src/main/kotlin/com/example/aiapp/`.
|
|
||||||
|
|
||||||
**Rows become units, and units are bounded.** `TranscriptUnits.kt` turns a
|
|
||||||
transcript row into the things the lazy list actually holds. An assistant
|
|
||||||
reply is not one unit: it is one unit per piece of its markdown, so the
|
|
||||||
list composes and draws a paragraph, a fence, a table or one bullet at a
|
|
||||||
time. The reason is the draw phase: a row's display list holds every glyph
|
|
||||||
of it and is re-recorded whenever drawing is invalidated, and the lazy list
|
|
||||||
composes an item whole in the frame it scrolls into. The tallest single
|
|
||||||
row still being drawn before this was 36,982px, twenty-five screens in one
|
|
||||||
message. Long user messages are sliced the same way (`UserChunk`), through
|
|
||||||
the shared `cardPiece` modifier that draws one card in lazy-list pieces.
|
|
||||||
|
|
||||||
**One parse per message, addressed by piece.** `MarkdownPieces.kt`'s
|
|
||||||
`Piece(block, item)` is an address into the message's single parse tree,
|
|
||||||
not a substring: `block` indexes the root's children and `item` one
|
|
||||||
`LIST_ITEM` of a top-level list. Cutting was originally done by
|
|
||||||
re-parsing substrings, which cost a parse per piece and broke reference
|
|
||||||
links defined at the foot of a message. `ParsedReplies` caches the parse
|
|
||||||
and the piece list per text (`of`, `piecesOf`), warmed off the composing
|
|
||||||
thread by `TranscriptItems.warm`. The parser is still intellij-markdown via
|
|
||||||
the mikepenz renderer, but its `Markdown()` composable is not called at all:
|
|
||||||
`MarkdownRoot` in `Markdown.kt` provides the `Local*` environment itself --
|
|
||||||
reference links from the parse, padding, dimens, colours, typography, a
|
|
||||||
no-op image transformer, animations, components -- and `MarkdownElement`
|
|
||||||
dispatches a whole block through our component table. Nothing between a
|
|
||||||
piece and the screen is the library's now except the leaf composables that
|
|
||||||
table names.
|
|
||||||
|
|
||||||
**Lists are drawn an item at a time, by us.** The renderer has no element
|
|
||||||
for a single list item, so `MarkdownListItem` draws one: marker, then the
|
|
||||||
item's children, nested lists recursing through `MarkdownList`. The
|
|
||||||
marker is drawn in one place on purpose; styled bullets per depth go
|
|
||||||
there.
|
|
||||||
|
|
||||||
**Links are spans, not nodes.** `MarkdownLinks.kt`. Compose turns every
|
|
||||||
`LinkAnnotation` into a layout node (clipped, focusable, hoverable,
|
|
||||||
clickable, outline recomputed from the text layout). A paragraph of eight
|
|
||||||
links was nine nodes, and measured against the same paragraphs with each
|
|
||||||
link replaced by plain words it cost 26.3ms worst measure against 5.2ms,
|
|
||||||
1.7x the place time. That was the bump at a reply's list of sources.
|
|
||||||
`LinkedText` builds the annotated string with the renderer's own inline
|
|
||||||
builder but answers links itself: colour, underline, a string annotation
|
|
||||||
carrying the URL, and one tap detector for the whole text that asks the
|
|
||||||
layout which glyph is under the finger. Hit-testing must check the glyph
|
|
||||||
on either side of the returned caret, because `getOffsetForPosition`
|
|
||||||
returns the nearest boundary; taps on the right half of a glyph otherwise
|
|
||||||
open nothing. Headings need the `ATX_CONTENT`/`SETEXT_CONTENT` child, since
|
|
||||||
the inline builder draws nothing for a node type it does not know (a week
|
|
||||||
of blank headings). Tables go through `LinkedTable`/`LinkedTableRow` so
|
|
||||||
cells get the same treatment.
|
|
||||||
|
|
||||||
**An inline code chip is drawn behind the text, not as a span
|
|
||||||
background.** A `SpanStyle` background is part of the text's own drawing
|
|
||||||
and the text node draws the selection *under* the glyphs, so an opaque
|
|
||||||
chip hid the selection: selecting a sentence highlighted every word of it
|
|
||||||
except the ones in backticks, and there is no way to reorder that -- the
|
|
||||||
order is the node's. `appendCodeChip` therefore takes the code span from
|
|
||||||
the renderer's builder, keeps its style and its space of padding either
|
|
||||||
side but drops the background, and marks the range; `LinkedText` draws
|
|
||||||
those ranges in a `drawBehind`, which is under both the selection and the
|
|
||||||
glyphs -- the same place a fenced block's box already was, which is why
|
|
||||||
one of those always looked right. Geometry is one box per line, from the
|
|
||||||
bounding boxes of the run's first and last characters, taken as far as the
|
|
||||||
line's `visibleEnd`: `getPathForRange` is a *selection* shape and runs to
|
|
||||||
the right edge of every line but the last, which left a full-width empty
|
|
||||||
chip behind whenever the code wrapped, and `visibleEnd` is what makes the
|
|
||||||
chip and the selection rectangle stop in the same place. Measured against
|
|
||||||
the same build without it, streaming 60 paragraphs of three chips each:
|
|
||||||
measure 755ms against 776ms, record 327ms against 321ms, transcript draw
|
|
||||||
0.22ms in both -- noise.
|
|
||||||
|
|
||||||
**Text draws on the platform directly.** A paragraph without an image
|
|
||||||
skips the renderer's `MarkdownText`, which charges every paragraph for the
|
|
||||||
possibility of inline images (placement callback, derived inline-content
|
|
||||||
map, semantics group, size animation). Paragraphs that contain an image
|
|
||||||
still take the renderer's path.
|
|
||||||
|
|
||||||
**Tables spread or scroll without subcomposition.** The renderer used
|
|
||||||
`BoxWithConstraints` to decide; `LinkedTable` uses
|
|
||||||
`fillMaxWidth().horizontalScroll().layout { }` -- `horizontalScroll`
|
|
||||||
passes `minWidth` through and lifts `maxWidth` to infinity, so the inner
|
|
||||||
layout reads `minWidth` as the room available and takes
|
|
||||||
`max(minWidth, columns * cellWidth)`.
|
|
||||||
|
|
||||||
**A streaming reply is reparsed one block at a time.** `LiveParse` in
|
|
||||||
`Markdown.kt` freezes every finished top-level block with its parse and
|
|
||||||
reparses only the tail block per delta. Markdown's block rules make later
|
|
||||||
text unable to alter an earlier block, with the single exception of a
|
|
||||||
late reference definition, which is accepted. Measured on a 58-word stream
|
|
||||||
of list, fence, table and quote: 47 tail reparses at 1.7ms mean. A
|
|
||||||
single-list stream would reparse the whole list per delta, since it is one
|
|
||||||
tail block; that is what the rule below cuts.
|
|
||||||
|
|
||||||
**A streaming list becomes a unit per item.** `LiveParse.advanceTo` cuts at
|
|
||||||
the last item of a multi-item list (`openPiece`), provided that item has
|
|
||||||
content beyond its marker -- a bare `-` is an empty item now and the first
|
|
||||||
character of a paragraph line once `-x` arrives, so cutting on it would draw
|
|
||||||
that line as a new item. The cut is at the start of the item's line, so the
|
|
||||||
indentation the reparse reads its nesting from survives. `Segment.continues`
|
|
||||||
marks a tail that carries on a list, and `MarkdownPiece`'s
|
|
||||||
`continuesList`/`listContinues` keep an inner item's padding at the seam, so
|
|
||||||
nothing moves when the seam does. Forty linked bullets streamed a word at a
|
|
||||||
time went from 2412ms of reparsing to 674ms, and `record: one block` from
|
|
||||||
1.8ms worst to 0.7ms.
|
|
||||||
|
|
||||||
**Fences are highlighted off the drawing thread, and a fence still being
|
|
||||||
written is drawn plain.** `Highlighter.kt` holds `highlight` and the scanner
|
|
||||||
behind it (shared with a tool call's input, so the same code is the same
|
|
||||||
colours wherever it appears); `CodeFence.kt` holds the `fenceLanguage` alias
|
|
||||||
table and `fenceContent`. A word not in the table stays plain, because a
|
|
||||||
fence coloured by the wrong language's rules looks highlighted and is wrong
|
|
||||||
in a way the reader cannot see. Highlighting is warmed and cached exactly as
|
|
||||||
parsing is (`ParsedReplies.highlighted`, filled by `warm` from
|
|
||||||
`fences(parse)`), and `highlight` takes no colour from the theme, which is
|
|
||||||
what lets it run off the drawing thread: a two-hundred-line Kotlin fence
|
|
||||||
costs 15ms to scan on the emulator's debug build -- it cost 102ms through
|
|
||||||
the library that used to do this -- and a `remember` inside the fence was
|
|
||||||
charged that again every time the block scrolled back into composition. Because the warming has to ask for the same
|
|
||||||
string the drawing does, `fenceContent` extracts the code and the language
|
|
||||||
word itself -- two extractions would be two keys, and the warmed answer
|
|
||||||
would be missed at every fence with nothing saying so. A fence still
|
|
||||||
arriving is the same stall in a second place, and warming cannot reach it:
|
|
||||||
the tail was re-lexed at every delta, on the composing thread, for colours
|
|
||||||
on text being replaced as fast as they were computed -- 211 lexes and 13.7
|
|
||||||
seconds across one turn. So `MarkdownRoot`'s `streaming`, true only for a
|
|
||||||
live reply's last segment, draws the block plain until it freezes; a
|
|
||||||
finished fence colours as soon as the next block starts, and the settling
|
|
||||||
lex happens once, in `warm`.
|
|
||||||
|
|
||||||
**Markers, and images.** `MarkdownListItem`'s `Marker` draws the bullet by
|
|
||||||
depth, cycling past the third, in `listMarkerColor` (Theme.kt). The colour
|
|
||||||
is the same at every depth on purpose: depth is said by the glyph and the
|
|
||||||
indent, and a colour per depth would make a difference in degree look like
|
|
||||||
one in kind. 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*; an `IMAGE`
|
|
||||||
node is now appended by `appendPlainLink` as a link carrying its alt text
|
|
||||||
(the address when there is none), which says what was there and opens it.
|
|
||||||
|
|
||||||
**Expansion anchors the edge that was tapped, and the list never moves
|
|
||||||
under the reader** except when pinned to the bottom with new content
|
|
||||||
arriving. Those two rules are in `ScrollAnchor.kt` and `TranscriptList.kt`
|
|
||||||
and are the reason several tempting simplifications were rejected.
|
|
||||||
|
|
||||||
## Techniques and harness
|
|
||||||
|
|
||||||
- **`app/ui-sandbox.sh`** starts a second `ai-server` against a sandbox
|
|
||||||
home with the echo driver, so nothing touches real sessions.
|
|
||||||
`spawn [title]` makes an echo session and prints its id; `send SID text`
|
|
||||||
or `send SID @file` sends into it; `api /path [curl args]` is an
|
|
||||||
authenticated request. Restarting it regenerates the config but keeps
|
|
||||||
enrolled tokens.
|
|
||||||
- **The echo driver is the test rig** (`server/src/session/echo.rs`, the
|
|
||||||
list at the top of the file). `/stream N`, `/mixed N`, `/table N`,
|
|
||||||
`/tools N gap`, `/ask`, `/peer`, `/compact`, `/slow`, `/bash command`
|
|
||||||
each produce a shape the real CLI produces only when it feels like it.
|
|
||||||
Build what a UI test needs into it rather than spending model turns.
|
|
||||||
- **`app/transcript-bench.sh`** is the standard measurement: restart, open
|
|
||||||
the first session, scroll, print the render report. The report is what
|
|
||||||
the "Copy render timings" button copies and also logs
|
|
||||||
(`adb logcat -d -s ai-app:I`), and it includes the last crash's stack
|
|
||||||
(`CrashLog.kt`), which is how a crash on the phone reaches a session
|
|
||||||
here.
|
|
||||||
- **`app/stream-bench.sh [-k] FILE`** is `transcript-bench.sh` for a reply
|
|
||||||
still arriving: opens the first session, taps "Jump to latest" so the list
|
|
||||||
is pinned to the newest end, resets the report, sends FILE, waits for the
|
|
||||||
transcript to stop growing, prints the report. Both of those last two are
|
|
||||||
corrections to a first version that measured nothing -- a transcript parked
|
|
||||||
further back never redraws while a reply streams into it, and a session is
|
|
||||||
idle at *both* ends of a turn, so polling for idle answers before the turn
|
|
||||||
has started. Fixtures live in `/tmp` and are regenerated from the shapes
|
|
||||||
named here: `fixture.md` (lists four deep, ordered and nested, fences in
|
|
||||||
kotlin/rust/sh/none, a table with a link, a quote with a list, an inline
|
|
||||||
and a standalone image, a reference link), `longfence.md` (200-line Kotlin
|
|
||||||
fence), `longlist.md` (40 linked items).
|
|
||||||
- **Two traps in the emulator loop**, each of which cost a bench run.
|
|
||||||
`adb shell pm clear` removes the enrolment and the notification permission
|
|
||||||
along with the saved anchors, so the next run measures a permission
|
|
||||||
dialog; re-enrol with the command `ui-sandbox.sh` prints and
|
|
||||||
`pm grant ... POST_NOTIFICATIONS`. And a saved anchor is per session id,
|
|
||||||
so the only way two builds start a scroll from the same place is a *fresh
|
|
||||||
session for each*.
|
|
||||||
- **`DebugStats`/`FrameStats`** time our own phases (`record: one block`,
|
|
||||||
`measure: the app root`) and count events (`markdown reparsed while
|
|
||||||
streaming`, `markdown cut into pieces`). Add a counter before guessing.
|
|
||||||
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
|
|
||||||
framework, via `atrace` text output, no trace processor needed. It is
|
|
||||||
how the link-node cost was attributed.
|
|
||||||
- **`app/debug-transcript.sh`** loads a real Claude Code conversation onto
|
|
||||||
the emulator; two faults were invisible on fixtures and obvious on it.
|
|
||||||
Real transcripts are private: fixtures stay in `/tmp`, never in the repo.
|
|
||||||
- **`ui-trace`** reads the screen as text. Bounds print as
|
|
||||||
`x1,y1..x2,y2`; unanchored `-m` patterns match labels, anchored ones do
|
|
||||||
not. A row taller than the viewport reports clipped bounds, so compare
|
|
||||||
screenshots for that case.
|
|
||||||
- **Emulator frame times are not app measurements.** Software rendering
|
|
||||||
puts the stock Settings app at 60ms of UI-thread traversal per frame.
|
|
||||||
Costs of operations in milliseconds rank correctly; smoothness itself is
|
|
||||||
judged on the phone.
|
|
||||||
- **System Tracing on the phone does not work on GrapheneOS.** Its
|
|
||||||
Categories list is empty because the tracing daemon builds it by running
|
|
||||||
`atrace --list_categories`, which returns nothing there, and a recorded
|
|
||||||
trace contains zero ftrace events: no app sections, no frames, no
|
|
||||||
scheduling. Callstack sampling records, but the app's profiler config
|
|
||||||
unwinds one process shard in four. GrapheneOS issues 2206 and 6094 are
|
|
||||||
open on exactly this. Until they close, phone numbers come from the
|
|
||||||
render report and from Bryan noticing.
|
|
||||||
- **Compose `DropdownMenu` in an edge-to-edge activity** needs
|
|
||||||
`PopupProperties(clippingEnabled = false)` or it opens a status bar's
|
|
||||||
height away from its anchor (`~/.claude/TOOLCHAIN.md`).
|
|
||||||
- **The syntax highlighter is ours: `Highlighter.kt` and `Languages.kt`.**
|
|
||||||
One left-to-right scanner with a small state -- in a line comment, in a
|
|
||||||
block comment, in a string, or in ordinary code -- and a `Rules` row per
|
|
||||||
language, so a new language is a table entry rather than code. Every span
|
|
||||||
is emitted by advancing an index, so spans cannot overlap, arrive out of
|
|
||||||
order or run backwards, and an unterminated string or comment simply runs
|
|
||||||
to the end of the code. `HighlighterTest.kt` is the JVM unit test
|
|
||||||
(`./gradlew :androidApp:testDebugUnitTest`); the cases in it are the
|
|
||||||
library's mistakes, kept as regressions.
|
|
||||||
It replaced dev.snipme:highlights 1.1.0 on 2026-09-03, which found
|
|
||||||
comments before it knew the language and paired `/*` with `*/` by
|
|
||||||
ordinal. That library used one set of delimiters for every language, so
|
|
||||||
`//` in any URL commented out the rest of its line (in `curl
|
|
||||||
https://example.com/x && echo done` the comment ran to the end and took
|
|
||||||
`echo` with it, and in Kotlin `val url = "https://..."` the string
|
|
||||||
disappeared inside it), every Rust `#[derive(...)]` greyed out as a
|
|
||||||
comment, a `#` inside a Kotlin string swallowed the line, and `x '*/a/*'`
|
|
||||||
in shell yielded `start=6, end=5` -- a range `AnnotatedString` rejects,
|
|
||||||
which crashed a card holding `-path '*/.git/*'`. Comments were located
|
|
||||||
before strings and won over them, so post-processing could not recover
|
|
||||||
what a wrong comment range had already suppressed. The scanner is also
|
|
||||||
about seven times faster on the same fixture, and it colours RON, TOML,
|
|
||||||
fish and JSON, which the library did not know at all.
|
|
||||||
|
|
||||||
## Rejected, and why
|
|
||||||
|
|
||||||
- **Writing our own markdown renderer.** Rejected in favour of keeping
|
|
||||||
the intellij-markdown parser and the library's inline builder while
|
|
||||||
owning block dispatch and the leaf composables. The parser is the hard
|
|
||||||
part and is not the slow part; everything that was slow lived in the
|
|
||||||
composables, which are now ours.
|
|
||||||
- **Re-parsing substrings per piece.** Cost a parse per piece and broke
|
|
||||||
foot-of-message reference links. Replaced by addressed pieces of one
|
|
||||||
parse.
|
|
||||||
- **Animated or timing-dependent corrections.** Anything the reader could
|
|
||||||
catch at 120Hz is a bug; corrections must be structurally impossible to
|
|
||||||
see.
|
|
||||||
|
|
||||||
## What is next, in order
|
|
||||||
|
|
||||||
1. **The reconnect loop.** Restarting the app onto a session with a saved
|
|
||||||
anchor while a long reply was streaming left it reconnecting every 1.5s
|
|
||||||
(`RECONNECT_DELAY_MS`), spinner up, until the server was restarted.
|
|
||||||
`events?after=N` more than `CATCH_UP_LIMIT` (200) behind answers `reset`
|
|
||||||
plus the newest 200 *raw* deltas -- a window starting mid-message -- and
|
|
||||||
the reset clears `items`, which is the state the restore loop then pages
|
|
||||||
against. The restore's one-event-per-request bug was part of what made it
|
|
||||||
so visible and has been fixed; whether this survives that fix is the
|
|
||||||
first thing to find out.
|
|
||||||
2. **Regression runs.** `transcript-bench.sh` and `stream-bench.sh` before
|
|
||||||
and after any change to the files above, with the report in the commit.
|
|
||||||
The numbers to watch are the worst `record: one block`, the reparse mean
|
|
||||||
while streaming, and the draw phase's accounting line.
|
|
||||||
@@ -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,315 +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 (see `ansiPalette` in `Theme.kt`) so nothing on screen is a colour from
|
|
||||||
* somewhere else, but the two are not one table and must not become one -- adding a syntax role to
|
|
||||||
* this list would silently move `ls`'s directory blue.
|
|
||||||
*/
|
|
||||||
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: `ESC[0;32m` in front of
|
|
||||||
* every green word. Stripping them all would be the other half-answer -- colour is often the whole
|
|
||||||
* of what a diff, a test run or a linter is saying.
|
|
||||||
*
|
|
||||||
* So the sequences that decide how text *looks* become spans, and every other one is dropped.
|
|
||||||
* Dropped rather than shown, because the rest move a cursor around a grid this is not: a transcript
|
|
||||||
* is a scrolling document, and "go to column 40" has no meaning here that is better than nothing.
|
|
||||||
*
|
|
||||||
* A carriage return is honoured the way a terminal honours it: what was written since the last line
|
|
||||||
* break is thrown away and the line starts again. That is what makes a progress bar show its final
|
|
||||||
* state rather than every state it passed through, which was tens of lines run together.
|
|
||||||
*
|
|
||||||
* Not a composable, and the palette is a parameter: this can then 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 and the return itself would draw as a stray
|
|
||||||
// control character.
|
|
||||||
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, which is worse than the
|
|
||||||
* escape it was meant to remove. Three shapes -- the CSI (`ESC [ … letter`), the string escapes
|
|
||||||
* (OSC, DCS, APC, PM) which run to a terminator, and the two-character ones.
|
|
||||||
*/
|
|
||||||
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)
|
|
||||||
@@ -1,994 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import java.io.IOException
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
// The REST half of the backend's surface (see server/src/routes.rs for the
|
|
||||||
// table); the SSE half is EventStream.kt. All blocking network calls --
|
|
||||||
// invoke from a background dispatcher. Each throws ApiException on failure,
|
|
||||||
// carrying the server's own explanation where it sent one, since those
|
|
||||||
// messages are written to be read on this screen.
|
|
||||||
|
|
||||||
// Shared with EventStream.kt, which connects the same way but then reads
|
|
||||||
// without a deadline.
|
|
||||||
const val CONNECT_TIMEOUT_MS = 5000
|
|
||||||
private const val READ_TIMEOUT_MS = 5000
|
|
||||||
|
|
||||||
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs one request against the backend, with the pinned TLS setup, the bearer token, and the
|
|
||||||
* failure translation every call needs. [readBody] gets the connected, already-status-checked
|
|
||||||
* connection to read from.
|
|
||||||
*
|
|
||||||
* @param readTimeoutMs how long to wait on the response body. The SSE stream doesn't come through
|
|
||||||
* here -- an event stream has no bounded read time (see EventStream.kt).
|
|
||||||
*/
|
|
||||||
fun <T> requestFromServer(
|
|
||||||
settings: ServerSettings,
|
|
||||||
path: String,
|
|
||||||
method: String = "GET",
|
|
||||||
jsonBody: String? = null,
|
|
||||||
/**
|
|
||||||
* A request body written as it is produced -- the upload path. Content type, and a writer
|
|
||||||
* handed the connection's stream. Sent chunked, since what a writer will produce is not known
|
|
||||||
* up front and the point is that a file never sits whole in memory on this side.
|
|
||||||
*/
|
|
||||||
streamBody: Pair<String, (java.io.OutputStream) -> Unit>? = null,
|
|
||||||
readTimeoutMs: Int = READ_TIMEOUT_MS,
|
|
||||||
readBody: (HttpURLConnection) -> T,
|
|
||||||
): T {
|
|
||||||
val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection
|
|
||||||
try {
|
|
||||||
connection.applyPinnedTls()
|
|
||||||
connection.requestMethod = method
|
|
||||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
|
||||||
connection.readTimeout = readTimeoutMs
|
|
||||||
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
|
|
||||||
if (jsonBody != null) {
|
|
||||||
connection.doOutput = true
|
|
||||||
connection.setRequestProperty("Content-Type", "application/json")
|
|
||||||
connection.outputStream.use { it.write(jsonBody.encodeToByteArray()) }
|
|
||||||
} else if (streamBody != null) {
|
|
||||||
connection.doOutput = true
|
|
||||||
connection.setChunkedStreamingMode(0)
|
|
||||||
connection.setRequestProperty("Content-Type", streamBody.first)
|
|
||||||
connection.outputStream.use(streamBody.second)
|
|
||||||
}
|
|
||||||
if (connection.responseCode !in 200..299) {
|
|
||||||
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
|
|
||||||
throw ApiException(
|
|
||||||
when {
|
|
||||||
connection.responseCode == 401 ->
|
|
||||||
"The server rejected this device's token. Re-enroll by scanning " +
|
|
||||||
"the server's QR (or rotate with --rotate-token and scan the new one)."
|
|
||||||
detail.isNullOrEmpty() ->
|
|
||||||
"Server returned HTTP ${connection.responseCode} for $path"
|
|
||||||
else -> detail
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return readBody(connection)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: IOException) {
|
|
||||||
// Surfacing the real exception (rather than one canned message for
|
|
||||||
// every failure mode) is what lets this be diagnosed on a device
|
|
||||||
// with no logcat access.
|
|
||||||
throw ApiException(
|
|
||||||
"Couldn't reach the server at ${settings.baseUrl} " +
|
|
||||||
"(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
|
|
||||||
"this device able to reach that address (WireGuard up)?",
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw ApiException(
|
|
||||||
"Reached ${settings.baseUrl}$path but couldn't read its response " +
|
|
||||||
"(${e::class.simpleName}: ${e.message})",
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
connection.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The response body as one JSON object. */
|
|
||||||
private fun HttpURLConnection.jsonObject(): JSONObject =
|
|
||||||
JSONObject(inputStream.bufferedReader().readText())
|
|
||||||
|
|
||||||
/** The response body as a JSON array of objects, each mapped through [parse]. */
|
|
||||||
private fun <T> HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List<T> =
|
|
||||||
JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse)
|
|
||||||
|
|
||||||
private fun <T> JSONArray.mapObjects(parse: (JSONObject) -> T): List<T> =
|
|
||||||
(0 until length()).map { parse(getJSONObject(it)) }
|
|
||||||
|
|
||||||
private fun JSONArray.strings(): List<String> = (0 until length()).map { getString(it) }
|
|
||||||
|
|
||||||
/** Percent-encodes a value going into a query string. */
|
|
||||||
private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name())
|
|
||||||
|
|
||||||
// One row of GET /sessions. A session names the machine it runs on and
|
|
||||||
// which of that machine's providers it runs.
|
|
||||||
data class SessionSummary(
|
|
||||||
val id: String,
|
|
||||||
/**
|
|
||||||
* Id of the machine this session runs on. Only ever used to *address* that machine -- to pick
|
|
||||||
* this session's row out of the per-machine usage snapshots for the header's five-hour bar.
|
|
||||||
*
|
|
||||||
* It was deliberately left out until 2026-08-29, on the grounds that nothing here addressed a
|
|
||||||
* setup and holding both the id and the name invited showing the wrong one, which had already
|
|
||||||
* happened once. Something addresses one now, so the reason lapsed rather than being overruled.
|
|
||||||
* The guard that replaces it is the rule below: never show this.
|
|
||||||
*/
|
|
||||||
val setup: String,
|
|
||||||
/** The machine's current label. This is the one to display; [setup] is never shown. */
|
|
||||||
val setupName: String,
|
|
||||||
val provider: String,
|
|
||||||
val title: String,
|
|
||||||
val model: String?,
|
|
||||||
/**
|
|
||||||
* Whether the conversation would outlive deleting this session, decided by the server from the
|
|
||||||
* provider's kind rather than here from its name.
|
|
||||||
*
|
|
||||||
* What it licenses is narrow, and the delete dialog is worded to match: the driver keeps its
|
|
||||||
* own record of the conversation somewhere this app's delete does not reach. It is not a
|
|
||||||
* promise that the file is still there, and re-importing is not a restore -- this app's
|
|
||||||
* transcript holds things that record does not.
|
|
||||||
*/
|
|
||||||
val keepsOwnTranscript: Boolean,
|
|
||||||
/** How much the session asks before acting; null when it was never set. */
|
|
||||||
val permissionMode: String?,
|
|
||||||
/**
|
|
||||||
* Whether this continues a session the machine already had, which changes what deleting means.
|
|
||||||
*/
|
|
||||||
val imported: Boolean,
|
|
||||||
/**
|
|
||||||
* Whether this session announces itself when it wants attention.
|
|
||||||
*
|
|
||||||
* Reported rather than assumed, for the same reason [permissionMode] is: a switch that draws
|
|
||||||
* itself from a default is one you can turn off while believing you are reading it. Defaults to
|
|
||||||
* on when a backend is too old to say, which matches what that backend actually does.
|
|
||||||
*/
|
|
||||||
val notify: Boolean,
|
|
||||||
/**
|
|
||||||
* The directory the session works in, or null where it was never given one.
|
|
||||||
*
|
|
||||||
* Null is not "the home directory": it is the session never having been told, and what the
|
|
||||||
* process then starts in belongs to whatever launches it. Shown as unset rather than filled in
|
|
||||||
* with a guess, so a reader changing it is choosing rather than confirming.
|
|
||||||
*/
|
|
||||||
val cwd: String?,
|
|
||||||
/**
|
|
||||||
* How much context this session is holding, as the server last measured it -- see
|
|
||||||
* `SessionEvent.UsageDelta`.
|
|
||||||
*
|
|
||||||
* Null where nothing has been measured: a session that has not run a turn, a provider that does
|
|
||||||
* not report usage, or a clear nobody has run a turn since. That is not zero, and the status
|
|
||||||
* row says so in words rather than drawing an empty context for a conversation that may be
|
|
||||||
* nearly full.
|
|
||||||
*/
|
|
||||||
val contextTokens: Long?,
|
|
||||||
/**
|
|
||||||
* The longest edge an image should have when it reaches this session, or null where the
|
|
||||||
* provider has no limit.
|
|
||||||
*
|
|
||||||
* Null and "a big number" are different answers, and only the first stays true: a provider that
|
|
||||||
* does not care about size should not be given a threshold this app invented. Decided by the
|
|
||||||
* server because that is where a provider's kind is known -- see `uploadPickedImage`.
|
|
||||||
*/
|
|
||||||
val maxImageEdge: Int?,
|
|
||||||
val status: String,
|
|
||||||
val lastActivity: Double,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun parseSession(session: JSONObject) =
|
|
||||||
SessionSummary(
|
|
||||||
id = session.getString("id"),
|
|
||||||
setup = session.getString("setup"),
|
|
||||||
keepsOwnTranscript = session.optBoolean("keepsOwnTranscript", false),
|
|
||||||
setupName = session.getString("setupName"),
|
|
||||||
provider = session.getString("provider"),
|
|
||||||
title = session.getString("title"),
|
|
||||||
model = session.optString("model").ifEmpty { null },
|
|
||||||
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
|
||||||
imported = session.optBoolean("imported", false),
|
|
||||||
notify = session.optBoolean("notify", true),
|
|
||||||
cwd = session.optString("cwd").ifEmpty { null },
|
|
||||||
contextTokens =
|
|
||||||
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
|
||||||
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
|
||||||
status = session.getString("status"),
|
|
||||||
lastActivity = session.getDouble("lastActivity"),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
|
||||||
requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One session as the server has it now.
|
|
||||||
*
|
|
||||||
* For screens whose controls are *set to* something rather than merely showing it. A screen opened
|
|
||||||
* from a list row carries the row the list last fetched, which is a snapshot: fine for a title,
|
|
||||||
* wrong for a switch, since a switch drawn from a stale row shows a position that may have been
|
|
||||||
* changed since -- here or on another device -- and nothing on screen says which.
|
|
||||||
*/
|
|
||||||
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
|
|
||||||
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
|
|
||||||
|
|
||||||
// What the server offers, so the spawn screen has no hardcoded lists: a
|
|
||||||
// setup added to the server's config.ron appears here with no app rebuild.
|
|
||||||
//
|
|
||||||
// One list rather than two. A provider only exists on a machine that has
|
|
||||||
// it installed, so offering machines and providers as independent choices
|
|
||||||
// would offer pairs that cannot work.
|
|
||||||
data class Provider(val name: String, val kind: String, val models: List<String>)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A machine, and what it can run. [address] is absent for the backend itself.
|
|
||||||
*
|
|
||||||
* [id] is stable and [name] is not: renaming a machine keeps its sessions, so everything that
|
|
||||||
* refers to a setup uses the id and everything a person reads uses the name.
|
|
||||||
*/
|
|
||||||
data class Setup(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val address: String?,
|
|
||||||
val providers: List<Provider>,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun parseProvider(provider: JSONObject) =
|
|
||||||
Provider(
|
|
||||||
name = provider.getString("name"),
|
|
||||||
kind = provider.getString("kind"),
|
|
||||||
// Omitted entirely when the provider offers none.
|
|
||||||
models = provider.optJSONArray("models")?.strings().orEmpty(),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun parseSetup(setup: JSONObject) =
|
|
||||||
Setup(
|
|
||||||
id = setup.getString("id"),
|
|
||||||
name = setup.getString("name"),
|
|
||||||
address = setup.optString("address").ifEmpty { null },
|
|
||||||
providers = setup.getJSONArray("providers").mapObjects(::parseProvider),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun fetchSetups(settings: ServerSettings): List<Setup> =
|
|
||||||
requestFromServer(settings, "/setups") { it.jsonObjects(::parseSetup) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Claude Code session already on a machine, which can be continued here.
|
|
||||||
*
|
|
||||||
* Identified by [id] and never by a path. The server resolves which file that is, so this app has
|
|
||||||
* no way to ask it to read one -- the same rule that keeps a provider's command out of this client.
|
|
||||||
*/
|
|
||||||
data class Importable(
|
|
||||||
val id: String,
|
|
||||||
val cwd: String,
|
|
||||||
val title: String,
|
|
||||||
val modified: Double,
|
|
||||||
val lines: Int,
|
|
||||||
/**
|
|
||||||
* Size of the session file in bytes.
|
|
||||||
*
|
|
||||||
* Worth a place on the row because it is the only thing there that predicts what continuing the
|
|
||||||
* session costs, and the line count does not: these transcripts embed screenshots as base64, so
|
|
||||||
* a single line can be a megabyte.
|
|
||||||
*/
|
|
||||||
val bytes: Long,
|
|
||||||
/**
|
|
||||||
* Tokens the model was holding at the last turn, or null if no turn has recorded any.
|
|
||||||
*
|
|
||||||
* The number that predicts what continuing this session costs. It disagrees with [bytes] in the
|
|
||||||
* direction that matters: most of a large transcript is usually history from before a
|
|
||||||
* compaction, which the model is no longer given.
|
|
||||||
*/
|
|
||||||
val contextTokens: Long?,
|
|
||||||
/** Whether [title] is a name somebody chose rather than the last thing said in the session. */
|
|
||||||
val named: Boolean,
|
|
||||||
/**
|
|
||||||
* Whether a Claude Code is running this session right now.
|
|
||||||
*
|
|
||||||
* "unknown" is a third answer and not a synonym for "no": the machine may keep no record of
|
|
||||||
* what is running, and a session that cannot be checked is not a session that is free. The
|
|
||||||
* server refuses an import of a "yes"; the row says so before you press it.
|
|
||||||
*/
|
|
||||||
val inUse: String,
|
|
||||||
/**
|
|
||||||
* What this server is doing to the session right now -- "importing" or "deleting" -- or null
|
|
||||||
* when nothing is.
|
|
||||||
*
|
|
||||||
* The server's answer rather than the phone's, because the work outlives the screen that asked
|
|
||||||
* for it: leaving the import list and coming back has to show what is still running, and a
|
|
||||||
* phone that was asleep or out of range never saw the events that said so.
|
|
||||||
*/
|
|
||||||
val pending: String?,
|
|
||||||
/**
|
|
||||||
* How the last attempt on this row failed, if it did. Kept by the server until something
|
|
||||||
* replaces it, for the same reason [pending] is the server's to answer.
|
|
||||||
*/
|
|
||||||
val error: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing.
|
|
||||||
*
|
|
||||||
* [operation] is only set by a start, and [message] only by a failure -- the three states are every
|
|
||||||
* way an operation can be, and each carries exactly what that state knows.
|
|
||||||
*/
|
|
||||||
data class ImportableChange(
|
|
||||||
val session: String,
|
|
||||||
val state: String,
|
|
||||||
val operation: String?,
|
|
||||||
val message: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun parseImportableChange(payload: String): ImportableChange? =
|
|
||||||
try {
|
|
||||||
val frame = JSONObject(payload)
|
|
||||||
ImportableChange(
|
|
||||||
session = frame.getString("session"),
|
|
||||||
state = frame.getString("state"),
|
|
||||||
operation = frame.optString("operation").takeIf { it.isNotEmpty() },
|
|
||||||
message = frame.optString("message").takeIf { it.isNotEmpty() },
|
|
||||||
)
|
|
||||||
} catch (_: org.json.JSONException) {
|
|
||||||
// A frame this build does not understand is not a reason to drop the stream: the listing
|
|
||||||
// is the truth and will say what happened whatever this missed.
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a machine has that could be continued.
|
|
||||||
*
|
|
||||||
* The slowest call this app makes, and it was the only expensive one left on the 5 second default —
|
|
||||||
* which is how it came to time out against a server that was answering perfectly well. Listing
|
|
||||||
* means reading every transcript Claude Code has ever written: about four seconds against a
|
|
||||||
* gigabyte of them before the tunnel adds anything, and that figure grows with every session
|
|
||||||
* anybody has. A timeout is for a server that has stopped answering, so it is set well clear of how
|
|
||||||
* long the work takes rather than just above it.
|
|
||||||
*/
|
|
||||||
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
|
|
||||||
requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) {
|
|
||||||
it.jsonObjects { session ->
|
|
||||||
Importable(
|
|
||||||
id = session.getString("id"),
|
|
||||||
cwd = session.optString("cwd"),
|
|
||||||
title = session.optString("title"),
|
|
||||||
modified = session.optDouble("modified", 0.0),
|
|
||||||
lines = session.optInt("lines", 0),
|
|
||||||
bytes = session.optLong("bytes", 0L),
|
|
||||||
// Absent means nothing has been measured -- which is not a context of zero, so
|
|
||||||
// it stays null and the row simply does not claim a figure.
|
|
||||||
contextTokens =
|
|
||||||
if (session.isNull("contextTokens")) null
|
|
||||||
else session.optLong("contextTokens").takeIf { it > 0L },
|
|
||||||
// Absent means an older backend that cannot answer, which is exactly what
|
|
||||||
// "unknown" says -- so the default is the honest one rather than "no".
|
|
||||||
inUse = session.optString("inUse", "unknown"),
|
|
||||||
named = session.optBoolean("named", false),
|
|
||||||
pending = session.optString("pending").takeIf { it.isNotEmpty() },
|
|
||||||
error = session.optString("error").takeIf { it.isNotEmpty() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How to reach a machine. Deliberately carries no command: the server discovers what a machine can
|
|
||||||
* run by asking it, so this app has no way to introduce something to run.
|
|
||||||
*
|
|
||||||
* [identityFile] is a path on the *backend*, not a key -- private keys do not travel.
|
|
||||||
*/
|
|
||||||
data class SshDetails(
|
|
||||||
val address: String,
|
|
||||||
val port: Int? = null,
|
|
||||||
val identityFile: String? = null,
|
|
||||||
/**
|
|
||||||
* Where files attached from here land on that machine; null for the session's own directory.
|
|
||||||
*/
|
|
||||||
val attachmentsDir: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun SshDetails.toJson() =
|
|
||||||
JSONObject().put("address", address).apply {
|
|
||||||
if (port != null) put("port", port)
|
|
||||||
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
|
|
||||||
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a machine turns out to have, without saving anything. */
|
|
||||||
fun probeSetup(settings: ServerSettings, ssh: SshDetails?): List<Provider> =
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/setups/probe",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody = JSONObject().apply { if (ssh != null) put("ssh", ssh.toJson()) }.toString(),
|
|
||||||
readTimeoutMs = 40000,
|
|
||||||
) {
|
|
||||||
it.jsonObjects(::parseProvider)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addSetup(settings: ServerSettings, name: String, ssh: SshDetails?): Setup =
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/setups",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody =
|
|
||||||
JSONObject()
|
|
||||||
.put("name", name)
|
|
||||||
.apply { if (ssh != null) put("ssh", ssh.toJson()) }
|
|
||||||
.toString(),
|
|
||||||
readTimeoutMs = 40000,
|
|
||||||
) {
|
|
||||||
parseSetup(it.jsonObject())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Renames a machine, and optionally asks it again what it has. */
|
|
||||||
fun updateSetup(
|
|
||||||
settings: ServerSettings,
|
|
||||||
id: String,
|
|
||||||
name: String? = null,
|
|
||||||
rediscover: Boolean = false,
|
|
||||||
): Setup =
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/setups/${id.urlEncoded()}",
|
|
||||||
method = "PUT",
|
|
||||||
jsonBody =
|
|
||||||
JSONObject()
|
|
||||||
.apply {
|
|
||||||
if (name != null) put("name", name)
|
|
||||||
if (rediscover) put("rediscover", true)
|
|
||||||
}
|
|
||||||
.toString(),
|
|
||||||
readTimeoutMs = 40000,
|
|
||||||
) {
|
|
||||||
parseSetup(it.jsonObject())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteSetup(settings: ServerSettings, id: String) {
|
|
||||||
requestFromServer(settings, "/setups/${id.urlEncoded()}", method = "DELETE") {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spawns a session and returns it as the list would show it. [setup] names the machine and
|
|
||||||
* [provider] one of the things that machine offers.
|
|
||||||
*/
|
|
||||||
fun spawnSession(
|
|
||||||
settings: ServerSettings,
|
|
||||||
setup: String,
|
|
||||||
provider: String,
|
|
||||||
title: String,
|
|
||||||
model: String? = null,
|
|
||||||
cwd: String? = null,
|
|
||||||
permissionMode: String? = null,
|
|
||||||
params: Map<String, String> = emptyMap(),
|
|
||||||
/** Continue this Claude Code session instead of starting an empty one. */
|
|
||||||
import: String? = null,
|
|
||||||
): SessionSummary =
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/sessions",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody =
|
|
||||||
JSONObject()
|
|
||||||
.put("setup", setup)
|
|
||||||
.put("provider", provider)
|
|
||||||
.put("title", title)
|
|
||||||
.apply {
|
|
||||||
if (!model.isNullOrBlank()) put("model", model)
|
|
||||||
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
|
||||||
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
|
||||||
if (!import.isNullOrBlank()) put("import", import)
|
|
||||||
if (params.isNotEmpty()) {
|
|
||||||
put("params", JSONObject(params.toMap<String, Any>()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toString(),
|
|
||||||
readTimeoutMs = 30000,
|
|
||||||
) { connection ->
|
|
||||||
parseSession(connection.jsonObject())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendMessage(
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
text: String,
|
|
||||||
attachmentIds: List<String> = emptyList(),
|
|
||||||
) {
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/sessions/$sessionId/message",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody =
|
|
||||||
JSONObject()
|
|
||||||
.put("text", text)
|
|
||||||
.put("attachmentIds", JSONArray(attachmentIds))
|
|
||||||
.toString(),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Takes back a message the session has not read yet, named by the id its `messageQueued` carried.
|
|
||||||
*
|
|
||||||
* Throws rather than returning an outcome, because both ways of failing are things the reader has
|
|
||||||
* to be told: 409 means the session was already given it, and 404 means nothing is waiting under
|
|
||||||
* that id. The bubble disappearing is the success case and it arrives on the event stream, not from
|
|
||||||
* here -- every device drops it, not only the one that tapped.
|
|
||||||
*/
|
|
||||||
fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) {
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/sessions/$sessionId/unqueue",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody = JSONObject().put("messageId", messageId).toString(),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves a session to a different working directory.
|
|
||||||
*
|
|
||||||
* The server checks the directory is there on that machine and refuses if it is not -- a mistyped
|
|
||||||
* path accepted here would surface much later, as a session that would not start, with nothing
|
|
||||||
* pointing at the typo.
|
|
||||||
*
|
|
||||||
* Its process is **stopped**, because a working directory is settled when the process is spawned.
|
|
||||||
* The next thing said to the session starts it again in the new one, which is this app's rule for a
|
|
||||||
* session with no process everywhere else.
|
|
||||||
*/
|
|
||||||
fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) {
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/sessions/$sessionId/cwd",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody = JSONObject().put("cwd", cwd).toString(),
|
|
||||||
readTimeoutMs = 30000,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads one attachment, streamed by [write]; the returned id goes into [sendMessage]. [name] is
|
|
||||||
* what the server keeps a file under and tells the session; for an image it is ignored, since the
|
|
||||||
* model is shown the picture rather than told its name.
|
|
||||||
*/
|
|
||||||
fun uploadAttachment(
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
mime: String,
|
|
||||||
name: String,
|
|
||||||
write: (java.io.OutputStream) -> Unit,
|
|
||||||
): String {
|
|
||||||
val boundary = "----aiapp-${System.currentTimeMillis()}"
|
|
||||||
// The header is a line: a quote or a line break in the name would end it early.
|
|
||||||
val safeName = name.replace(Regex("[\"\r\n]"), "_")
|
|
||||||
val head =
|
|
||||||
("--$boundary\r\n" +
|
|
||||||
"Content-Disposition: form-data; name=\"file\"; filename=\"$safeName\"\r\n" +
|
|
||||||
"Content-Type: $mime\r\n\r\n")
|
|
||||||
.encodeToByteArray()
|
|
||||||
val tail = "\r\n--$boundary--\r\n".encodeToByteArray()
|
|
||||||
return requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/sessions/$sessionId/attachments",
|
|
||||||
method = "POST",
|
|
||||||
streamBody =
|
|
||||||
"multipart/form-data; boundary=$boundary" to
|
|
||||||
{ out ->
|
|
||||||
out.write(head)
|
|
||||||
write(out)
|
|
||||||
out.write(tail)
|
|
||||||
},
|
|
||||||
// Long: a trace is hundreds of megabytes, and the server copies it on to a remote
|
|
||||||
// machine before answering.
|
|
||||||
readTimeoutMs = 600000,
|
|
||||||
) { connection ->
|
|
||||||
connection.jsonObject().getString("id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetches an image the transcript references (produced or uploaded). */
|
|
||||||
fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray =
|
|
||||||
requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) {
|
|
||||||
it.inputStream.readBytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// One rate-limit window, rendered as a labeled bar on the usage screen.
|
|
||||||
data class UsageWindow(
|
|
||||||
/**
|
|
||||||
* The API's own word for which window this is -- "session" for the five-hour one.
|
|
||||||
*
|
|
||||||
* How to find a particular window. The label beside it is written for a person to read, so
|
|
||||||
* matching on it would select nothing the day its wording changes.
|
|
||||||
*/
|
|
||||||
val kind: String,
|
|
||||||
val label: String,
|
|
||||||
val percent: Double,
|
|
||||||
val resetsAt: String?,
|
|
||||||
val active: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UsageSnapshot(
|
|
||||||
val provider: String,
|
|
||||||
/** Stable id of the machine these numbers belong to. */
|
|
||||||
val setup: String,
|
|
||||||
/** That machine's current label. */
|
|
||||||
val setupName: String,
|
|
||||||
/**
|
|
||||||
* What came back: "ok", "notLoggedIn", "unreachable" or "failed".
|
|
||||||
*
|
|
||||||
* Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a
|
|
||||||
* machine somebody chose not to put an account on -- a fact, not a fault -- while the other two
|
|
||||||
* are faults worth chasing. Collapsing them made a healthy setup read as broken.
|
|
||||||
*/
|
|
||||||
val state: String,
|
|
||||||
/** Why, for the two states that are faults. Absent otherwise. */
|
|
||||||
val detail: String?,
|
|
||||||
val windows: List<UsageWindow>,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The backend caches; refreshing more often than its poll interval just re-reads the cache. */
|
|
||||||
fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
|
|
||||||
requestFromServer(settings, "/usage", readTimeoutMs = 30000) { connection ->
|
|
||||||
connection.jsonObjects { snapshot ->
|
|
||||||
UsageSnapshot(
|
|
||||||
provider = snapshot.getString("provider"),
|
|
||||||
setup = snapshot.optString("setup"),
|
|
||||||
setupName = snapshot.optString("setupName"),
|
|
||||||
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok"
|
|
||||||
// would draw an empty card as a healthy one.
|
|
||||||
state = snapshot.optString("state").ifEmpty { "failed" },
|
|
||||||
detail = snapshot.optString("detail").ifEmpty { null },
|
|
||||||
windows =
|
|
||||||
snapshot.getJSONArray("windows").mapObjects { window ->
|
|
||||||
UsageWindow(
|
|
||||||
kind = window.optString("kind").ifEmpty { "unknown" },
|
|
||||||
label = window.getString("label"),
|
|
||||||
percent = window.getDouble("percent"),
|
|
||||||
resetsAt = window.optString("resetsAt").ifEmpty { null },
|
|
||||||
active = window.getBoolean("active"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Answers one question with everything that was chosen.
|
|
||||||
*
|
|
||||||
* A list even when one thing was picked, because that is the shape of the answer rather than a
|
|
||||||
* special case of it. What a provider makes of several answers is its own business and is decided
|
|
||||||
* on the server; nothing here joins, splits or reformats them for one.
|
|
||||||
*/
|
|
||||||
fun answerQuestion(
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
questionId: String,
|
|
||||||
answers: List<String>,
|
|
||||||
) {
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/sessions/$sessionId/answer",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody =
|
|
||||||
JSONObject()
|
|
||||||
.put("questionId", questionId)
|
|
||||||
.put("answers", JSONArray(answers))
|
|
||||||
.toString(),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun interruptSession(settings: ServerSettings, sessionId: String) {
|
|
||||||
requestFromServer(settings, "/sessions/$sessionId/interrupt", method = "POST") {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ends the process behind a session, leaving the session and its transcript.
|
|
||||||
*
|
|
||||||
* Not a delete and not an interrupt: the conversation stays exactly where it is and [startSession]
|
|
||||||
* picks it back up. The server reports what it could not do -- there was nothing running, or the
|
|
||||||
* machine would not say whether there was -- rather than answering the same way either way.
|
|
||||||
*/
|
|
||||||
fun stopSession(settings: ServerSettings, sessionId: String) {
|
|
||||||
requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Starts the process again on the conversation it left. See [stopSession]. */
|
|
||||||
fun startSession(settings: ServerSettings, sessionId: String) {
|
|
||||||
requestFromServer(settings, "/sessions/$sessionId/start", method = "POST") {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Asks the machine to delete Claude Code sessions, and returns as soon as it has accepted the lot.
|
|
||||||
*
|
|
||||||
* The transcript *is* the session, so this ends any chance of resuming those conversations. The
|
|
||||||
* caller confirms first; see ImportScreen.
|
|
||||||
*
|
|
||||||
* The work runs on the server, so this returning is not the same as it being done -- what says that
|
|
||||||
* is each row's own state, through [fetchImportable] and the change stream. That is the point:
|
|
||||||
* leaving the screen used to cancel the delete it had started.
|
|
||||||
*
|
|
||||||
* One request for the whole batch, which is what makes a handover all-or-nothing. Sending one per
|
|
||||||
* row meant a batch could half-arrive -- four deleted, two never asked for -- and the two that were
|
|
||||||
* missed looked exactly like two that had not been picked.
|
|
||||||
*/
|
|
||||||
fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) {
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/setups/$setup/importable/delete",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody = JSONObject().put("sessions", JSONArray(sessionIds)).toString(),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Continues Claude Code sessions in the background, returning once the server has accepted them.
|
|
||||||
*
|
|
||||||
* Separate from [spawnSession] because the two are asked different questions. That one means "start
|
|
||||||
* this and take me to it", so it waits and answers with the session. This is the import list's
|
|
||||||
* batch: several at once, nobody waiting on any particular one, and the result arrives as a row
|
|
||||||
* changing rather than as a reply -- which is what lets the screen be left. One request for all of
|
|
||||||
* them, for the reason [deleteImportable] gives.
|
|
||||||
*/
|
|
||||||
fun startImport(
|
|
||||||
settings: ServerSettings,
|
|
||||||
setup: String,
|
|
||||||
sessionIds: List<String>,
|
|
||||||
provider: String,
|
|
||||||
permissionMode: String? = null,
|
|
||||||
model: String? = null,
|
|
||||||
) {
|
|
||||||
val body =
|
|
||||||
JSONObject().apply {
|
|
||||||
put("sessions", JSONArray(sessionIds))
|
|
||||||
put("provider", provider)
|
|
||||||
permissionMode?.let { put("permissionMode", it) }
|
|
||||||
model?.let { put("model", it) }
|
|
||||||
}
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/setups/$setup/importable/import",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody = body.toString(),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A page of a session's transcript, oldest first within the page.
|
|
||||||
*
|
|
||||||
* One request instead of one stream frame per event. The SSE stream is the right shape for live
|
|
||||||
* events and the wrong one for a backlog: opening an imported session replayed hundreds of frames
|
|
||||||
* before anything was readable, which looked exactly like the app loading top-down, because it was.
|
|
||||||
*
|
|
||||||
* [before] pages backwards for history somebody scrolls to; absent means the newest page.
|
|
||||||
*/
|
|
||||||
fun fetchTranscript(
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
before: Long? = null,
|
|
||||||
limit: Int = 80,
|
|
||||||
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page
|
|
||||||
// of a delta-heavy conversation is a page of the screen rather than a fraction of one message.
|
|
||||||
// The scroll-back pager wants this; the anchor restore does not (it counts events to a known
|
|
||||||
// seq). Ignored by the server for the newest window, where the live cursor needs real seqs.
|
|
||||||
// See the server's `read_window`.
|
|
||||||
coalesce: Boolean = false,
|
|
||||||
): List<SeqEvent> {
|
|
||||||
val query = buildString {
|
|
||||||
append("?limit=").append(limit)
|
|
||||||
if (before != null) append("&before=").append(before)
|
|
||||||
if (coalesce) append("&coalesce=true")
|
|
||||||
}
|
|
||||||
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
|
|
||||||
val body = JSONArray(connection.inputStream.bufferedReader().readText())
|
|
||||||
(0 until body.length()).map { parseSeqEvent(body.getJSONObject(it).toString()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renames a session.
|
|
||||||
*
|
|
||||||
* The name is the backend's own -- it is what the list shows and it exists before any process does
|
|
||||||
* -- so this settles it rather than asking. Where the thing running the session has a name of its
|
|
||||||
* own, the backend passes it on, which is what makes a session the same session in Claude Code's
|
|
||||||
* picker and to any other agent that lists it.
|
|
||||||
*/
|
|
||||||
fun renameSession(settings: ServerSettings, sessionId: String, title: String) {
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/sessions/$sessionId/title",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody = JSONObject().put("title", title).toString(),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Switches a running session's model; the CLI changes it in place. */
|
|
||||||
fun setSessionModel(settings: ServerSettings, sessionId: String, model: String) {
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/sessions/$sessionId/model",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody = JSONObject().put("model", model).toString(),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The permission modes the Claude CLI accepts, in the order they give up asking. "manual" asks for
|
|
||||||
* everything (each ask arrives on the phone as a question card); the others are the CLI's own
|
|
||||||
* escalating levels of autonomy.
|
|
||||||
*
|
|
||||||
* One list for every screen that offers them -- spawn, import, and the session's own picker --
|
|
||||||
* because three copies had already drifted: the import screen was missing "plan".
|
|
||||||
*/
|
|
||||||
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
|
||||||
|
|
||||||
/** Switches how much a running session asks before acting, also in place. */
|
|
||||||
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/sessions/$sessionId/permission-mode",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody = JSONObject().put("mode", mode).toString(),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */
|
|
||||||
fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) {
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/sessions/$sessionId/notify",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody = JSONObject().put("notify", notify).toString(),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Asks the session to run one of its own commands.
|
|
||||||
*
|
|
||||||
* Sent as typed. The server turns the two it understands into its own operations -- a compaction, a
|
|
||||||
* rename, which is also what the settings screen sends -- and passes anything else to whatever runs
|
|
||||||
* the session. Either way it waits for the turn to end if one is in flight, and says so on the
|
|
||||||
* event stream, which is where the waiting bubble comes from.
|
|
||||||
*/
|
|
||||||
fun runCommand(settings: ServerSettings, sessionId: String, text: String) {
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/sessions/$sessionId/command",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody = JSONObject().put("text", text).toString(),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Asks the session to summarise its own history and carry on from the summary.
|
|
||||||
*
|
|
||||||
* Nothing comes back here: a compaction takes a minute or two, and what it is doing arrives on the
|
|
||||||
* event stream like everything else -- a `compacting` status while it runs, then how much context
|
|
||||||
* it recovered. A call that waited would be a second, worse account of the same thing.
|
|
||||||
*/
|
|
||||||
fun compactSession(settings: ServerSettings, sessionId: String) {
|
|
||||||
requestFromServer(settings, "/sessions/$sessionId/compact", method = "POST") {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a session, and optionally the machine's own transcript of the same conversation.
|
|
||||||
*
|
|
||||||
* [deleteForeign] is the delete this app cannot otherwise reach: Claude Code keeps its own record
|
|
||||||
* under `~/.claude/projects`, and leaving it is what makes an ordinary delete recoverable. The
|
|
||||||
* server does both halves, and does the unrecoverable one first, so a machine it cannot reach
|
|
||||||
* leaves the session exactly where it was rather than half-deleted.
|
|
||||||
*/
|
|
||||||
fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Boolean = false) {
|
|
||||||
val query = if (deleteForeign) "?deleteForeign=true" else ""
|
|
||||||
requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Models: what this backend has downloaded, what it is downloading, and
|
|
||||||
// what HuggingFace offers. Browsing is proxied by the server rather than
|
|
||||||
// done here, because this app trusts exactly one certificate and has no
|
|
||||||
// general internet trust to spend on huggingface.co.
|
|
||||||
|
|
||||||
data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A download in flight or finished. [total] is null when the server never said how big the file is
|
|
||||||
* -- which must render as "not known", never as a bar at some invented position.
|
|
||||||
*/
|
|
||||||
data class Download(
|
|
||||||
val key: String,
|
|
||||||
val run: Long,
|
|
||||||
val repo: String,
|
|
||||||
val file: String,
|
|
||||||
val state: String,
|
|
||||||
val done: Long,
|
|
||||||
val total: Long?,
|
|
||||||
val error: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Models(val local: List<LocalModel>, val downloads: List<Download>)
|
|
||||||
|
|
||||||
data class RemoteRepo(val id: String, val downloads: Long, val likes: Long)
|
|
||||||
|
|
||||||
data class RemoteFile(val path: String, val bytes: Long, val have: Boolean)
|
|
||||||
|
|
||||||
private fun parseDownload(o: JSONObject) =
|
|
||||||
Download(
|
|
||||||
key = o.getString("key"),
|
|
||||||
run = o.getLong("run"),
|
|
||||||
repo = o.getString("repo"),
|
|
||||||
file = o.getString("file"),
|
|
||||||
state = o.getString("state"),
|
|
||||||
done = o.getLong("done"),
|
|
||||||
// Absent rather than zero when unknown; see the field's comment.
|
|
||||||
total = if (o.has("total")) o.getLong("total") else null,
|
|
||||||
error = if (o.has("error")) o.getString("error") else null,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun fetchModels(settings: ServerSettings): Models =
|
|
||||||
requestFromServer(settings, "/models") { connection ->
|
|
||||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
|
||||||
Models(
|
|
||||||
local =
|
|
||||||
body.getJSONArray("local").mapObjects { m ->
|
|
||||||
LocalModel(
|
|
||||||
key = m.getString("key"),
|
|
||||||
repo = m.getString("repo"),
|
|
||||||
file = m.getString("file"),
|
|
||||||
bytes = m.getLong("bytes"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
downloads = body.getJSONArray("downloads").mapObjects(::parseDownload),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun searchModels(settings: ServerSettings, query: String): List<RemoteRepo> =
|
|
||||||
requestFromServer(settings, "/models/search?q=${query.urlEncoded()}") { connection ->
|
|
||||||
connection.jsonObjects { r ->
|
|
||||||
RemoteRepo(
|
|
||||||
id = r.getString("id"),
|
|
||||||
downloads = r.getLong("downloads"),
|
|
||||||
likes = r.getLong("likes"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fetchRepoFiles(settings: ServerSettings, repo: String): List<RemoteFile> =
|
|
||||||
requestFromServer(settings, "/models/files?repo=${repo.urlEncoded()}") { connection ->
|
|
||||||
connection.jsonObjects { f ->
|
|
||||||
RemoteFile(
|
|
||||||
path = f.getString("path"),
|
|
||||||
bytes = f.getLong("bytes"),
|
|
||||||
have = f.getBoolean("have"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startDownload(settings: ServerSettings, repo: String, file: String): Download =
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/models/download",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody = JSONObject().put("repo", repo).put("file", file).toString(),
|
|
||||||
) { connection ->
|
|
||||||
parseDownload(JSONObject(connection.inputStream.bufferedReader().readText()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelDownload(settings: ServerSettings, key: String) {
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/models/cancel",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody = JSONObject().put("key", key).toString(),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteModel(settings: ServerSettings, key: String) {
|
|
||||||
requestFromServer(
|
|
||||||
settings,
|
|
||||||
"/models/delete",
|
|
||||||
method = "POST",
|
|
||||||
jsonBody = JSONObject().put("key", key).toString(),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
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.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.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 setups are not here any more. They are tabs inside [MainScreen] -- four views
|
|
||||||
* of the same backend, none of them a step down from another -- and what is left in this `when` is
|
|
||||||
* only what genuinely is a step down: one session, spawning one, and settings. A session's own
|
|
||||||
* settings are not among them: they are a dialog over the session, which is where the thing they
|
|
||||||
* change is.
|
|
||||||
*/
|
|
||||||
private sealed class Screen {
|
|
||||||
data object Main : Screen()
|
|
||||||
|
|
||||||
data class Session(val summary: SessionSummary) : Screen()
|
|
||||||
|
|
||||||
data object Spawn : 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 -- see MainActivity, which counts them.
|
|
||||||
*/
|
|
||||||
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 instead of showing a stale list.
|
|
||||||
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 that might be
|
|
||||||
// caused by it. 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, which is a
|
|
||||||
// snapshot the list already fetched, 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 --
|
|
||||||
// see the layout note in SessionScreen.
|
|
||||||
when (val here = screen) {
|
|
||||||
is 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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is Screen.Session ->
|
|
||||||
// Keyed on the id, because a different session is a different screen rather than this
|
|
||||||
// one showing other rows. SessionScreen remembers a transcript, an open event stream, a
|
|
||||||
// draft and a scroll position, and without the key Compose keeps all of it across the
|
|
||||||
// change and merges two conversations -- which crashes the list on the first duplicate
|
|
||||||
// row key. Only reachable since a notification can move straight from one session to
|
|
||||||
// another; every other way here passes through [Screen.Main], which disposes it anyway.
|
|
||||||
key(here.summary.id) {
|
|
||||||
// The gesture goes on a box around the screen rather than inside it, so it is the
|
|
||||||
// outermost thing in the tree and everything within has already had its chance at
|
|
||||||
// the drag. See [swipeBack]. No imePadding here, for the reason above.
|
|
||||||
Box(Modifier.swipeBack(goToMain)) {
|
|
||||||
SessionScreen(
|
|
||||||
settings = current,
|
|
||||||
summary = here.summary,
|
|
||||||
onBack = goToMain,
|
|
||||||
share = share,
|
|
||||||
onShareTaken = { share = null },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is Screen.Spawn ->
|
|
||||||
Box(Modifier.imePadding().swipeBack(goToMain)) {
|
|
||||||
SpawnScreen(
|
|
||||||
settings = current,
|
|
||||||
onSpawned = { spawned ->
|
|
||||||
reloadToken++
|
|
||||||
screen = Screen.Session(spawned)
|
|
||||||
},
|
|
||||||
onBack = goToMain,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is Screen.Settings ->
|
|
||||||
Box(Modifier.imePadding().swipeBack(goToMain)) {
|
|
||||||
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, so it is not the
|
|
||||||
// page's job to leave room for it. Tapping one is the same act as tapping a notification, so
|
|
||||||
// it goes through the same `open`, failure dialog included.
|
|
||||||
SessionAlerts(onOpen = { request -> scope.launch { open(request) } })
|
|
||||||
}
|
|
||||||
@@ -1,402 +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, been recorded and
|
|
||||||
* come back as an event, so on a phone the card sat unchanged for most of a second after a tap and
|
|
||||||
* the natural response was to tap again.
|
|
||||||
*
|
|
||||||
* Picked options and typed words are one field each because they are alternatives rather than
|
|
||||||
* parts: answering in the reader's own words is the case no option covers, so typing puts the picks
|
|
||||||
* away and picking puts the words away, and there is never a draft that means two things.
|
|
||||||
*/
|
|
||||||
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 -- what each option means, what picking it
|
|
||||||
* would produce, whether several may be picked at once. None of it is read out of the call's own
|
|
||||||
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
|
|
||||||
* app, where no other provider can reach it and where it drifts the first time the schema moves.
|
|
||||||
*
|
|
||||||
* One question on screen with arrows to the others, rather than all of them stacked. A card asking
|
|
||||||
* three questions with four options and a description each is several screens tall, so the reader
|
|
||||||
* scrolls past the question they are answering to reach the button that sends it, and never sees
|
|
||||||
* the whole of any one of them. Paged, each question is a screen and the count says how many are
|
|
||||||
* left -- which is also what makes "not all of them are answered" something the reader can act on
|
|
||||||
* rather than something to go hunting for.
|
|
||||||
*
|
|
||||||
* Nothing is sent until Submit. Answering is one act even when it is several questions: the tool
|
|
||||||
* asked them together and is waiting on all of them, and sending each as it was tapped meant the
|
|
||||||
* reader could not change their mind about the first after reading the third.
|
|
||||||
*/
|
|
||||||
@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 that 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 one 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. A question is the same thing either way, and 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 still 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 -- see
|
|
||||||
// [OtherAnswer]. Only ever the answers that match nothing offered, so a question answered
|
|
||||||
// by picking says it by the mark alone.
|
|
||||||
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, which is the failure a tint step routinely produces on a dark theme. 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, and the reader
|
|
||||||
* cannot tell that it was ever open.
|
|
||||||
*/
|
|
||||||
@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 does not read as a bug: it reads as those having been the only choices, which is the worst
|
|
||||||
* way for a list of choices to be wrong.
|
|
||||||
*/
|
|
||||||
@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,66 +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 or a log with -- so the
|
|
||||||
* name is the whole 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 -- and either
|
|
||||||
* ellipsis alone takes away one of them.
|
|
||||||
*/
|
|
||||||
@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,175 +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 of this on a phone is the upload, not the decode. What the limit *is* comes from
|
|
||||||
* the server, per session -- see `DriverKind::max_image_edge` -- because that is where a provider's
|
|
||||||
* requirements are known, and a phone that carried its own copy of them would be a second place to
|
|
||||||
* update when one changes.
|
|
||||||
*/
|
|
||||||
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 or a log 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}", 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, so neither is left to end the process.
|
|
||||||
*/
|
|
||||||
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, and there is
|
|
||||||
* nothing to gain from it. 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, with
|
|
||||||
// nothing anywhere saying so. 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,54 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
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.Shape
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
// The composer's row of settings and pickers, and the menus they open. One file because the
|
|
||||||
// outline and the corner are one appearance: a control shaped like this opens a surface shaped
|
|
||||||
// like this, and a reader learns the pair once.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A bordered pill: a control that can be seen without being pressed.
|
|
||||||
*
|
|
||||||
* The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at
|
|
||||||
* all until they are touched. Three bare words sitting under the message field read as a caption
|
|
||||||
* about the field rather than as three things to press, and the only way to find out otherwise was
|
|
||||||
* to press one. The outline says "control" without the weight of a filled button, which is reserved
|
|
||||||
* here for the two that act on the session (send, and start/stop).
|
|
||||||
*/
|
|
||||||
@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. This is the roundest corner that still leaves a straight edge
|
|
||||||
* beside a one-line option, which is the shortest menu here.
|
|
||||||
*/
|
|
||||||
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
|
|
||||||
@@ -1,101 +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
|
|
||||||
* rather than something the reader learns once.
|
|
||||||
*
|
|
||||||
* [label] names the operation and `null` means none is running. One parameter rather than a boolean
|
|
||||||
* beside a string, which can disagree: there is no such thing as busy with nothing happening. It is
|
|
||||||
* a *word* because a spinner alone cannot say which operation this is — deleting and importing are
|
|
||||||
* different in kind, and losing a session to the wrong one is not recoverable by waiting.
|
|
||||||
*
|
|
||||||
* It does **not** make the row inert; the caller disables its own click handling while it passes a
|
|
||||||
* label. That was the other way round at first — an overlay consuming pointer events, so no caller
|
|
||||||
* had to remember — and it swallowed the drag along with the tap, which meant a list could not be
|
|
||||||
* scrolled while anything in it was busy. Consuming taps but not drags means re-deciding what a
|
|
||||||
* gesture is above the components that already decide it; disabling the click is the platform's own
|
|
||||||
* answer and leaves the scroll where it belongs.
|
|
||||||
*/
|
|
||||||
@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 what this app already used for a row on its
|
|
||||||
* way out, and it is the same cue as a disabled control, so a busy row read as one more thing that
|
|
||||||
* could not be tapped. Draining the colour is what says the row is *suspended* — the status word,
|
|
||||||
* the accent on a warning and everything else that means something by its colour stop meaning it
|
|
||||||
* for as long as the operation runs, which is exactly true: none of them is being kept up to date.
|
|
||||||
*
|
|
||||||
* Not all the way to grey. A row with no colour left is hard to find again in a list, and the
|
|
||||||
* reader is watching this one.
|
|
||||||
*/
|
|
||||||
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,72 +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, where x runs across the opening and
|
|
||||||
* y runs from the open side to the tip, and [Pointing] is only a table of how those two map onto
|
|
||||||
* the box.
|
|
||||||
*
|
|
||||||
* It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole
|
|
||||||
* of what assistive technology has to go on, and it is also the answer to "what was that arrow for"
|
|
||||||
* six months from now.
|
|
||||||
*/
|
|
||||||
@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,208 +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, through [highlighted] and one palette, so a
|
|
||||||
* `kotlin` fence and the Kotlin a tool wrote are the same colours. A fence in a language [scan] has
|
|
||||||
* no rules for is plain rather than wrongly coloured: [fenceLanguage] answers null for those, and
|
|
||||||
* plain is what the reader would have seen before.
|
|
||||||
*
|
|
||||||
* Finding the code is still the library's: which children of the node are the fence markers, the
|
|
||||||
* language word and the code between them is its knowledge of the parser, and [MarkdownCodeFence]
|
|
||||||
* hands out the code and the language and leaves the drawing to the block it is given.
|
|
||||||
*/
|
|
||||||
@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.
|
|
||||||
*
|
|
||||||
* Which children of the node are the fence markers, the language word and the code between them is
|
|
||||||
* the library's knowledge of the parser, copied from its `MarkdownCodeFence` rather than called:
|
|
||||||
* that one is a composable, and the whole point of this function is that [warm] can run it on a
|
|
||||||
* background thread and highlight the same string the drawing will ask for. Two extractions would
|
|
||||||
* be two keys, and the warmed answer would be silently missed at every fence.
|
|
||||||
*
|
|
||||||
* Null for a fence too short to hold anything -- an unterminated one still arriving, which the
|
|
||||||
* library skips as invalid.
|
|
||||||
*/
|
|
||||||
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.
|
|
||||||
* The vertical margin is the renderer's too, kept so a reply's fences sit where they always have.
|
|
||||||
*/
|
|
||||||
@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;
|
|
||||||
// see [MarkdownRoot]'s `streaming`.
|
|
||||||
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]
|
|
||||||
|
|
||||||
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,
|
|
||||||
"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,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,153 +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 to whatever runs the session, because a dialect's own vocabulary is its own and grows
|
|
||||||
* without this list -- it just arrives unannounced and unexplained.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
* and reads as having been missed.
|
|
||||||
*/
|
|
||||||
@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,68 +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. It has no collapsed form -- it
|
|
||||||
* is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which
|
|
||||||
* a clear also uses, so the two marks cannot drift apart.
|
|
||||||
*
|
|
||||||
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it,
|
|
||||||
* which is the same thing the status line says while the compaction runs.
|
|
||||||
*/
|
|
||||||
@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 -- and they are all this says, because a divider is read in passing.
|
|
||||||
* When they were not reported this says only that a compaction happened, rather than filling in a
|
|
||||||
* plausible number or explaining at length what was missing.
|
|
||||||
*/
|
|
||||||
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 of them 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, percentage or
|
|
||||||
* estimate here would be this screen's guess wearing a measurement's clothes. Knowing it has been
|
|
||||||
* going forty seconds is what a reader actually wants -- it is the difference between waiting and
|
|
||||||
* going to look at why.
|
|
||||||
*
|
|
||||||
* [seconds] is null when this device did not see the compaction start, which is what opening a
|
|
||||||
* session that is already compacting looks like. That case says only "compacting": no number is the
|
|
||||||
* honest answer, and a number counted from the moment the screen opened would be wrong in the
|
|
||||||
* direction that matters, since a compaction somebody is asking about is a long one.
|
|
||||||
*/
|
|
||||||
fun compactingLabel(seconds: Long?): String =
|
|
||||||
when {
|
|
||||||
seconds == null -> "compacting"
|
|
||||||
seconds < 60 -> "compacting ${seconds}s"
|
|
||||||
else -> "compacting ${seconds / 60}m ${seconds % 60}s"
|
|
||||||
}
|
|
||||||
@@ -1,66 +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 -- and a
|
|
||||||
* crash report that arrives a day later, without the stack, is a guess. This costs one file write
|
|
||||||
* on a process that is already dying, and it turns "it crashes when I open that chat" into the
|
|
||||||
* frame it crashed in.
|
|
||||||
*
|
|
||||||
* 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, so
|
|
||||||
* what gets cut is the part nobody reads.
|
|
||||||
*/
|
|
||||||
private const val CRASH_LIMIT = 4000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records uncaught exceptions, then lets the platform do what it was going to do.
|
|
||||||
*
|
|
||||||
* Chained rather than replacing: the default handler is what shows the "app has stopped" dialog and
|
|
||||||
* ends the process, and an app that swallows that instead sits there in an unknown state. This only
|
|
||||||
* adds a witness.
|
|
||||||
*/
|
|
||||||
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,164 +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, and a frame number taken in it says nothing about a
|
|
||||||
* 120Hz phone. Counts do not have that problem: how many times a row was composed, or a reply
|
|
||||||
* parsed, is the same number on any machine, and it is the number that says whether the work is
|
|
||||||
* proportional to what is on screen or to everything ever loaded.
|
|
||||||
*
|
|
||||||
* 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, its own placement and its own
|
|
||||||
* recording, and this is the subtraction that was otherwise done by hand in a conversation every
|
|
||||||
* time a report arrived. What is left over is the framework's per-frame bookkeeping after a layout,
|
|
||||||
* which grows with how many nodes are alive rather than with how many are on screen.
|
|
||||||
*
|
|
||||||
* Per frame rather than in total, because the budget it has to fit in is per frame. The recordings
|
|
||||||
* are not themselves per-frame -- a measurement happens on the frames that need one -- so these are
|
|
||||||
* shares of an average frame, not a claim about any particular one.
|
|
||||||
*/
|
|
||||||
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, and
|
|
||||||
* the whole point of it is to save one.
|
|
||||||
*/
|
|
||||||
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,54 +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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 rather than a
|
|
||||||
* coloured phrase sitting in an unrelated grey line.
|
|
||||||
*
|
|
||||||
* Written once here rather than styled at each of them, so the two cannot drift into looking like
|
|
||||||
* different kinds of thing.
|
|
||||||
*/
|
|
||||||
@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 mark a clear leaves.
|
|
||||||
*
|
|
||||||
* Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a
|
|
||||||
* compaction it summarises nothing and measures nothing, so there is nothing to report but the
|
|
||||||
* fact. Everything above stays on screen and stays scrollable -- the reader can see that, which is
|
|
||||||
* why this does not say it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ClearedRow(modifier: Modifier = Modifier) {
|
|
||||||
TranscriptDivider("Context cleared", clearedColor, modifier)
|
|
||||||
}
|
|
||||||
@@ -1,36 +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, written on every keystroke, and half a sentence surfacing
|
|
||||||
* on another device would be a surprise rather than a convenience. What has been *sent* is the
|
|
||||||
* server's, and that is the part which has to outlive this phone.
|
|
||||||
*
|
|
||||||
* Kept per session id, because the thing being typed belongs to the conversation it is aimed at:
|
|
||||||
* one shared box would hand a message meant for one session to whichever was opened next.
|
|
||||||
*/
|
|
||||||
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 -- so a sent message removes its own
|
|
||||||
* entry and nothing accumulates for a session in ordinary use. A session *deleted* while it held a
|
|
||||||
* draft does leave its key behind: pruning those means a pass over the live session list, which
|
|
||||||
* this file would otherwise have no reason to know about, and the residue is a few bytes per
|
|
||||||
* session ever abandoned mid-sentence. That is a trade rather than an oversight.
|
|
||||||
*/
|
|
||||||
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,38 +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 of it carries
|
|
||||||
* the rest -- `2.5s`, `30ms`. At a minute or more the question is "how long exactly", so every unit
|
|
||||||
* that has something in it is written out -- `5d 12h 4m`. Units that are empty are left out rather
|
|
||||||
* than written as zero, since the labels say which is which and `5d 0h 4m` is only longer.
|
|
||||||
*
|
|
||||||
* Sub-second precision is dropped past a minute: nothing that takes days is measured in
|
|
||||||
* milliseconds, and carrying them would make the common case the widest one.
|
|
||||||
*/
|
|
||||||
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,38 +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. See SessionScreen.
|
|
||||||
*/
|
|
||||||
class EventStream(settings: ServerSettings, private val sessionId: String) {
|
|
||||||
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 -- the same thing it does when the screen opens. It
|
|
||||||
* arrives before those events, so a caller that clears on it stays in order.
|
|
||||||
*/
|
|
||||||
fun run(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
|
|
||||||
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
|
|
||||||
// A named frame carries no payload and a data frame has no name, so this is one or
|
|
||||||
// the other.
|
|
||||||
if (name == RESET_EVENT) onReset()
|
|
||||||
else if (data.isNotEmpty()) onEvent(parseSeqEvent(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,319 +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: what an option means,
|
|
||||||
* and what picking it would produce, are the things that decide it. 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 since 2026-09-03
|
|
||||||
* any file, told apart by [isImageRef].
|
|
||||||
*
|
|
||||||
* On the message rather than beside it: these arrived as separate image events until
|
|
||||||
* 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent
|
|
||||||
* it, and left this app deciding from adjacency alone which message an image went with --
|
|
||||||
* something the sender knew and could simply have said.
|
|
||||||
*/
|
|
||||||
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 or restarting the app drew nothing waiting while the
|
|
||||||
* message was still queued -- and nothing waiting is what "there is nothing" looks like.
|
|
||||||
*
|
|
||||||
* Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by
|
|
||||||
* [CommandSent].
|
|
||||||
*/
|
|
||||||
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 -- with nothing left to resolve it, since the [UserMessage] that normally does is
|
|
||||||
* exactly what was cancelled.
|
|
||||||
*/
|
|
||||||
data class MessageDropped(val id: String) : SessionEvent()
|
|
||||||
|
|
||||||
data class AssistantText(val delta: String) : SessionEvent()
|
|
||||||
|
|
||||||
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
|
|
||||||
|
|
||||||
data class ToolUpdate(val id: String, val output: String) : SessionEvent()
|
|
||||||
|
|
||||||
data class 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, which is already in the right place, and for
|
|
||||||
* one that started no turn. See the server's `Event::PeerMessage`.
|
|
||||||
*/
|
|
||||||
val turnStart: Long? = null,
|
|
||||||
) : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command the session was asked to run on itself and cannot run yet.
|
|
||||||
*
|
|
||||||
* Resolved by [CommandSent] with the same id. A command that ran straight away has only that
|
|
||||||
* one, so nothing here ever draws a bubble that resolves in the same frame.
|
|
||||||
*/
|
|
||||||
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()
|
|
||||||
|
|
||||||
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 and by different things. Asking for a
|
|
||||||
* change is not having one, so this -- not the request -- is what the pickers show.
|
|
||||||
*/
|
|
||||||
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a turn cost, and how much the model was holding when it ended.
|
|
||||||
*
|
|
||||||
* [context] is prompt plus both cache figures, measured by the backend from the turn's own
|
|
||||||
* usage. Carried on the event rather than summed by the reader, because it is not a sum: a
|
|
||||||
* conversation's context drops at a compaction and a clear, so adding turns up would report a
|
|
||||||
* figure the session stopped being true of. Null where the dialect did not say, and on entries
|
|
||||||
* recorded before the backend sent it -- which leaves the context unmeasured rather than
|
|
||||||
* unchanged.
|
|
||||||
*/
|
|
||||||
data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A compaction that finished, and how much context it recovered.
|
|
||||||
*
|
|
||||||
* The counts are nullable because the server sends them only when it was told them: a
|
|
||||||
* compaction whose size nobody measured has to be able to say so, since a zero here would read
|
|
||||||
* as "recovered nothing" and a made-up number would read as a measurement.
|
|
||||||
*/
|
|
||||||
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 it carries nothing: what it means is entirely its
|
|
||||||
* position in the transcript.
|
|
||||||
*/
|
|
||||||
data object Cleared : SessionEvent()
|
|
||||||
|
|
||||||
data class Error(val message: String) : SessionEvent()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An event type this app build doesn't know -- a newer server. Kept (not thrown) so one new
|
|
||||||
* event kind degrades to a placeholder row instead of killing the stream.
|
|
||||||
*/
|
|
||||||
data class Unknown(val type: String) : SessionEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A JSON array of strings under [name], empty when the field is absent.
|
|
||||||
*
|
|
||||||
* Absent is the ordinary case -- most messages carry no attachment, and the server omits the field
|
|
||||||
* rather than sending an empty list -- so this is the shape every caller wants.
|
|
||||||
*/
|
|
||||||
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"))
|
|
||||||
"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,
|
|
||||||
)
|
|
||||||
"commandQueued" ->
|
|
||||||
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
|
|
||||||
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
|
|
||||||
"status" -> SessionEvent.Status(body.getString("state"))
|
|
||||||
"settings" ->
|
|
||||||
SessionEvent.Settings(
|
|
||||||
model = body.optString("model").ifEmpty { null },
|
|
||||||
permissionMode = body.optString("permissionMode").ifEmpty { null },
|
|
||||||
)
|
|
||||||
"usageDelta" ->
|
|
||||||
SessionEvent.UsageDelta(
|
|
||||||
body.getLong("tokens"),
|
|
||||||
if (body.has("context")) body.getLong("context") 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
|
|
||||||
"error" -> SessionEvent.Error(body.getString("message"))
|
|
||||||
else -> SessionEvent.Unknown(type)
|
|
||||||
}
|
|
||||||
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The context after [event], given what it was before.
|
|
||||||
*
|
|
||||||
* The same rule the server folds with, because the screen has to keep up between page loads: the
|
|
||||||
* summary it opened with is a measurement from before this stream started, and every event that
|
|
||||||
* moves the figure arrives here.
|
|
||||||
*
|
|
||||||
* The two that lower it are the point. A clear takes the conversation away and a compaction
|
|
||||||
* replaces it with a summary, so a figure measured before either stopped being true at that moment
|
|
||||||
* -- and carrying it forward is how a session that had just been cleared went on reporting the
|
|
||||||
* context it no longer had.
|
|
||||||
*
|
|
||||||
* Null is "we don't know", which is a state each of them can reach: nothing measured yet, a
|
|
||||||
* compaction that finished without saying how much it recovered, or a clear nobody has run a turn
|
|
||||||
* since.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
|
|
||||||
*
|
|
||||||
* One predicate because two readers have to agree on the list: the session screen's working
|
|
||||||
* indicator, and the fold's decision that the newest reply is finished
|
|
||||||
* ([TranscriptItem.AssistantMsg.settled]). Two copies would drift the first time the server grows a
|
|
||||||
* state, and the drift would be a reply that never splits or one split mid-stream.
|
|
||||||
*/
|
|
||||||
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
|
|
||||||
|
|
||||||
fun contextAfter(current: Long?, event: SessionEvent): Long? =
|
|
||||||
when (event) {
|
|
||||||
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a
|
|
||||||
// turn -- which every context figure is -- rather than unknown.
|
|
||||||
is SessionEvent.UsageDelta -> event.context ?: current
|
|
||||||
is SessionEvent.Compacted -> event.postTokens
|
|
||||||
is SessionEvent.Cleared -> null
|
|
||||||
else -> current
|
|
||||||
}
|
|
||||||
@@ -1,160 +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. Guessing
|
|
||||||
* between those two is how a day gets spent rewriting the half that was already fast.
|
|
||||||
*
|
|
||||||
* The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds,
|
|
||||||
* broken down into the parts the UI thread is responsible for -- handling input, running
|
|
||||||
* animations, measuring and laying out, recording the draw -- and the parts after it.
|
|
||||||
*
|
|
||||||
* One of these for the app, like [DebugStats], because the two are read as one report and
|
|
||||||
* [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session
|
|
||||||
* and the counters were not, so a report copied after visiting two sessions divided every session's
|
|
||||||
* work by the newest one's frame count -- and printed the result as a per-frame measurement. It
|
|
||||||
* said 36.8 seconds of placement inside a 13.5 second window, and left "everything else" clamped at
|
|
||||||
* 0.00ms (0%), which reads as a screen whose whole cost is this app's own code.
|
|
||||||
*/
|
|
||||||
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: it is 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. See [FrameStats].
|
|
||||||
*
|
|
||||||
* 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,318 +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 {
|
|
||||||
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 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.KEYWORD -> keyword
|
|
||||||
Kind.STRING -> string
|
|
||||||
Kind.LITERAL -> literal
|
|
||||||
Kind.COMMENT -> comment
|
|
||||||
Kind.METADATA -> metadata
|
|
||||||
Kind.PUNCTUATION -> punctuation
|
|
||||||
Kind.MARK -> mark
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [code] with its keywords, strings and comments coloured, or plain if there is no language for it.
|
|
||||||
*
|
|
||||||
* Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the same
|
|
||||||
* code is the same colours wherever it appears.
|
|
||||||
*
|
|
||||||
* Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it
|
|
||||||
* off the drawing thread: the syntax palette is fixed, and a fence with no language is plain text
|
|
||||||
* which needs no colour of its own -- the style the caller draws it with carries that.
|
|
||||||
*
|
|
||||||
* 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") { scan(code, rulesOf(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. Punctuation and marks are coloured only in
|
|
||||||
* ordinary code, never inside a string or a comment.
|
|
||||||
*/
|
|
||||||
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,714 +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 it is its own state and the difference is the
|
|
||||||
* kind that matters: nothing has been done to this session yet, so a batch stopped here leaves it
|
|
||||||
* exactly as it was. Marked from the moment the batch is handed over all the same -- a queued row
|
|
||||||
* that still looked ordinary was still tappable, and tapping it would import it a second time
|
|
||||||
* behind the batch already coming for it.
|
|
||||||
*/
|
|
||||||
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, which is not something a second tap can
|
|
||||||
* undo.
|
|
||||||
*
|
|
||||||
* Swallowed silently rather than shown, because anything drawn on every row a batch passes would be
|
|
||||||
* a flicker running down the list. Half a second: long enough to cover a tap already travelling
|
|
||||||
* when the row moved, short enough that it is not in the way of a deliberate one.
|
|
||||||
*/
|
|
||||||
private const val SETTLE_MS = 500L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Continuing a Claude Code session the machine already has.
|
|
||||||
*
|
|
||||||
* The list is the machine's answer, not this app's: it asks a setup what sessions it holds and
|
|
||||||
* shows them. Choosing one sends its **id**, never a path, so an enrolled phone cannot turn this
|
|
||||||
* screen into a file reader.
|
|
||||||
*
|
|
||||||
* 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 setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
|
||||||
var chosen by remember { mutableStateOf<Setup?>(null) }
|
|
||||||
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
|
|
||||||
|
|
||||||
// What is happening to each row right now, as the word the row shows: "importing" or
|
|
||||||
// "deleting". A map keyed by id rather than a flag per row, because the rows are rebuilt from
|
|
||||||
// whatever the server last said and this belongs to the request rather than to the session --
|
|
||||||
// the same arrangement the session list uses for its deletes.
|
|
||||||
var running by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
|
||||||
// Which rows the reader has picked out. Empty means selection mode is off: there is no
|
|
||||||
// separate flag, because a selection mode with nothing selected is a state with no controls
|
|
||||||
// in it and no way to leave except Back.
|
|
||||||
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) }
|
|
||||||
// Same default as the spawn screen, and for the same reason: a phone
|
|
||||||
// is the wrong place to answer "allow Bash?" forty times.
|
|
||||||
var permissionMode by remember { mutableStateOf("auto") }
|
|
||||||
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from
|
|
||||||
// it, so a tap reading it needs no recomposition and there is no timer to cancel when a
|
|
||||||
// second removal lands on top of the first.
|
|
||||||
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 screen -- or another phone --
|
|
||||||
* started. Anything held locally would be a second version of that, and the stale one.
|
|
||||||
*/
|
|
||||||
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
|
|
||||||
try {
|
|
||||||
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, setup.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(setup: Setup) {
|
|
||||||
sessions = LoadState.Loading
|
|
||||||
selected = emptySet()
|
|
||||||
scope.launch { sessions = fetchInto(setup) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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) {
|
|
||||||
setups =
|
|
||||||
try {
|
|
||||||
val found = withContext(Dispatchers.IO) { fetchSetups(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. So there
|
|
||||||
* is nothing here to wait for and nothing to sequence: the rows are marked, the batch goes, and
|
|
||||||
* everything after that arrives as an event.
|
|
||||||
*
|
|
||||||
* Marked [WAITING] rather than with the operation's own word until the server confirms. Between
|
|
||||||
* the request leaving and the `started` event coming back, "we have asked" is the truth and "it
|
|
||||||
* is importing" is a guess -- and the row is inert either way, which is the part that matters.
|
|
||||||
*
|
|
||||||
* 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 setup = 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: the fourth of six could fail, or the screen could be
|
|
||||||
// left with two still unsent, and what came back was some rows running and some
|
|
||||||
// untouched -- indistinguishable, on the list, from rows nobody had picked. Now
|
|
||||||
// either the server has the batch or it has none of it, and this is the one place
|
|
||||||
// that can be true.
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { send(ids) }
|
|
||||||
} catch (err: Exception) {
|
|
||||||
// The server never took it, so nothing is running and no event will arrive to say
|
|
||||||
// so. This is the one failure the screen must report itself -- and it is now the
|
|
||||||
// whole batch's failure, which is the point: no row was singled out.
|
|
||||||
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 for the stream to open late, one row of a pair of deletes cleared
|
|
||||||
// and the other stayed on "waiting".
|
|
||||||
//
|
|
||||||
// The listing is the repair, because it carries the same state the events do. Only
|
|
||||||
// when something still looks outstanding, so the ordinary case -- where the events
|
|
||||||
// arrived and the rows are already gone -- does not pay for a second listing, which
|
|
||||||
// is the most expensive call this screen makes.
|
|
||||||
if (setup != null && targets.any { running.containsKey(it.id) }) {
|
|
||||||
// Quietly: no Loading, because blanking the list to report on rows that are
|
|
||||||
// already saying what is happening to them is the flicker this screen avoids
|
|
||||||
// everywhere else.
|
|
||||||
sessions = fetchInto(setup)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Imports [targets], and goes to the session it made when [thenOpen].
|
|
||||||
*
|
|
||||||
* One function for the tap and for the bar, differing in that one flag: continuing a session
|
|
||||||
* and then looking at it is what a tap on a row means, and a batch has several results and no
|
|
||||||
* reason to pick one of them to become the screen.
|
|
||||||
*/
|
|
||||||
/** Continues [targets] in the background, leaving the screen where it is. */
|
|
||||||
fun importAll(targets: List<Importable>) {
|
|
||||||
val setup = chosen ?: return
|
|
||||||
val useProvider = provider ?: return
|
|
||||||
handOver(targets) { ids ->
|
|
||||||
startImport(
|
|
||||||
settings,
|
|
||||||
setup = setup.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 setup = 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,
|
|
||||||
setup = setup.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 screen or another phone started.
|
|
||||||
//
|
|
||||||
// Failures here are deliberately quiet. There is nothing for a reader to do about a dropped
|
|
||||||
// event stream, and nothing is lost by one: every state it would have carried is in the next
|
|
||||||
// listing, which is what Refresh and re-entering the tab already fetch.
|
|
||||||
val liveChanges = remember {
|
|
||||||
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
|
|
||||||
}
|
|
||||||
LaunchedEffect(chosen?.id) {
|
|
||||||
val setup = chosen?.id ?: return@LaunchedEffect
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
val stream = ImportableStream(settings, setup)
|
|
||||||
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, and neither is
|
|
||||||
// something this list still has to offer.
|
|
||||||
"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, so nothing it can do is worth taking the app down for --
|
|
||||||
// and catching only the failure that was expected means an unexpected one
|
|
||||||
// reaches the top of the app and closes it, from a screen that is merely
|
|
||||||
// loading a list.
|
|
||||||
} 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, and nothing is nudged by a number that was
|
|
||||||
// right for one font size.
|
|
||||||
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 = setups) {
|
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
|
||||||
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
|
|
||||||
is LoadState.Loaded -> {
|
|
||||||
// Only worth choosing when there is a choice.
|
|
||||||
if (loaded.value.size > 1) {
|
|
||||||
Row(Modifier.fillMaxWidth()) {
|
|
||||||
loaded.value.forEach { setup ->
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
chosen = setup
|
|
||||||
loadSessions(setup)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
setup.name,
|
|
||||||
color =
|
|
||||||
if (setup.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 = PERMISSION_MODES,
|
|
||||||
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 setup = chosen ?: return@TextButton
|
|
||||||
confirming = null
|
|
||||||
handOver(targets) { ids -> deleteImportable(settings, setup.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 rather than on the one
|
|
||||||
* somebody was thinking of.
|
|
||||||
*/
|
|
||||||
@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 deliberately
|
|
||||||
// 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
|
|
||||||
// than one that offers the thing it can do.
|
|
||||||
// Two `--resume` processes on one transcript
|
|
||||||
// each replay the other's writes, which is why
|
|
||||||
// this must not simply try.
|
|
||||||
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 -- the reader should be able to see the
|
|
||||||
// ordering they are being given rather than infer 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, where the lines below
|
|
||||||
// it are short enough to wrap readably. Cut at the head,
|
|
||||||
// because a path is identified by its tail and these all
|
|
||||||
// share a long prefix. By the row's real width rather than a
|
|
||||||
// character count, which was one guess for every font size
|
|
||||||
// and screen.
|
|
||||||
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, the
|
|
||||||
// way every other failure in this app is shown.
|
|
||||||
errors[session.id]?.let { message ->
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A byte count at the coarsest unit that still says something, so rows stay comparable. */
|
|
||||||
private 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"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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. First
|
|
||||||
// of the measurements for that reason, and absent rather than zero when nothing has
|
|
||||||
// been measured -- a session with no turns yet has no figure, not a figure of none.
|
|
||||||
session.contextTokens?.let { "${it / 1000}k context" },
|
|
||||||
"${session.lines} lines",
|
|
||||||
// Kept beside the context figure because the two disagree usefully: most of a large
|
|
||||||
// transcript is history from before a compaction, which the model is no longer
|
|
||||||
// given, so a big file can be cheap to continue and a small one expensive.
|
|
||||||
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. The colour is what makes it findable; the words are what make it
|
|
||||||
* actionable.
|
|
||||||
*/
|
|
||||||
private fun warningOf(session: Importable): String? =
|
|
||||||
when (session.inUse) {
|
|
||||||
// What was measured is that a live process on that machine holds this session open. Which
|
|
||||||
// process is not measured, so it isn't claimed: "a terminal — close it there first" sent
|
|
||||||
// people looking for a window that need not exist. It is just as likely another agent, or
|
|
||||||
// this app on a session it spawned. Naming a place the reader then can't find turns a
|
|
||||||
// correct refusal into a wrong instruction.
|
|
||||||
"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 setup: String) {
|
|
||||||
private val stream = Sse(settings)
|
|
||||||
|
|
||||||
fun close() = stream.close()
|
|
||||||
|
|
||||||
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
|
|
||||||
stream.run("/setups/$setup/importable/events", onOpen) { _, data ->
|
|
||||||
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A language the highlighter has rules for.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
enum class Language {
|
|
||||||
C,
|
|
||||||
COFFEESCRIPT,
|
|
||||||
CPP,
|
|
||||||
CSHARP,
|
|
||||||
DART,
|
|
||||||
FISH,
|
|
||||||
GO,
|
|
||||||
JAVA,
|
|
||||||
JAVASCRIPT,
|
|
||||||
JSON,
|
|
||||||
KOTLIN,
|
|
||||||
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 and no string starts -- 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 rules for [language]. */
|
|
||||||
fun rulesOf(language: Language): Rules = RULES.getValue(language)
|
|
||||||
|
|
||||||
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
|
|
||||||
* (`SyntaxTokens.kt`, Apache-2.0), the library this scanner replaced, so that no fence which is
|
|
||||||
* coloured today turns plain. Entries that are not plain words were dropped -- Kotlin's `as?`,
|
|
||||||
* `!in` and `!is`, Swift's `#if` family, Ruby's `defined?`, CoffeeScript's `=` and `->` -- because
|
|
||||||
* the word scanner cannot reach them and the library only matched them by luck.
|
|
||||||
*/
|
|
||||||
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 rather than each needing its own.
|
|
||||||
*/
|
|
||||||
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,203 +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 then stay
|
|
||||||
* down until the app was launched again -- which is the case notifications exist to avoid.
|
|
||||||
*/
|
|
||||||
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` -- dark icons -- which was right against the
|
|
||||||
// default light surface and became unreadable the moment the app wore Catppuccin Mocha.
|
|
||||||
// Asking the colour means a future palette change cannot reintroduce that: whatever
|
|
||||||
// `background` becomes, the icons follow it.
|
|
||||||
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. See
|
|
||||||
// [AiAppSelectionColors].
|
|
||||||
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 -- every frame of the IME animation
|
|
||||||
// relays out and re-records this whole box.
|
|
||||||
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 -- measured above as most of
|
|
||||||
// the frame budget. Each screen takes the keyboard itself
|
|
||||||
// (AppRoot wraps the ordinary ones; the session screen moves
|
|
||||||
// only its composer and transcript), so the per-frame cost is
|
|
||||||
// scoped to what actually moves.
|
|
||||||
.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 rather than another
|
|
||||||
* intent to remember to handle.
|
|
||||||
*/
|
|
||||||
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,161 +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 four views of the backend behind it.
|
|
||||||
*
|
|
||||||
* These were four screens reached by four words in a row under the title, and the row was already
|
|
||||||
* full -- the comment it replaced recorded that a fifth would have to go somewhere else. Tabs say
|
|
||||||
* the same thing in less space and say one more thing besides: that these are places to be rather
|
|
||||||
* than errands to run. Sessions, the machine's importable history, the models on it and the
|
|
||||||
* machines themselves are all *the same backend*, looked at four ways, and none of them is a step
|
|
||||||
* down from another. Settings still is a step down, which is why it stays a pushed screen and keeps
|
|
||||||
* its own Back.
|
|
||||||
*/
|
|
||||||
private enum class MainTab(val label: String) {
|
|
||||||
Sessions("Sessions"),
|
|
||||||
Import("Import"),
|
|
||||||
Models("Models"),
|
|
||||||
Setups("Setups"),
|
|
||||||
}
|
|
||||||
|
|
||||||
@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,
|
|
||||||
) {
|
|
||||||
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, or that fetched before the network came up,
|
|
||||||
// came back to "Couldn't reach the server" sitting at the top of a list the server would now
|
|
||||||
// answer for perfectly well, and nothing took it off until somebody pressed Refresh. A stale
|
|
||||||
// failure is worse than a stale list: it is a claim about right now.
|
|
||||||
//
|
|
||||||
// Through the same token the Refresh button uses, so this is one instruction the tabs already
|
|
||||||
// understand rather than a second path into each of them -- which is also what makes it cover
|
|
||||||
// all four rather than the one the report came from.
|
|
||||||
//
|
|
||||||
// Not on the first entry: the tab composing already asks, and bumping here would make every
|
|
||||||
// cold start fetch twice.
|
|
||||||
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 -- that reads as a crash to somebody who only meant to
|
|
||||||
// get back to their sessions. Nested inside AppRoot's handler, so it wins while it is enabled.
|
|
||||||
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 -- everything below this row is one tab's business, and a control belongs
|
|
||||||
// with the thing it acts on.
|
|
||||||
// Flush against each other: a glyph button carries its own padding, so two of them
|
|
||||||
// side by side already have two rings between their marks and one ring plus this
|
|
||||||
// row's padding to the screen edge.
|
|
||||||
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 to the tab below, so they are summed rather than
|
|
||||||
// tracked apart -- either one moving moves the sum, which is all a tab watches.
|
|
||||||
val token = reloadToken + refreshToken
|
|
||||||
when (tab) {
|
|
||||||
MainTab.Sessions ->
|
|
||||||
SessionListScreen(
|
|
||||||
settings = settings,
|
|
||||||
reloadToken = token,
|
|
||||||
onOpen = onOpen,
|
|
||||||
onSpawn = onSpawn,
|
|
||||||
)
|
|
||||||
MainTab.Import ->
|
|
||||||
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
|
|
||||||
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
|
|
||||||
MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,716 +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: when drawing is invalidated, only the piece that
|
|
||||||
* changed is re-recorded instead of the whole reply, which is worth a great deal while every delta
|
|
||||||
* invalidates the message and a finished one can be twenty-five screens tall. It is worth nothing
|
|
||||||
* once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows were re-recorded 65
|
|
||||||
* times in fifty seconds of reading -- and it is not free: each layer is a layout node and a
|
|
||||||
* display list held for the life of the row, and live node count is what the per-frame cost of the
|
|
||||||
* transcript scales with.
|
|
||||||
*/
|
|
||||||
@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. See [MarkdownRoot].
|
|
||||||
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: no block gap between them, and neither the item above
|
|
||||||
* the seam nor the one below it takes the padding of a list's edge.
|
|
||||||
*/
|
|
||||||
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, every one of them blank, the whole conversation shrunk to fit a single screen; a moment
|
|
||||||
* later it was all there again. That is the "skipping up and down" this list must never do.
|
|
||||||
*
|
|
||||||
* Every parse after the first is off the composing thread, and the row keeps drawing the parse it
|
|
||||||
* already has until the new one lands, so there is never a frame without a height. What is on
|
|
||||||
* screen is always a real prefix of the reply rather than a guess at it; it is simply one parse
|
|
||||||
* behind.
|
|
||||||
*/
|
|
||||||
@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, since a paragraph ends at the blank
|
|
||||||
* line or the block that interrupts it, a fence at its closing fence, a list at the first line that
|
|
||||||
* is neither an item nor indented under one. So every block but the last is [frozen] with the parse
|
|
||||||
* that finished it, and only the tail -- the last block and whatever has arrived since -- is parsed
|
|
||||||
* again.
|
|
||||||
*
|
|
||||||
* A list is cut once more, at its last item, by the same reasoning one level down: an item is
|
|
||||||
* finished once the next item has begun, since a line can only continue the item it is indented
|
|
||||||
* under or start a new one. Without this a reply that is one long list -- forty sources -- parsed
|
|
||||||
* the whole list per delta, and a list streams as forty paragraphs would. The item the cut lands on
|
|
||||||
* has to have begun in earnest: a bare `-` is an empty item now and the first character of a
|
|
||||||
* paragraph line once `-x` arrives, and cutting on it would draw that line as a new item.
|
|
||||||
*
|
|
||||||
* What the cut gives up is one thing: a reference definition arriving later than a link that uses
|
|
||||||
* it, since the frozen block's parse never sees it. The link draws as its brackets until the reply
|
|
||||||
* settles and is parsed whole by [warm], which is the same moment every other transient of
|
|
||||||
* streaming is put right.
|
|
||||||
*/
|
|
||||||
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 block. Null when nothing before it is
|
|
||||||
* finished, so the tail stays whole.
|
|
||||||
*/
|
|
||||||
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.
|
|
||||||
* Everything below this is the mapping onto the app's palette and type scale.
|
|
||||||
*
|
|
||||||
* The locals are provided directly rather than through the renderer's `Markdown()` composable,
|
|
||||||
* which was the last of its composables on the hot path and was here only to provide them. What
|
|
||||||
* that buys is that nothing between a piece and the screen is the library's but the leaf
|
|
||||||
* composables named in the component table, so a different parser could stand behind [State]
|
|
||||||
* without the renderer's entry point being involved.
|
|
||||||
*
|
|
||||||
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules
|
|
||||||
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
|
|
||||||
*
|
|
||||||
* [streaming] says this parse is the part of a reply still being written, which only the fences
|
|
||||||
* care about: lexing is proportional to how much code there is, and a fence still arriving is
|
|
||||||
* re-lexed at every delta on the composing thread. Measured streaming a two-hundred-line Kotlin
|
|
||||||
* fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms -- for colours on
|
|
||||||
* text that was being replaced as fast as they were computed. So a fence still being written is
|
|
||||||
* drawn plain and takes its colours when the block freezes, which is the same bargain [LiveParse]
|
|
||||||
* already makes for a reference link defined at the foot of a message.
|
|
||||||
*/
|
|
||||||
@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 -- see [rawSurface],
|
|
||||||
// and the tool call above this reply, which now matches. `surfaceVariant` was
|
|
||||||
// exactly a card's own fill, so a fenced block inside a tool call had no
|
|
||||||
// background at all and one in a reply read as a step *up* out of the page.
|
|
||||||
codeBackground = rawSurface,
|
|
||||||
// The same colour. Not drawn by the renderer as a span background but by
|
|
||||||
// [LinkedText] behind the text, so a selection lands on top of it as it does on a
|
|
||||||
// fenced block -- see `appendCodeChip`.
|
|
||||||
inlineCodeBackground = rawSurface,
|
|
||||||
// The same tint a code block gets, rather than the renderer's 2%-alpha default:
|
|
||||||
// two adjacent tints that differ by a fiftieth read as one flat block on a phone,
|
|
||||||
// so the table would have had a border-less grid and nothing saying where it began.
|
|
||||||
tableBackground = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
LocalMarkdownTypography provides
|
|
||||||
markdownTypography(
|
|
||||||
// A ladder that starts near the body text and descends, because these are headings
|
|
||||||
// inside a chat message rather than the top of a document. The renderer's defaults
|
|
||||||
// are the Material *display* styles -- `#` came out at 57sp and `##` at 45sp, which
|
|
||||||
// is bigger than this app's own screen titles and reads as the reply shouting.
|
|
||||||
//
|
|
||||||
// Every step is a different size, so two levels of nesting never draw the same:
|
|
||||||
// one clear step per level is the whole job of a heading.
|
|
||||||
h1 = MaterialTheme.typography.headlineSmall,
|
|
||||||
h2 = MaterialTheme.typography.titleLarge,
|
|
||||||
h3 = MaterialTheme.typography.titleMedium,
|
|
||||||
h4 = MaterialTheme.typography.titleSmall,
|
|
||||||
h5 = MaterialTheme.typography.labelMedium,
|
|
||||||
h6 = MaterialTheme.typography.labelSmall,
|
|
||||||
// Body text at the size everything else in the transcript uses.
|
|
||||||
text = body,
|
|
||||||
paragraph = body,
|
|
||||||
ordered = body,
|
|
||||||
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, it
|
|
||||||
// is text that happens to be code, and painting all of it green said the whole
|
|
||||||
// block was one. Where a literal really does appear inside code, the thing that
|
|
||||||
// should colour it is a syntax highlighter looking at the code, which is exactly
|
|
||||||
// what a tool call's input already gets from `catppuccinSyntax`.
|
|
||||||
//
|
|
||||||
// The colour rides on the style here rather than in `markdownColor`, which
|
|
||||||
// stopped carrying `codeText`/`inlineCodeText`/`linkText` when the renderer moved
|
|
||||||
// them onto the typography.
|
|
||||||
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
|
|
||||||
// -- and the narrowest column is where the wrapping below has the least room.
|
|
||||||
tableCellPadding = 8.dp,
|
|
||||||
// What a column narrows to before the table starts scrolling sideways instead. It
|
|
||||||
// is the floor, not the width: a table with room to spare spreads across it.
|
|
||||||
//
|
|
||||||
// Down from the renderer's 160dp, and the number is a measurement rather than a
|
|
||||||
// taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp
|
|
||||||
// makes even a three-column table -- the commonest shape there is -- scroll, while
|
|
||||||
// 136dp fits three across the phone this app is read on. Four and up still scroll,
|
|
||||||
// which is the right answer for genuinely too many columns: squeezing six columns
|
|
||||||
// into a phone would give every cell one word per line.
|
|
||||||
//
|
|
||||||
// Narrower would fit more, and stop being readable. This is the widest minimum
|
|
||||||
// that keeps three columns on screen, which is the trade the number is making.
|
|
||||||
tableCellWidth = 136.dp,
|
|
||||||
),
|
|
||||||
LocalMarkdownComponents provides
|
|
||||||
markdownComponents(
|
|
||||||
// The m3 renderer's own default, restored: supplying `components` at all replaces
|
|
||||||
// the whole set, and this is the only member of it the Material layer overrides.
|
|
||||||
checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) },
|
|
||||||
// Everything that draws a run of text, so a link is a span rather than a node --
|
|
||||||
// see [LinkedText]. Setext headings take the same styles as `#` and `##`, which
|
|
||||||
// is the renderer's own pairing.
|
|
||||||
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 ([markdownDimens]'s `tableCellWidth`), so the table is at least
|
|
||||||
* columns-times-floor wide; narrower than the room it has, it spreads to fill it, and wider, it
|
|
||||||
* scrolls sideways rather than squeezing. The renderer decided that with a `BoxWithConstraints`,
|
|
||||||
* which is a subcomposition; here it is one layout modifier, and the trick is where it sits.
|
|
||||||
* `fillMaxWidth` fixes the minimum width to the room available, the horizontal scroll passes that
|
|
||||||
* minimum through to its content while lifting the maximum to unbounded, and the modifier after it
|
|
||||||
* reads the minimum back as the room and sizes the rows to the larger of that and the floor. The
|
|
||||||
* scroll then has exactly the overflow to scroll, which is none when the table fits.
|
|
||||||
*/
|
|
||||||
@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: anything past about twenty characters ends in "..." with
|
|
||||||
* no way to see the rest, and an elided cell looks like a short one, so a table of measurements
|
|
||||||
* reads as a table of plausible shorter measurements. And they draw a link in a cell as its own
|
|
||||||
* layout node, the cost [LinkedText] exists to avoid.
|
|
||||||
*
|
|
||||||
* So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell
|
|
||||||
* beside a one-line one centred the short one against the middle of the tall one and lost the line
|
|
||||||
* the reader was reading across. What the wrapping does *not* do is make a wide table fit;
|
|
||||||
* [LinkedTable] scrolls it instead, which is the right answer for too many columns -- wrapping a
|
|
||||||
* six-column table into the width of a phone would give every cell one word per line.
|
|
||||||
*
|
|
||||||
* The semantics are the renderer's: each cell is an item of the table's collection, and a header
|
|
||||||
* cell is a heading.
|
|
||||||
*/
|
|
||||||
@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 they
|
|
||||||
* appeared in. That is the lag when a block loads.
|
|
||||||
*
|
|
||||||
* Nothing here changes what a row does when it has no answer waiting: it parses inline, on the
|
|
||||||
* composing thread, because a row measured at nothing before it is measured at its real height
|
|
||||||
* collapses the transcript above it. The point is only that by the time the reader scrolls to a
|
|
||||||
* row, the answer is usually already made -- [warm] runs on a background thread as each page of
|
|
||||||
* history arrives, which is seconds before anybody reaches the rows it brought.
|
|
||||||
*
|
|
||||||
* A miss is not stored, and that is what bounds this: the map holds one entry per message a page
|
|
||||||
* warmed and nothing else, so a reply still streaming cannot fill it with hundreds of copies of
|
|
||||||
* itself on the way to being finished. It is dropped with the screen, and emptied by the stream
|
|
||||||
* reset that drops the rows it describes.
|
|
||||||
*/
|
|
||||||
@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 where a lookup is
|
|
||||||
* proportional to nothing.
|
|
||||||
*/
|
|
||||||
private val pieces = ConcurrentHashMap<String, List<Piece>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How each message divides into prose and memory notes, cached for the same reason as
|
|
||||||
* [piecesOf]: the regex scan behind [messageParts] is proportional to the message.
|
|
||||||
*/
|
|
||||||
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. [warm] fills this off the drawing thread before the row is reached.
|
|
||||||
*/
|
|
||||||
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, drawing the parse it already has, until the screen has warmed it and re-flattens. An
|
|
||||||
* explicit mark rather than a peek into the parse cache, because a message with memory notes is
|
|
||||||
* warmed as its *parts*: nothing ever parses its full text, and inferring readiness from the
|
|
||||||
* cache left exactly that message unsplittable forever, re-warmed on every fold.
|
|
||||||
*/
|
|
||||||
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, measured on a Pixel 9 Pro XL -- and on the default dispatcher that is
|
|
||||||
* every core busy, with the frame's own thread waiting for one. That showed up as 21ms of
|
|
||||||
* `waited` at the 90th percentile: the frame could not start, rather than taking too long.
|
|
||||||
*/
|
|
||||||
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 -- proportional to what was written, and charged to the frame
|
|
||||||
// that first draws it if nobody paid it earlier.
|
|
||||||
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,332 +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 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 of those
|
|
||||||
* annotations per link. Measured on the emulator against the same paragraphs with each link
|
|
||||||
* replaced by its label and address as plain words -- *more* text, the same gestures -- the linked
|
|
||||||
* version cost five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. On a
|
|
||||||
* Pixel 9 Pro XL that was the bump at the list of sources in a reply, and nowhere else in it.
|
|
||||||
*
|
|
||||||
* Here a link is the link colour and underline, a string annotation carrying its address, and one
|
|
||||||
* tap detector for the whole text that asks the layout which character was under the finger. What
|
|
||||||
* that gives up is a link being its own accessibility node with a pressed state; the app's link
|
|
||||||
* style never defined a pressed style, so nothing visible changes.
|
|
||||||
*
|
|
||||||
* Every block the renderer dispatches through its component table comes here, which includes the
|
|
||||||
* paragraphs inside lists, quotes and alerts, and so does every table cell through
|
|
||||||
* [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it
|
|
||||||
* resolves those against its definitions.
|
|
||||||
*
|
|
||||||
* An image is a link too, carrying its alt text. The app has no image loader and the renderer's
|
|
||||||
* transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the
|
|
||||||
* model put something, with no sign of what fell out. The link says what was there and where, and
|
|
||||||
* opens it. It also means no paragraph needs the renderer's own text composable, which existed to
|
|
||||||
* place inline images and charged every paragraph for the possibility.
|
|
||||||
*/
|
|
||||||
@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. Which is what this did
|
|
||||||
* for a week.
|
|
||||||
*/
|
|
||||||
@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 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 something somebody
|
|
||||||
// else's -- a scroll, or a press held past the long-press timeout, which is
|
|
||||||
// how a selection starts. The timeout is the load-bearing half: without it a
|
|
||||||
// press held for a second and released was still an up with nothing consumed,
|
|
||||||
// so holding a peer message to select from it shut the card instead.
|
|
||||||
val up =
|
|
||||||
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
|
|
||||||
waitForUpOrCancellation()
|
|
||||||
} ?: return@awaitEachGesture
|
|
||||||
val url = text.linkAt(layout.value, up.position)
|
|
||||||
when {
|
|
||||||
url != null -> {
|
|
||||||
up.consume()
|
|
||||||
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 out of its component table, so nothing between a card and
|
|
||||||
* the text inside it is ours to pass a parameter through; the renderer already hands its colours,
|
|
||||||
* its typography and its components down the same way.
|
|
||||||
*
|
|
||||||
* It exists because a pointer-input node over the glyphs takes the tap and the card's own click
|
|
||||||
* handler never sees it. Measured on the emulator against an opened peer message: with a handler on
|
|
||||||
* the text -- consuming or not -- a tap on its words did nothing at all, and with the handler
|
|
||||||
* removed entirely the same tap shut the card. So a card whose body is markdown cannot be shut by
|
|
||||||
* pressing its words unless the words do the shutting, and "nothing happens when I press it" is
|
|
||||||
* indistinguishable from a card that has stopped working.
|
|
||||||
*
|
|
||||||
* Provided as a value that outlives a recomposition (see [rememberMarkdownTap]), since a fresh
|
|
||||||
* lambda per composition would invalidate every paragraph reading it.
|
|
||||||
*/
|
|
||||||
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [onTap] as a stable value to provide for [LocalMarkdownTap].
|
|
||||||
*
|
|
||||||
* The identity stays put while the behaviour follows the latest [onTap], which is what keeps
|
|
||||||
* providing it from invalidating the text under it on every recomposition of the card.
|
|
||||||
*/
|
|
||||||
@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 of it except the ones in backticks. Anything drawn by a modifier
|
|
||||||
* on the text is under both, which is where a fenced block's box already is and why one of those
|
|
||||||
* always looked right. The [CODE_CHIP] annotation is what says where the fill goes.
|
|
||||||
*/
|
|
||||||
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 rather than the
|
|
||||||
* chip sticking a space out past the end of a selected line. It is also what leaves nothing behind
|
|
||||||
* when the only thing to reach a line is the space a chip is padded with.
|
|
||||||
*
|
|
||||||
* A run's extent is taken from the boxes of its first and last characters, which is exact while a
|
|
||||||
* line reads in one direction; mixed directions inside a code span would draw one box across the
|
|
||||||
* whole run rather than one per direction, and code spans are code.
|
|
||||||
*/
|
|
||||||
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,253 +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, so an item
|
|
||||||
* has to be bounded for the worst frame to be. Measured on a Pixel 9 Pro XL, the tallest row still
|
|
||||||
* being drawn was 36,982px, twenty-five screens in one message. A piece is a paragraph, a fence, a
|
|
||||||
* table, one bullet: bounded, so both costs are.
|
|
||||||
*
|
|
||||||
* Cut where the parser says the blocks are, which is the whole reason this is safe: a fence, a
|
|
||||||
* table and a nested list are each one node whatever is inside them, so nothing is ever split down
|
|
||||||
* the middle. A list is the one block that is not bounded -- a reply's list of sources can be forty
|
|
||||||
* items -- so it is cut once more, into its items, and a nested list stays inside the item that
|
|
||||||
* holds it.
|
|
||||||
*
|
|
||||||
* A piece is an *address* into the message's one parse ([block] indexes the root's children, [item]
|
|
||||||
* the list items of that child) rather than a substring of the message. Every piece of a message is
|
|
||||||
* drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and a
|
|
||||||
* reference definition at its foot still resolves the links above it -- the two costs of cutting a
|
|
||||||
* message into strings and parsing each on its own.
|
|
||||||
*/
|
|
||||||
@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 -- the parser keeps the
|
|
||||||
* newlines -- 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 is what carries the
|
|
||||||
* theme, the components and the reference links to the renderer's element composables.
|
|
||||||
*
|
|
||||||
* A whole block goes to the renderer's own dispatch with this app's component table, so a paragraph
|
|
||||||
* or heading is a [LinkedText], a table is [LinkedTableRow]s, and a nested list comes back here
|
|
||||||
* through [MarkdownList]. Only the list item is drawn directly, because a list item is the one
|
|
||||||
* piece the renderer has no element for.
|
|
||||||
*
|
|
||||||
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply (see
|
|
||||||
* `LiveParse`): an item that is the first or last of its own parse but not of the list the reader
|
|
||||||
* sees keeps an inner item's padding, so nothing moves when the seam between segments does.
|
|
||||||
*/
|
|
||||||
@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; they
|
|
||||||
* are drawn an item at a time as pieces.
|
|
||||||
*/
|
|
||||||
@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 the renderer's bullet and number, and a checkbox for a task item. It is drawn here
|
|
||||||
* rather than by a handler because it is the thing a reader might one day want styled -- a
|
|
||||||
* different glyph per depth, a colour -- and this is the one place it is drawn.
|
|
||||||
*/
|
|
||||||
@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, which is what makes them safe to rely on; a glyph the
|
|
||||||
* platform lacks draws as a box, and that check is the price of adding one here.
|
|
||||||
*/
|
|
||||||
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,169 +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, which is worth showing,
|
|
||||||
* because "I was told this before" and "I worked this out just now" are different things and the
|
|
||||||
* reader cannot otherwise tell them apart.
|
|
||||||
*
|
|
||||||
* A tag that has not finished arriving is left alone. Streaming means the closing tag may be
|
|
||||||
* seconds away, and a half-written marker is not a marker yet.
|
|
||||||
*/
|
|
||||||
@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 that nothing reports,
|
|
||||||
* and the row pays the parse in the frame it appears, which is the cost being removed.
|
|
||||||
*
|
|
||||||
* Public because [transcriptUnits] flattens settled replies into the same parts; go through
|
|
||||||
* [ParsedReplies.partsOf] on any path that runs per fold or per page, so the scan happens once per
|
|
||||||
* message.
|
|
||||||
*/
|
|
||||||
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, which reads as the answer having stopped and restarted -- and these
|
|
||||||
* arrive several to a message.
|
|
||||||
*
|
|
||||||
* What stays visible is which file it came from, because that is the whole of what the note claims
|
|
||||||
* and it is the part a reader scanning for "why does it think that" is looking for.
|
|
||||||
*
|
|
||||||
* Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to
|
|
||||||
* still be open on the way back, and a card that remembered for itself would forget the moment the
|
|
||||||
* list stopped composing it. The text is a good enough name -- it does not change once the closing
|
|
||||||
* tag has arrived, so a note stays open across the moment its reply settles.
|
|
||||||
*/
|
|
||||||
@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 in it 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,35 +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", so choosing it is a request
|
|
||||||
* the session can act on rather than a name this app made up.
|
|
||||||
*/
|
|
||||||
const val DEFAULT_MODEL = "default"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A model's name as a person reads it.
|
|
||||||
*
|
|
||||||
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to
|
|
||||||
* `claude-haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session
|
|
||||||
* using" and far too long for a button in a row that also has to hold Stop and Send.
|
|
||||||
*
|
|
||||||
* So the two ends that identify nothing are dropped and nothing else is: the vendor prefix, which
|
|
||||||
* is the same on every model this app can show, and the release date, which distinguishes builds of
|
|
||||||
* one model rather than one model from another. What is left is the part somebody chose --
|
|
||||||
* `haiku-4-5` -- and anything that does not look like that is returned untouched, since a name this
|
|
||||||
* does not recognise is a name it has no business editing.
|
|
||||||
*
|
|
||||||
* A display decision, not a correction: the full name is what the session reports and what a reader
|
|
||||||
* is shown when there is room for it.
|
|
||||||
*/
|
|
||||||
fun modelLabel(model: String?): String {
|
|
||||||
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
|
|
||||||
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}$""")
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Models on the backend, and HuggingFace to get more from.
|
|
||||||
*
|
|
||||||
* Everything here is the server's state rather than this screen's: what is downloaded, and what is
|
|
||||||
* downloading, are the same answers on every enrolled device, and a download started here keeps
|
|
||||||
* going when this screen closes.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var state by remember { mutableStateOf<LoadState<Models>>(LoadState.Loading) }
|
|
||||||
var query by remember { mutableStateOf("") }
|
|
||||||
var results by remember { mutableStateOf<LoadState<List<RemoteRepo>>?>(null) }
|
|
||||||
var openRepo by remember { mutableStateOf<String?>(null) }
|
|
||||||
var repoFiles by remember { mutableStateOf<LoadState<List<RemoteFile>>?>(null) }
|
|
||||||
var actionError by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
suspend fun reload() {
|
|
||||||
state =
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchModels(settings)) }
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Polled rather than pushed: a download belongs to the machine, not to
|
|
||||||
// any session, so it has no event stream of its own. Slow enough not
|
|
||||||
// to matter, frequent enough that a bar moves.
|
|
||||||
// Keyed on the token as well, so the header's Refresh restarts the loop with a read now
|
|
||||||
// rather than leaving the reader watching for up to a second and a half to see whether
|
|
||||||
// anything happened.
|
|
||||||
LaunchedEffect(reloadToken) {
|
|
||||||
while (true) {
|
|
||||||
reload()
|
|
||||||
delay(1500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
|
||||||
actionError?.let {
|
|
||||||
Text(it, color = MaterialTheme.colorScheme.error)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = query,
|
|
||||||
onValueChange = { query = it },
|
|
||||||
label = { Text("Search HuggingFace") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
TextButton(
|
|
||||||
enabled = query.isNotBlank(),
|
|
||||||
onClick = {
|
|
||||||
openRepo = null
|
|
||||||
results = LoadState.Loading
|
|
||||||
scope.launch {
|
|
||||||
results =
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
LoadState.Loaded(searchModels(settings, query))
|
|
||||||
}
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text("Search")
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
|
||||||
when (val current = state) {
|
|
||||||
is LoadState.Loading -> item { CircularProgressIndicator() }
|
|
||||||
is LoadState.Error ->
|
|
||||||
item { Text(current.message, color = MaterialTheme.colorScheme.error) }
|
|
||||||
is LoadState.Loaded -> {
|
|
||||||
if (current.value.downloads.isNotEmpty()) {
|
|
||||||
item { SectionLabel("Downloading") }
|
|
||||||
uniqueItems(current.value.downloads, key = { it.key + it.run }) { download
|
|
||||||
->
|
|
||||||
DownloadCard(download) {
|
|
||||||
scope.launch {
|
|
||||||
actionError =
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
cancelDownload(settings, download.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item { SectionLabel("On the backend") }
|
|
||||||
if (current.value.local.isEmpty()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"None yet. Search above to find one.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
uniqueItems(current.value.local, key = { it.key }) { model ->
|
|
||||||
LocalModelCard(model) {
|
|
||||||
scope.launch {
|
|
||||||
actionError =
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
deleteModel(settings, model.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results?.let { found ->
|
|
||||||
item { SectionLabel("HuggingFace") }
|
|
||||||
when (found) {
|
|
||||||
is LoadState.Loading -> item { CircularProgressIndicator() }
|
|
||||||
is LoadState.Error ->
|
|
||||||
item { Text(found.message, color = MaterialTheme.colorScheme.error) }
|
|
||||||
is LoadState.Loaded ->
|
|
||||||
uniqueItems(found.value, key = { it.id }) { repo ->
|
|
||||||
val open = openRepo == repo.id
|
|
||||||
RepoRow(repo, expanded = open) {
|
|
||||||
if (open) {
|
|
||||||
openRepo = null
|
|
||||||
} else {
|
|
||||||
openRepo = repo.id
|
|
||||||
repoFiles = LoadState.Loading
|
|
||||||
scope.launch {
|
|
||||||
repoFiles =
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
LoadState.Loaded(
|
|
||||||
fetchRepoFiles(settings, repo.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Inside the expanded repository's own item
|
|
||||||
// rather than as a section after the list:
|
|
||||||
// drawn after every card, a repository's files
|
|
||||||
// read as belonging to whichever card happened
|
|
||||||
// to be last.
|
|
||||||
if (open) {
|
|
||||||
when (val files = repoFiles) {
|
|
||||||
null -> {}
|
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
|
||||||
is LoadState.Error ->
|
|
||||||
Text(files.message, color = MaterialTheme.colorScheme.error)
|
|
||||||
is LoadState.Loaded ->
|
|
||||||
Column {
|
|
||||||
val busy =
|
|
||||||
(state as? LoadState.Loaded)
|
|
||||||
?.value
|
|
||||||
?.downloads
|
|
||||||
.orEmpty()
|
|
||||||
.filter { it.state == "running" }
|
|
||||||
.map { it.key }
|
|
||||||
.toSet()
|
|
||||||
files.value.forEach { file ->
|
|
||||||
RepoFileRow(
|
|
||||||
file,
|
|
||||||
downloading = "${repo.id}/${file.path}" in busy,
|
|
||||||
) {
|
|
||||||
scope.launch {
|
|
||||||
actionError =
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
startDownload(
|
|
||||||
settings,
|
|
||||||
repo.id,
|
|
||||||
file.path,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SectionLabel(text: String) {
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Text(text, style = MaterialTheme.typography.titleSmall)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DownloadCard(download: Download, onCancel: () -> Unit) {
|
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(download.file, style = MaterialTheme.typography.titleSmall)
|
|
||||||
Text(
|
|
||||||
download.repo,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
// A determinate bar only when the size is known. The server
|
|
||||||
// sends no total when it was never told one, and a bar drawn
|
|
||||||
// from a guess is worse than one that admits it is counting.
|
|
||||||
if (download.total != null && download.total > 0) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { download.done.toFloat() / download.total.toFloat() },
|
|
||||||
// Blue at every value, unlike a quota bar: a download nearing its end is
|
|
||||||
// nearing success, and colouring it like a limit being approached would say
|
|
||||||
// the opposite of what is happening.
|
|
||||||
color = progressColor,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
|
|
||||||
Text(
|
|
||||||
"${gigabytes(download.done)} so far, total size unknown",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
download.error?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row {
|
|
||||||
Text(
|
|
||||||
download.state,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (download.state == "running") {
|
|
||||||
TextButton(onClick = onCancel) { Text("Cancel") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LocalModelCard(model: LocalModel, onDelete: () -> Unit) {
|
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
|
||||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(model.file, style = MaterialTheme.typography.titleSmall)
|
|
||||||
Text(
|
|
||||||
"${model.repo} · ${gigabytes(model.bytes)}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TextButton(onClick = onDelete) { Text("Delete") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
|
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
|
||||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
repo.id,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
maxLines = 1,
|
|
||||||
// The owner is the part that repeats; the model name at
|
|
||||||
// the end is what tells two entries apart.
|
|
||||||
overflow = TextOverflow.StartEllipsis,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"${repo.downloads} downloads · ${repo.likes} likes",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(file.path, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
Text(
|
|
||||||
gigabytes(file.bytes),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Disabled rather than absent, so the row reads the same whether
|
|
||||||
// this one is absent, already here, or on its way. Offering
|
|
||||||
// "Download" for a file that is downloading would be a button that
|
|
||||||
// does nothing anyone can see -- the server joins the running
|
|
||||||
// download rather than starting a second.
|
|
||||||
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
|
|
||||||
Text(
|
|
||||||
when {
|
|
||||||
file.have -> "Downloaded"
|
|
||||||
downloading -> "Downloading"
|
|
||||||
else -> "Download"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun gigabytes(bytes: Long): String =
|
|
||||||
if (bytes >= 1_000_000_000) {
|
|
||||||
"%.2f GB".format(bytes / 1_000_000_000.0)
|
|
||||||
} else {
|
|
||||||
"%.0f MB".format(bytes / 1_000_000.0)
|
|
||||||
}
|
|
||||||
@@ -1,261 +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 and whoever gets the empty box instead is never
|
|
||||||
* the person who wrote it. That objection is about *relying* on a system font, and it is exactly
|
|
||||||
* right: the answer is not to avoid glyphs but to ship them. The font here is
|
|
||||||
* `app/build-icon-font.sh`'s output -- eleven glyphs, 2.1 KB, subset out of the 3 MB symbols font
|
|
||||||
* and committed -- so the codepoints below are resolved by an asset in the APK and cannot come back
|
|
||||||
* as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script
|
|
||||||
* did not subset is a glyph that silently isn't there.
|
|
||||||
*
|
|
||||||
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
|
|
||||||
* That is what makes two icons the same size without either of them being given a size: the
|
|
||||||
* proportional face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side
|
|
||||||
* by side came out visibly different widths, and matching them at the call site would have meant
|
|
||||||
* one hardcoded measurement per pair. [GLYPH_SIZE] carries the cost.
|
|
||||||
*
|
|
||||||
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
|
|
||||||
* Material Design codepoints. Those two must not drift: an icon that means "settings" in one app
|
|
||||||
* and something else in the other is the failure this is worth preventing. The script is copied
|
|
||||||
* rather than shared because most of what looks like duplication is the `GLYPHS` list, which has to
|
|
||||||
* differ -- the point of subsetting is to ship only the codepoints one app draws. All Material
|
|
||||||
* Design bar one, so they read as one family; the exception is noted where it is declared.
|
|
||||||
*/
|
|
||||||
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 -- the CLI is still there and still holds the conversation -- which
|
|
||||||
* is a pause, not a stop, and drawing it as a square said otherwise.
|
|
||||||
*/
|
|
||||||
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 the two buttons have to be told apart at a glance -- one glyph doing both jobs
|
|
||||||
* while looking identical would promise something immediate and do something that waits.
|
|
||||||
*/
|
|
||||||
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, which is
|
|
||||||
* what makes it read as a measurement rather than as a trend.
|
|
||||||
*/
|
|
||||||
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)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The size an icon draws at beside a line of text.
|
|
||||||
*
|
|
||||||
* 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at
|
|
||||||
* most 0.83 em of its point size and most filled a good deal less, so the number was standing in
|
|
||||||
* for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and
|
|
||||||
* keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked
|
|
||||||
* for. This is what the largest of them already drew at.
|
|
||||||
*/
|
|
||||||
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, and a mark cannot end up further from the button beside it than from the edge of the
|
|
||||||
* screen. That is what it was: the box was the size of the mark (28dp) and the separation was
|
|
||||||
* bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the
|
|
||||||
* edge, so a pair that acts on one screen read as two unrelated marks with one falling off it.
|
|
||||||
*
|
|
||||||
* 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has
|
|
||||||
* to find. It is what the pressed-state ripple draws, too: at 28dp that circle was inscribed in the
|
|
||||||
* mark's own corners, and beside a title it arrived at the first letter. And it is taller than any
|
|
||||||
* header's text, which is what lets the button fill a header row rather than sit in the middle of
|
|
||||||
* one -- the rows add no vertical padding of their own for the same reason they add no gap.
|
|
||||||
*/
|
|
||||||
private val GLYPH_BUTTON_SIZE = 48.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
|
|
||||||
* a back arrow.
|
|
||||||
*
|
|
||||||
* Two glyph buttons need nothing between them: each brings its own ring and the two add up, which
|
|
||||||
* is why a row of them sets no spacing. Text brings none, so the second ring has to be asked for.
|
|
||||||
* Without it the pressed-state circle, which fills the whole square, arrives at the first letter of
|
|
||||||
* the title -- and the gap a reader sees between the mark and that title is then half the one
|
|
||||||
* between the two marks at the other end of the same row.
|
|
||||||
*/
|
|
||||||
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 all assistive technology has to go on, and 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 on this app's headers already is -- so
|
|
||||||
* this is [GlyphButton] with the mark left to the caller rather than a second set of measurements
|
|
||||||
* beside it. The caller still owes it a [label]: nothing here draws a word.
|
|
||||||
*/
|
|
||||||
@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 -- a control that
|
|
||||||
* changed the width of its header while it worked would move its neighbours at the moment somebody
|
|
||||||
* was pressing them.
|
|
||||||
*/
|
|
||||||
@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, and every glyph in the Mono face fills that em.
|
|
||||||
// Left to the inherited body style the line box was 24sp tall around a 17sp-wide mark, so a
|
|
||||||
// glyph took a seventh more vertical space than horizontal wherever one is drawn without a box
|
|
||||||
// around it -- and where there is a box, that leading is what its padding is measured through.
|
|
||||||
Text(
|
|
||||||
glyph,
|
|
||||||
fontFamily = NerdIcons,
|
|
||||||
fontSize = size,
|
|
||||||
lineHeight = size,
|
|
||||||
color = colour,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,366 +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.
|
|
||||||
*
|
|
||||||
* The cost Android charges for it is a notification of its own that cannot be dismissed. That is
|
|
||||||
* made as quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no
|
|
||||||
* sound, shows no status-bar icon, and sits at the bottom of the shade -- the same arrangement
|
|
||||||
* Syncthing's "hide the persistent notification" option produces. It is not hidden outright,
|
|
||||||
* because it cannot be and because it should not be: it is the honest indicator that something is
|
|
||||||
* holding a connection open.
|
|
||||||
*/
|
|
||||||
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 then 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 -- a phone changes
|
|
||||||
* networks, the tunnel comes and goes, the backend restarts -- so it retries quietly and
|
|
||||||
* forever. Nothing is shown when it cannot connect: a notification saying "I could not tell you
|
|
||||||
* whether anything happened" on a phone in somebody's pocket is noise about a condition they
|
|
||||||
* cannot act on, and the session list already says what is waiting when they next look.
|
|
||||||
*/
|
|
||||||
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 for the same conversation is how a notification drawer
|
|
||||||
* becomes something to clear rather than read.
|
|
||||||
*/
|
|
||||||
private fun show(notification: SessionNotification) {
|
|
||||||
// Nothing to tell somebody about the session they are reading. The transcript in front of
|
|
||||||
// them is already saying it, and a sound over the top of it would be this app announcing
|
|
||||||
// what the screen is showing.
|
|
||||||
if (isOnScreen(notification.sessionId)) return
|
|
||||||
// The app is up: it says this itself, as a banner over whatever screen they are on. See
|
|
||||||
// [forTheScreen]. Never both -- one thing happened, and a drawer filling up behind an
|
|
||||||
// app that already showed you each one is a drawer nobody reads.
|
|
||||||
if (handOver(notification)) return
|
|
||||||
val manager = NotificationManagerCompat.from(this)
|
|
||||||
// Two different noes, and both are answers rather than faults: the runtime permission
|
|
||||||
// refused, and notifications switched off for the app in Android's own settings. Neither
|
|
||||||
// is reported anywhere -- the person said no, and saying it back to them through the
|
|
||||||
// channel they closed is not available anyway.
|
|
||||||
//
|
|
||||||
// The permission only exists from Android 13. Asking an older version about it gets
|
|
||||||
// "denied" for a name it does not know, which read as the person having said no -- so
|
|
||||||
// every notification on Android 12 and below was silently dropped. Before 13 the
|
|
||||||
// switch in Android's own settings, checked below, is the whole of the answer.
|
|
||||||
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)
|
|
||||||
.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 exactly
|
|
||||||
* the case lint's InlinedApi exists to catch. Zero is what ServiceCompat wants where types do
|
|
||||||
* not apply.
|
|
||||||
*/
|
|
||||||
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 and
|
|
||||||
* whatever sound and heads-up display the person has chosen for the app. The ongoing one is
|
|
||||||
* the platform's tax for staying connected, so it takes the lowest importance that exists.
|
|
||||||
* Both are created before the service starts, since posting to a channel that does not
|
|
||||||
* exist is silently dropped.
|
|
||||||
*/
|
|
||||||
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 a value
|
|
||||||
* could be passed through. [showing] and [stoppedShowing] are the pair, both called from
|
|
||||||
* the one composable that shows a session. Clearing names the session rather than setting
|
|
||||||
* null outright, because moving from one session to another composes the new screen before
|
|
||||||
* the old one's coroutine is cancelled -- an unconditional clear would then throw away the
|
|
||||||
* new screen's claim and start notifying about what is on it.
|
|
||||||
*/
|
|
||||||
@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, so there is nothing that
|
|
||||||
* could be left saying the app is up after it has gone. `tryEmit` neither suspends nor
|
|
||||||
* blocks the thread reading the stream, and the buffer is there so a handful of sessions
|
|
||||||
* finishing together all land rather than the last one winning.
|
|
||||||
*/
|
|
||||||
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. */
|
|
||||||
fun showing(context: Context, sessionId: String) {
|
|
||||||
onScreen = sessionId
|
|
||||||
// Whatever was posted about it before is about to be read, so it has nothing left
|
|
||||||
// to say -- and a row in the drawer for the conversation on screen is the same
|
|
||||||
// duplication this whole rule is about.
|
|
||||||
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
|
|
||||||
* when an intent arrives rather than two.
|
|
||||||
*
|
|
||||||
* 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 now shown in two
|
|
||||||
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift. The
|
|
||||||
* banner colours the line as well, which is its own decision and stays with the drawing.
|
|
||||||
*/
|
|
||||||
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,144 +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 -- which is exactly the question a peer message is usually the answer to.
|
|
||||||
*
|
|
||||||
* Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block,
|
|
||||||
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for the measurements that
|
|
||||||
* bought; what matters here is that the pieces have to add up to the card that was there before, so
|
|
||||||
* the fill, the corner radius and the padding all live in [peerSurface] rather than being written
|
|
||||||
* out at each piece.
|
|
||||||
*/
|
|
||||||
@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 ([CardDefaults] takes it from `FilledCardTokens`, which
|
|
||||||
* is `Level0`), so there is no shadow that a seam would show through -- which is the whole reason a
|
|
||||||
* card can be cut up at all. Each piece paints the caller's container colour the way a
|
|
||||||
* [androidx.compose .material3.Card] would and rounds only the corners at the ends of the message,
|
|
||||||
* so the pieces abut into one continuous card. Shared by the two rows that are cut this way -- an
|
|
||||||
* opened peer message and a long user message -- because two copies of the corner logic is how one
|
|
||||||
* of them grows a seam.
|
|
||||||
*
|
|
||||||
* The padding is the other half of it: 12dp all round was the card's own, so the top piece keeps
|
|
||||||
* the top of it, the bottom piece the bottom, and the middle pieces neither.
|
|
||||||
*/
|
|
||||||
@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,169 +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, and what
|
|
||||||
* these are attached to is the message being typed -- which is why they sit here rather than
|
|
||||||
* anywhere else on the screen.
|
|
||||||
*
|
|
||||||
* Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is
|
|
||||||
* in it, so four attachments look like four of the same thing rather than four smaller ones. A file
|
|
||||||
* is a tile of the same height carrying its name, since a name is all there is to show of it.
|
|
||||||
*/
|
|
||||||
@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 -- and
|
|
||||||
* the label is what says so, since nothing about the picture does.
|
|
||||||
*/
|
|
||||||
@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, and the only thing saying an image was
|
|
||||||
// attached was the cross drawn on top of nothing.
|
|
||||||
.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 that
|
|
||||||
// was 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, so making it the target
|
|
||||||
// would be a control drawn at a size nobody can hit.
|
|
||||||
//
|
|
||||||
// The disc is sized here and the mark centred inside it, rather than the glyph being
|
|
||||||
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box
|
|
||||||
// to the corner hung the visible mark over the edge and put its backing somewhere the
|
|
||||||
// eye reads as a second, misplaced square.
|
|
||||||
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,18 +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 the one product-specific fact -- 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,38 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
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.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 of them is adjusted.
|
|
||||||
*
|
|
||||||
* The colour is [rawSurface], which is also what a code block inside a reply is given; that is the
|
|
||||||
* point of having one name for it. Markdown's blocks are painted by the renderer rather than by
|
|
||||||
* this, since it draws its own, but they are the same colour on purpose.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
|
||||||
Column(
|
|
||||||
modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a
|
|
||||||
// rounded rectangle drawn at the same radius as the rounded rectangle behind it reads
|
|
||||||
// as a misprint rather than as nesting.
|
|
||||||
.clip(MaterialTheme.shapes.extraSmall)
|
|
||||||
.background(rawSurface)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,67 +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 and only the sentence around it differs, so everything here
|
|
||||||
// returns the span or the state on its own and leaves the wording to the caller.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words.
|
|
||||||
*
|
|
||||||
* Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s
|
|
||||||
* left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the
|
|
||||||
* figure on a minute it has already spent -- so the reader watching the number decide whether to
|
|
||||||
* start something was consistently told less headroom than they had. One rule, so the session bar
|
|
||||||
* and the usage dialog cannot round a shared measurement two different ways.
|
|
||||||
*/
|
|
||||||
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 -- measured against a live response on
|
|
||||||
* 2026-08-31, where the five-hour window's reset was exactly five hours after the moment work
|
|
||||||
* resumed. A timestamp that did arrive and could not be read is the genuinely unknown case, and it
|
|
||||||
* is the only one worth those words.
|
|
||||||
*
|
|
||||||
* Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on
|
|
||||||
* the one row somebody reads before starting something big -- and the usage dialog, looking at the
|
|
||||||
* same field, quietly drew nothing. Two rules for one missing value; this is the rule.
|
|
||||||
*/
|
|
||||||
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,57 +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 and a session that has said anything since has renumbered every position.
|
|
||||||
* The row key looks stable and is not: a tool row is named after its run, `joinPages` gives a run
|
|
||||||
* the name of its newest half, and the newest half is whatever the newest page happened to start
|
|
||||||
* with -- so an active session renames its tool runs every time it is reopened, and an anchor
|
|
||||||
* naming one is never found. A seq is the server's own numbering, assigned once and never moved.
|
|
||||||
*
|
|
||||||
* [unit] is which unit of the row the viewport started at -- see [TranscriptUnit.ordinal] -- and
|
|
||||||
* [offset] how far that unit was scrolled past the viewport's newest edge, in pixels. A seq alone
|
|
||||||
* is not a place: a reply is one seq and can be forty blocks long, and a reader stopped halfway
|
|
||||||
* down it is put back at that block, not at the reply.
|
|
||||||
*/
|
|
||||||
data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On this device rather than on the backend, which is where this app otherwise keeps state so every
|
|
||||||
* device sees it. Scroll position is the same exception a draft is: it is where the phone in
|
|
||||||
* somebody's hand is pointed, and having one device jump because another was scrolled would be a
|
|
||||||
* surprise rather than a convenience.
|
|
||||||
*/
|
|
||||||
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 and should open at the bottom, which is also the cheap
|
|
||||||
* case. A session *deleted* while it held an anchor leaves its key behind, for the reason and at
|
|
||||||
* the cost `Drafts.kt` describes.
|
|
||||||
*/
|
|
||||||
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,28 +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 and neither may be changed casually. The scheme is what routes a
|
|
||||||
* scanned QR here rather than to Dev Updater, and the key alias names the Android Keystore key the
|
|
||||||
* token is already sealed under on every enrolled phone -- changing it would leave those phones
|
|
||||||
* reading as not enrolled, with no error to explain why.
|
|
||||||
*/
|
|
||||||
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,186 +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 rather than through Android's drawer.
|
|
||||||
*
|
|
||||||
* Two places can carry the same fact and only one of them is right at a time. A row in the shade is
|
|
||||||
* for somebody looking at something else: it makes a sound, it waits however long it has to, and
|
|
||||||
* acting on it means leaving whatever they were doing. Somebody with this app open needs none of
|
|
||||||
* that -- they are already here, and what a tap on the notification would have done is what a tap
|
|
||||||
* on this does. So while these are on screen the stream is delivered here instead, which is
|
|
||||||
* arranged by the collection below and nothing else; see `NotificationService.forTheScreen`.
|
|
||||||
*
|
|
||||||
* A banner can go three ways, and each is somebody deciding something different: tapped, which
|
|
||||||
* opens the session; pushed off either side; or left alone, in which case it goes by itself when
|
|
||||||
* the bar across its foot runs out.
|
|
||||||
*/
|
|
||||||
@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, and for the same reason: a session that finished and then
|
|
||||||
// asked a question is one thing to know about, the question. It keeps its
|
|
||||||
// place in the queue rather than moving to the end, because the reader may
|
|
||||||
// already be reaching for it.
|
|
||||||
val already = queue.indexOfFirst {
|
|
||||||
it.notification.sessionId == notification.sessionId
|
|
||||||
}
|
|
||||||
if (already >= 0) queue[already] = alert else queue.add(alert)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Leaving the app hands the job back to the drawer, so nothing arriving while it
|
|
||||||
// is away is lost. What would be lost is the truth of what is already up: these
|
|
||||||
// say a session wants somebody *now*, and one still sitting here on a return
|
|
||||||
// several minutes later is a claim nobody checked. Frozen, too -- Compose stops
|
|
||||||
// the clock with the window, so the timer that was going to retire it has been
|
|
||||||
// standing still the whole time.
|
|
||||||
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. What is drawn
|
|
||||||
* is therefore the thing that decides, which is the only arrangement where a bar that has emptied
|
|
||||||
* cannot be sitting under a banner that is still there.
|
|
||||||
*/
|
|
||||||
@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 that happens to be in the way. The
|
|
||||||
// border is the one cue, and the elevation beside it is the platform's shadow rather
|
|
||||||
// than a second tint -- Material draws no tonal overlay over a container stated here.
|
|
||||||
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 -- the reason `progressColor` exists. Stated beside the track,
|
|
||||||
// which is the card's own colour so that the spent part reads as empty rather
|
|
||||||
// than as a second bar.
|
|
||||||
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, so
|
|
||||||
* this is a duration the reader can watch rather than one they have to learn.
|
|
||||||
*/
|
|
||||||
private const val ALERT_LIFE_MS = 6_000
|
|
||||||
@@ -1,276 +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.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.size
|
|
||||||
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.mutableFloatStateOf
|
|
||||||
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.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.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
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 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 while
|
|
||||||
* somebody was looking at it -- and in a bottom-anchored list, images loading above the viewport
|
|
||||||
* moved the text under the reader's eyes. Reserving the final height makes loading invisible, which
|
|
||||||
* is what it should be.
|
|
||||||
*
|
|
||||||
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
|
|
||||||
* than as a page of its own. Full size is one tap away -- but the full-size view itself is not
|
|
||||||
* here. [onOpen] hands the ref to the screen, which draws [SessionImageViewer] outside the list;
|
|
||||||
* see that function for the reason.
|
|
||||||
*/
|
|
||||||
@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. So one gets a
|
|
||||||
// spinner in the space the picture is about to fill, and the other gets words.
|
|
||||||
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. The same happens to a row regrouped by a
|
|
||||||
* page of history landing.
|
|
||||||
*
|
|
||||||
* Held by the screen, none of that reaches it: what is open is a property of the screen, not of
|
|
||||||
* whichever row happened to draw the thumbnail.
|
|
||||||
*
|
|
||||||
* The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go
|
|
||||||
* through. Paid deliberately rather than plumbed around: it is one request for a picture somebody
|
|
||||||
* asked to see, and the loading and unavailable states below are the same two the thumbnail draws.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SessionImageViewer(
|
|
||||||
settings: ServerSettings,
|
|
||||||
sessionId: String,
|
|
||||||
ref: String,
|
|
||||||
onClose: () -> Unit,
|
|
||||||
) {
|
|
||||||
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
|
||||||
Dialog(
|
|
||||||
onDismissRequest = onClose,
|
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier.fillMaxSize().background(Color.Black).clickable(onClick = onClose),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
when (val image = bitmap) {
|
|
||||||
// Two states, not one, exactly as the thumbnail has them: still coming, and never
|
|
||||||
// coming. Stated in white because this box paints its own black behind them and a
|
|
||||||
// theme colour would be picked against a surface that is not there.
|
|
||||||
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: this box paints its own black, and a theme colour would
|
|
||||||
// be chosen against a surface that is not there.
|
|
||||||
CircularProgressIndicator(color = Color.White)
|
|
||||||
}
|
|
||||||
else -> ZoomableImage(image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 of them turn out to be. Square is the closest
|
|
||||||
* thing to "the size of it" that can be drawn before knowing.
|
|
||||||
*
|
|
||||||
* Tinted, so the reader can see that something is being kept for a picture. That is also what
|
|
||||||
* distinguishes it from the failure beside it, which is words on the ordinary surface.
|
|
||||||
*/
|
|
||||||
@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 exactly when a hardcoded
|
|
||||||
* height would be 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, so this is a decision
|
|
||||||
* per image rather than a preference set once.
|
|
||||||
*/
|
|
||||||
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, the whole image
|
|
||||||
* visible, which is the thing a reader wants first; zoom is theirs from there.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ZoomableImage(image: ImageBitmap) {
|
|
||||||
var scale by remember { mutableFloatStateOf(1f) }
|
|
||||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
|
||||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
|
||||||
Image(
|
|
||||||
bitmap = image,
|
|
||||||
contentDescription = "Attached image",
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
// Zoomed in, the reader is looking at pixels on purpose.
|
|
||||||
filterQuality = FilterQuality.None,
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxSize()
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTransformGestures { _, pan, zoom, _ ->
|
|
||||||
// Floor of 1 so the image cannot be pinched smaller than fitted, which is
|
|
||||||
// already the whole of it; a ceiling so it cannot be lost off-screen.
|
|
||||||
scale = (scale * zoom).coerceIn(1f, 8f)
|
|
||||||
if (scale > 1f) {
|
|
||||||
offsetX += pan.x
|
|
||||||
offsetY += pan.y
|
|
||||||
} else {
|
|
||||||
offsetX = 0f
|
|
||||||
offsetY = 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = scale
|
|
||||||
scaleY = scale
|
|
||||||
translationX = offsetX
|
|
||||||
translationY = offsetY
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
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.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.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.FloatingActionButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
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.unit.dp
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox.
|
|
||||||
*
|
|
||||||
* 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,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
|
||||||
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
|
|
||||||
|
|
||||||
// Failures that belong to one session rather than to the list, keyed by
|
|
||||||
// its id and shown on its own card. The two scopes are decided by
|
|
||||||
// whether the server answered: it answered and refused, so this says
|
|
||||||
// nothing about the other rows, where a server that has stopped
|
|
||||||
// answering leaves every row stale and is `listState`'s to report.
|
|
||||||
//
|
|
||||||
// Cleared on the next successful load below -- an entry outlives its
|
|
||||||
// session otherwise, and would reappear against whatever the phone
|
|
||||||
// fetched next.
|
|
||||||
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
|
||||||
|
|
||||||
// Which sessions have a delete in flight. A set of ids rather than a flag on the row,
|
|
||||||
// because the rows are rebuilt from whatever the server last said and this belongs to the
|
|
||||||
// request rather than to the session.
|
|
||||||
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
|
|
||||||
|
|
||||||
fun refresh() {
|
|
||||||
listState = LoadState.Loading
|
|
||||||
scope.launch {
|
|
||||||
listState =
|
|
||||||
try {
|
|
||||||
val loaded =
|
|
||||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
|
|
||||||
deleteErrors = emptyMap()
|
|
||||||
loaded
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(reloadToken) { refresh() }
|
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
|
||||||
when (val state = listState) {
|
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
|
||||||
// The message as Api.kt wrote it, with nothing added: it is
|
|
||||||
// already a whole sentence naming the address and what to
|
|
||||||
// check, so a prefix here read "Couldn't reach the server:
|
|
||||||
// Couldn't reach the server at ...". It was also a guess --
|
|
||||||
// a delete that the server itself refused had reached it
|
|
||||||
// fine.
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Awaiting-answer first (the point of the screen), then
|
|
||||||
// most recently active.
|
|
||||||
val ordered =
|
|
||||||
state.value.sortedWith(
|
|
||||||
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
|
|
||||||
.thenByDescending { it.lastActivity }
|
|
||||||
)
|
|
||||||
LazyColumn {
|
|
||||||
uniqueItems(ordered, key = { it.id }) { session ->
|
|
||||||
SessionCard(
|
|
||||||
session = session,
|
|
||||||
error = deleteErrors[session.id],
|
|
||||||
deleting = session.id in deleting,
|
|
||||||
onOpen = { onOpen(session) },
|
|
||||||
onLongPress = { confirmingDelete = session },
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FloatingActionButton(
|
|
||||||
onClick = onSpawn,
|
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
|
|
||||||
) {
|
|
||||||
Text("+", style = MaterialTheme.typography.headlineMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmingDelete?.let { session ->
|
|
||||||
// Reset per session, so a toggle turned on for one conversation is not still on for the
|
|
||||||
// next one somebody opens this dialog for. Off to begin with: see [deleteSession].
|
|
||||||
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { confirmingDelete = null },
|
|
||||||
title = { Text("Delete \"${session.title}\"?") },
|
|
||||||
text = {
|
|
||||||
// Two different acts behind one button, so it says which one this is. What
|
|
||||||
// separates them is whether the *driver* keeps its own record of the
|
|
||||||
// conversation -- the Claude Code CLI does, under ~/.claude/projects, whether
|
|
||||||
// this app spawned the session or imported it; echo and llama.cpp do not, and
|
|
||||||
// for those the app's transcript is the only copy there is.
|
|
||||||
//
|
|
||||||
// This used to branch on `imported`, above a comment asserting that "a session
|
|
||||||
// started here has no copy anywhere". That was simply false for every
|
|
||||||
// claude-cli session this app spawned, and the two warnings disagreed about
|
|
||||||
// sessions that were equally recoverable. Getting it wrong in that direction
|
|
||||||
// is the expensive one: "this can't be undone", said of something that can,
|
|
||||||
// spends the credibility the sentence needs on the sessions where it is true.
|
|
||||||
//
|
|
||||||
// Neither branch promises a restore. The recoverable one says what is known --
|
|
||||||
// the driver keeps its own record -- rather than that the file is still there,
|
|
||||||
// which nothing here checked; and it names what goes either way, because this
|
|
||||||
// app's transcript holds images, peer messages and commands that the CLI's own
|
|
||||||
// record never had.
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
when {
|
|
||||||
!session.keepsOwnTranscript ->
|
|
||||||
"Kills the process and deletes the conversation. Nothing else " +
|
|
||||||
"keeps a copy, so this can't be undone."
|
|
||||||
// The sentence below is the one the toggle makes false, which is why
|
|
||||||
// it is written twice rather than appended to: leaving "should still
|
|
||||||
// be there to import again" on screen beside a switch that removes it
|
|
||||||
// is the reassurance being read at the moment it stops being true.
|
|
||||||
alsoDeleteForeign ->
|
|
||||||
"Kills the process and deletes both copies of the conversation: " +
|
|
||||||
"this app's, and Claude Code's own transcript on the " +
|
|
||||||
"machine. Nothing keeps another, so this can't be undone."
|
|
||||||
else ->
|
|
||||||
"Stops the process and deletes this app's copy of the " +
|
|
||||||
"conversation, including any images, peer messages and " +
|
|
||||||
"commands recorded only here. Claude Code keeps its own " +
|
|
||||||
"transcript on the machine, so the conversation itself " +
|
|
||||||
"should still be there to import again."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
// Only where there is a second copy to decide about. Absent rather than
|
|
||||||
// disabled, because this is not a capability being withheld: for echo and
|
|
||||||
// llama.cpp there is no other transcript, and a switch offering to delete
|
|
||||||
// one would be asking about something that does not exist.
|
|
||||||
if (session.keepsOwnTranscript) {
|
|
||||||
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 Claude Code'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 = null
|
|
||||||
// Marked here rather than after the request returns: the row has to say
|
|
||||||
// something is happening to it from the moment it is asked for, which
|
|
||||||
// is the whole of what this state is for.
|
|
||||||
deleting = deleting + session.id
|
|
||||||
deleteErrors = deleteErrors - session.id
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
deleteSession(settings, session.id, alsoDeleteForeign)
|
|
||||||
}
|
|
||||||
// Only this row, and only what changed. Refetching the list
|
|
||||||
// instead put every other session back through loading and
|
|
||||||
// handed the reader an empty screen -- to report on something
|
|
||||||
// that was never in doubt.
|
|
||||||
val loaded = listState
|
|
||||||
if (loaded is LoadState.Loaded) {
|
|
||||||
listState =
|
|
||||||
LoadState.Loaded(
|
|
||||||
loaded.value.filterNot { it.id == session.id }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// Kept, because it is still there: the server refused, so the
|
|
||||||
// session it refused about is exactly as it was.
|
|
||||||
deleteErrors =
|
|
||||||
deleteErrors + (session.id to (e.message ?: "Delete failed"))
|
|
||||||
} finally {
|
|
||||||
deleting = deleting - session.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
// Coloured by consequence: this takes something away, and does so wherever
|
|
||||||
// it appears -- the same rule the import screen's Delete follows.
|
|
||||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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, and putting it back when the server refuses
|
|
||||||
* is worse than never having taken it away.
|
|
||||||
*/
|
|
||||||
deleting: Boolean,
|
|
||||||
onOpen: () -> Unit,
|
|
||||||
onLongPress: () -> Unit,
|
|
||||||
) {
|
|
||||||
BusyItem(label = if (deleting) "deleting" else null) {
|
|
||||||
Card(
|
|
||||||
// Off while the delete is in flight: a card that still opens a session it is
|
|
||||||
// deleting is a race the reader can start by tapping. On the card rather than in
|
|
||||||
// [BusyItem], which leaves gestures alone so the list still scrolls.
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.combinedClickable(
|
|
||||||
enabled = !deleting,
|
|
||||||
onClick = onOpen,
|
|
||||||
onLongClick = onLongPress,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(16.dp)) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
session.title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
StatusText(session.status)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
// Machine, then what runs on it, then what it is set to: the same order
|
|
||||||
// and separator as the session screen's header and the usage dialog, so
|
|
||||||
// one pair of facts is not written three ways.
|
|
||||||
listOfNotNull(
|
|
||||||
session.setupName,
|
|
||||||
session.provider,
|
|
||||||
session.model?.let { modelLabel(it) },
|
|
||||||
)
|
|
||||||
.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 in this app is shown.
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StatusText(status: String) {
|
|
||||||
val (label, color) =
|
|
||||||
when (status) {
|
|
||||||
"awaitingInput" -> "your turn" to awaitingColor
|
|
||||||
"running" -> "running" to runningColor
|
|
||||||
"compacting" -> "compacting" to commandColor
|
|
||||||
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
// Said in words, because it differs in kind from the others rather than in degree:
|
|
||||||
// the session is not idle and has not exited, nobody has been able to find out
|
|
||||||
// which. A muted colour alone would read as one of the quiet states.
|
|
||||||
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
}
|
|
||||||
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,272 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What can be changed about one session, as opposed to about this app.
|
|
||||||
*
|
|
||||||
* Over the session rather than a step down from it: everything here is about the conversation
|
|
||||||
* behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a
|
|
||||||
* screen of its own until 2026-08-30, which put a page transition and a back stack around two
|
|
||||||
* controls and hid the thing they act on.
|
|
||||||
*
|
|
||||||
* The model and the permission mode are deliberately still on the session's own bar, because those
|
|
||||||
* are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs
|
|
||||||
* with the thing it acts on.
|
|
||||||
*
|
|
||||||
* Nothing here is captioned. Each control is a labelled noun with a switch or a field beside it,
|
|
||||||
* and a paragraph under every one of them made the dialog longer than the conversation it covers.
|
|
||||||
* Failures still get their words: those are what the reader cannot work out by looking.
|
|
||||||
*/
|
|
||||||
@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,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var name by remember(sessionId) { mutableStateOf(title) }
|
|
||||||
var saving by remember { mutableStateOf(false) }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
|
|
||||||
// whenever the list was last fetched, so drawing the switch straight from it would show a
|
|
||||||
// position that may have been changed since -- from here or from another device -- with
|
|
||||||
// nothing to say so. Until the answer arrives the switch is disabled and a spinner sits beside
|
|
||||||
// it, which is what not knowing looks like: distinguishable from off, and from a refusal.
|
|
||||||
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
|
||||||
var notifyError by remember { mutableStateOf<String?>(null) }
|
|
||||||
// Where the session works. Null until the server has been asked, for the same reason the
|
|
||||||
// switch above is: the row this dialog opened over is a snapshot, and a path drawn from it
|
|
||||||
// could be one somebody changed from another device. An empty answer is a session that was
|
|
||||||
// never given a directory, which is not the same as one whose directory is unknown -- the
|
|
||||||
// field is only enabled once one of those two is settled.
|
|
||||||
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
|
|
||||||
var typedCwd by remember(sessionId) { mutableStateOf("") }
|
|
||||||
var cwdError by remember { mutableStateOf<String?>(null) }
|
|
||||||
var movingCwd by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(sessionId) {
|
|
||||||
try {
|
|
||||||
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
|
||||||
notify = fresh.notify
|
|
||||||
cwd = fresh.cwd.orEmpty()
|
|
||||||
typedCwd = fresh.cwd.orEmpty()
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// Left unknown rather than falling back to the stale row: the switch stays
|
|
||||||
// disabled, instead of offering a position nothing confirmed.
|
|
||||||
notifyError = e.message
|
|
||||||
notify = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves the session, which ends the process that is in the old directory.
|
|
||||||
*
|
|
||||||
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
|
|
||||||
* process, and a stopped session is a state this app already has a word and a button for.
|
|
||||||
*/
|
|
||||||
fun moveCwd() {
|
|
||||||
val chosen = typedCwd.trim()
|
|
||||||
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
|
|
||||||
movingCwd = true
|
|
||||||
cwdError = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
|
|
||||||
cwd = chosen
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// Where it happened: this field is the only thing on screen that knows a move was
|
|
||||||
// asked for, and the reason is usually the path itself.
|
|
||||||
cwdError = e.message
|
|
||||||
} finally {
|
|
||||||
movingCwd = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Moved optimistically so the switch answers the finger that moved it, and put back if the
|
|
||||||
// request is refused -- a switch that waits for a round trip reads as broken on a slow
|
|
||||||
// tunnel, and one that stays moved after a refusal lies.
|
|
||||||
fun setNotify(wanted: Boolean) {
|
|
||||||
val was = notify
|
|
||||||
notify = wanted
|
|
||||||
notifyError = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) }
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
notify = was
|
|
||||||
notifyError = e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing to do when the name has not changed, so the button says so rather than sending a
|
|
||||||
// request whose success would look exactly like the failure of having typed nothing.
|
|
||||||
val changed = name.trim().isNotEmpty() && name.trim() != title
|
|
||||||
|
|
||||||
fun save() {
|
|
||||||
if (!changed || saving) return
|
|
||||||
val chosen = name.trim()
|
|
||||||
saving = true
|
|
||||||
error = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
|
|
||||||
onRenamed(chosen)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
// Reported here, where it happened, because this dialog is the only place that
|
|
||||||
// knows a rename was attempted -- the session behind it shows nothing about it.
|
|
||||||
error = e.message
|
|
||||||
saving = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Session settings") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
label = { Text("Name") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = !saving,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
// The keyboard's own action does what the button does: a one-field form
|
|
||||||
// where the return key does nothing is a form people press return at anyway.
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
keyboardActions = KeyboardActions(onDone = { save() }),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Notifications", modifier = Modifier.weight(1f))
|
|
||||||
if (notify == null && notifyError == null) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.width(16.dp).height(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = notify == true,
|
|
||||||
onCheckedChange = { setNotify(it) },
|
|
||||||
enabled = notify != null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Beside the switch that failed, not with the rename's error: they are two
|
|
||||||
// requests and a reader has to be able to tell which one the server refused.
|
|
||||||
notifyError?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = typedCwd,
|
|
||||||
onValueChange = { typedCwd = it },
|
|
||||||
label = { Text("Working directory") },
|
|
||||||
// What the field cannot say by being empty: a session that was never
|
|
||||||
// given one starts wherever its launcher does, and this names that
|
|
||||||
// rather than showing a path nobody chose.
|
|
||||||
placeholder = { Text("wherever the session was started") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = cwd != null && !movingCwd,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
|
|
||||||
)
|
|
||||||
TextButton(
|
|
||||||
onClick = { moveCwd() },
|
|
||||||
enabled =
|
|
||||||
cwd != null &&
|
|
||||||
!movingCwd &&
|
|
||||||
typedCwd.trim().isNotEmpty() &&
|
|
||||||
typedCwd.trim() != cwd,
|
|
||||||
) {
|
|
||||||
Text(if (movingCwd) "Moving..." else "Move")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The whole of what pressing Move does, where it is about to be pressed. A
|
|
||||||
// directory is settled when the process is spawned, so there is no changing one
|
|
||||||
// under a running session -- it is ended, and the next thing said to the session
|
|
||||||
// starts it in the new place.
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
error?.let {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// 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") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,260 +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.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 whole point: a session on `echo` or
|
|
||||||
* on a local llama.cpp has no paid quota at all, which is a fact about how it was set up and
|
|
||||||
* not a failure to find something out. The backend never asks such a machine, so it returns no
|
|
||||||
* snapshot for it -- and reading that silence as "couldn't find out" is exactly the mistake of
|
|
||||||
* answering with the nearest available word. Drawn as nothing, because there is nothing.
|
|
||||||
*/
|
|
||||||
data object NotMetered : SessionUsage()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The question could not be answered, and why.
|
|
||||||
*
|
|
||||||
* Its own state because "we couldn't find out" and "none of it is used" are the pair that must
|
|
||||||
* never share an appearance: a bar sitting at zero because a machine is unreachable reads as
|
|
||||||
* plenty of headroom, which is the opposite of the truth.
|
|
||||||
*/
|
|
||||||
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 of those used to fetch for
|
|
||||||
* itself. Two fetches say one thing twice and then disagree about it: the bar's copy can be a whole
|
|
||||||
* refresh interval old when the dialog opens with a fresh one, so the header read 42% while the
|
|
||||||
* screen over it read 47%, about a number somebody is deciding on. One feed per screen, and
|
|
||||||
* [refresh] moves both.
|
|
||||||
*/
|
|
||||||
class UsageFeed(
|
|
||||||
val snapshots: LoadState<List<UsageSnapshot>>,
|
|
||||||
/**
|
|
||||||
* A fetch is outstanding. Only ever true over an answer already shown; see [rememberUsageFeed].
|
|
||||||
*/
|
|
||||||
val refreshing: Boolean,
|
|
||||||
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
|
||||||
val refresh: () -> Unit,
|
|
||||||
) {
|
|
||||||
/** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */
|
|
||||||
fun forSetup(setup: String): SessionUsage =
|
|
||||||
when (val state = snapshots) {
|
|
||||||
is LoadState.Loading -> SessionUsage.Waiting
|
|
||||||
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
|
||||||
is LoadState.Loaded -> usageFor(state.value, setup)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 to the next one 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 came back rather than the three Claude sends today -- the backend
|
|
||||||
* deliberately passes windows it does not recognise straight through, so a fourth one is a thing
|
|
||||||
* that happens rather than a thing to notice later.
|
|
||||||
*
|
|
||||||
* Every state that is not a measurement takes the ordinary control colour instead. That is the
|
|
||||||
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
|
|
||||||
* unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind
|
|
||||||
* the button is where those say, in words, which one they are.
|
|
||||||
*/
|
|
||||||
@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 five-hour window for the machine this session runs on, 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, fetched from the machine that holds the
|
|
||||||
* account. It is never derived from what this app has watched go past: the transcript's token
|
|
||||||
* counts are a different quantity, measured differently, and a bar shaped like a quota gauge built
|
|
||||||
* out of them would be a guess wearing a measurement's clothes.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|
||||||
DebugStats.count("usage bar recomposed")
|
|
||||||
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
|
|
||||||
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
|
|
||||||
// value, Compose skips the recomposition, and a "left" that only ticked when the quota
|
|
||||||
// happened to move would sit at a stale figure for hours.
|
|
||||||
var now by remember { mutableStateOf(OffsetDateTime.now()) }
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
while (true) {
|
|
||||||
delay(REFRESH_MS)
|
|
||||||
now = OffsetDateTime.now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would
|
|
||||||
// report a problem about a setup somebody chose, on every screen, forever.
|
|
||||||
if (usage is SessionUsage.NotMetered) {
|
|
||||||
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) {
|
|
||||||
SessionUsage.NotMetered -> Unit
|
|
||||||
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
|
|
||||||
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
|
|
||||||
is SessionUsage.Known -> {
|
|
||||||
val window = state.windows.firstOrNull { it.kind == "session" }
|
|
||||||
if (window == null) {
|
|
||||||
UsageNote("5-hour usage unknown -- no five-hour window reported")
|
|
||||||
} else {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
|
||||||
// The same step at the same percentages as the dialog's bars: this is the
|
|
||||||
// same measurement, and a reader who learned the colour there has to be
|
|
||||||
// able to read it here without checking which screen they are on.
|
|
||||||
color = quotaColor(window.percent),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
fiveHourLabel(window, now),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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": how much is gone, then how long what is left has to last.
|
|
||||||
*
|
|
||||||
* The percentage on its own does not answer the question it gets asked, which is whether to start
|
|
||||||
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
|
|
||||||
*
|
|
||||||
* The window's end has two missing cases and they are worded differently on purpose; see
|
|
||||||
* [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is
|
|
||||||
* no countdown to report and inventing one would be the same fault as inventing the number.
|
|
||||||
*/
|
|
||||||
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
|
||||||
val percent = "${window.percent.toInt()}%"
|
|
||||||
return when (val end = windowEnd(window.resetsAt, now)) {
|
|
||||||
// Between blocks the five-hour window has no reset time, and saying so is a fact about
|
|
||||||
// nothing: there is no window to run out. The percentage is the whole answer.
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One machine's snapshot, out of every machine's.
|
|
||||||
*
|
|
||||||
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it:
|
|
||||||
* a machine nobody logged into, one that could not be reached, a snapshot that came back empty.
|
|
||||||
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
|
||||||
* machine having no quota rather than the question going unanswered.
|
|
||||||
*/
|
|
||||||
fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage {
|
|
||||||
// No snapshot at all means the backend never asked, which it only does for a machine with
|
|
||||||
// nothing metered on it. That is a different answer from having asked and failed.
|
|
||||||
val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered
|
|
||||||
if (mine.state != "ok") {
|
|
||||||
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
|
||||||
}
|
|
||||||
return SessionUsage.Known(mine.windows)
|
|
||||||
}
|
|
||||||
@@ -1,202 +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, so nothing to report.
|
|
||||||
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. Nothing is wrong with the camera, so nothing should
|
|
||||||
// say there is.
|
|
||||||
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.
|
|
||||||
* Which way round it renders is the terminal's business, not something this app should depend on.
|
|
||||||
* The mixed decoder alternates normal and inverted frames, costing half the frame rate at each
|
|
||||||
* polarity and nothing else.
|
|
||||||
*/
|
|
||||||
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,405 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material3.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.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 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 SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
|
||||||
var adding by remember { mutableStateOf(false) }
|
|
||||||
var renaming by remember { mutableStateOf<Setup?>(null) }
|
|
||||||
var confirmingDelete by remember { mutableStateOf<Setup?>(null) }
|
|
||||||
var busy by remember { mutableStateOf<String?>(null) }
|
|
||||||
var actionError by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
suspend fun reload() {
|
|
||||||
state =
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) }
|
|
||||||
} 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 }) { setup ->
|
|
||||||
SetupCard(
|
|
||||||
setup = setup,
|
|
||||||
onRename = { renaming = setup },
|
|
||||||
onRediscover = {
|
|
||||||
scope.launch {
|
|
||||||
busy = "Asking ${setup.name} what it has…"
|
|
||||||
actionError =
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
updateSetup(
|
|
||||||
settings,
|
|
||||||
setup.id,
|
|
||||||
rediscover = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
busy = null
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDelete = { confirmingDelete = setup },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adding) {
|
|
||||||
AddSetupDialog(
|
|
||||||
onDismiss = { adding = false },
|
|
||||||
onAdd = { name, ssh ->
|
|
||||||
adding = false
|
|
||||||
scope.launch {
|
|
||||||
busy = "Asking $name what it has…"
|
|
||||||
actionError =
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) { addSetup(settings, name, ssh) }
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
busy = null
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
renaming?.let { setup ->
|
|
||||||
RenameDialog(
|
|
||||||
setup = setup,
|
|
||||||
onDismiss = { renaming = null },
|
|
||||||
onRename = { name ->
|
|
||||||
renaming = null
|
|
||||||
scope.launch {
|
|
||||||
actionError =
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
updateSetup(settings, setup.id, name = name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmingDelete?.let { setup ->
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { confirmingDelete = null },
|
|
||||||
title = { Text("Remove \"${setup.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) { deleteSetup(settings, setup.id) }
|
|
||||||
}
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text("Remove")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SetupCard(
|
|
||||||
setup: Setup,
|
|
||||||
onRename: () -> Unit,
|
|
||||||
onRediscover: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
) {
|
|
||||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(setup.name, style = MaterialTheme.typography.titleSmall)
|
|
||||||
Text(
|
|
||||||
// Not "this machine": the seeded setup is *called* that,
|
|
||||||
// and the card read "this machine / this machine". The
|
|
||||||
// line has to say something the name cannot also be.
|
|
||||||
setup.address ?: "runs where the backend does",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
if (setup.providers.isEmpty()) {
|
|
||||||
"Nothing found on it. Install something and rediscover."
|
|
||||||
} else {
|
|
||||||
setup.providers.joinToString(" · ") { it.name }
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
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 AddSetupDialog(
|
|
||||||
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 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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 and
|
|
||||||
// made this field taller than the two beside it for no information.
|
|
||||||
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 and what needs no
|
|
||||||
// path typed on a phone.
|
|
||||||
OutlinedTextField(
|
|
||||||
value = attachmentsDir,
|
|
||||||
onValueChange = { attachmentsDir = it },
|
|
||||||
label = { Text("Folder for attached files (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(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
|
||||||
var name by remember { mutableStateOf(setup.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, and writing 22 here would
|
|
||||||
* put a second answer to that question in a second place.
|
|
||||||
*
|
|
||||||
* A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it,
|
|
||||||
* `[::1]:22`; a bare `::1` keeps every colon, because an address with several is an address, not an
|
|
||||||
* address and a port. So the rule is: brackets, or exactly one colon followed by digits.
|
|
||||||
*/
|
|
||||||
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,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: equal values would not recompose.
|
|
||||||
*/
|
|
||||||
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,358 +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 -- and because they are independent, any provider can be sent to any host.
|
|
||||||
*/
|
|
||||||
@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 -- see LoadState.
|
|
||||||
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
|
||||||
|
|
||||||
// Setup first, then one of its providers. Choosing a setup can
|
|
||||||
// invalidate the provider, so the provider is stored by name and
|
|
||||||
// resolved against the current setup rather than held as an object
|
|
||||||
// that could outlive the list it came from.
|
|
||||||
var setupName by remember { mutableStateOf<String?>(null) }
|
|
||||||
var providerName by remember { mutableStateOf<String?>(null) }
|
|
||||||
var title by remember { mutableStateOf("") }
|
|
||||||
var model by remember { mutableStateOf("") }
|
|
||||||
var cwd by remember { mutableStateOf("") }
|
|
||||||
// "auto" rather than "manual": on a phone every ask is a round trip to
|
|
||||||
// a question card, and answering "allow Bash?" dozens of times per task
|
|
||||||
// is what this app exists to avoid. Manual stays one tap away for a
|
|
||||||
// session that warrants it.
|
|
||||||
var permissionMode by remember { mutableStateOf("auto") }
|
|
||||||
var busy by remember { mutableStateOf(false) }
|
|
||||||
// Only the spawn's own failure. The fetch's lives in `options`: this
|
|
||||||
// one leaves a filled-in form worth keeping, and that one leaves
|
|
||||||
// nothing to fill in.
|
|
||||||
var spawnError by remember { mutableStateOf<String?>(null) }
|
|
||||||
// Downloaded models, for a llama provider to choose between. Fetched
|
|
||||||
// beside the setups but kept separate: a Claude session needs none, so
|
|
||||||
// failing to list them must not stop the screen rendering.
|
|
||||||
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
|
|
||||||
var modelKey by remember { mutableStateOf<String?>(null) }
|
|
||||||
var contextSize by remember { mutableStateOf("") }
|
|
||||||
var temperature by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
options =
|
|
||||||
try {
|
|
||||||
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
|
||||||
val first = fetched.firstOrNull()
|
|
||||||
setupName = first?.name
|
|
||||||
providerName = first?.providers?.firstOrNull()?.name
|
|
||||||
LoadState.Loaded(fetched)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
models =
|
|
||||||
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
|
|
||||||
.getOrDefault(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
|
||||||
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 setups =
|
|
||||||
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 setup = setups.firstOrNull { it.name == setupName }
|
|
||||||
val current = setup?.providers?.firstOrNull { it.name == providerName }
|
|
||||||
// Only the Claude CLI has models, a working directory and
|
|
||||||
// permission modes; keying the extra fields on the kind rather
|
|
||||||
// than the provider name keeps a second Claude provider from
|
|
||||||
// needing anything here.
|
|
||||||
val isClaude = current?.kind == "claude_cli"
|
|
||||||
val isLlama = current?.kind == "llama_cpp"
|
|
||||||
|
|
||||||
// The machine first, because it decides what can be run at all.
|
|
||||||
ChipGroup(
|
|
||||||
label = "Setup",
|
|
||||||
options = setups.map { it.name },
|
|
||||||
selected = setupName,
|
|
||||||
onSelect = { name ->
|
|
||||||
setupName = name
|
|
||||||
// The provider list changes with the machine, so a name
|
|
||||||
// carried over from the previous one would be a selection
|
|
||||||
// that isn't in the picker. Take that machine's first.
|
|
||||||
providerName =
|
|
||||||
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
|
|
||||||
},
|
|
||||||
)
|
|
||||||
setup?.address?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
// The address belongs to the setup above it, not to the
|
|
||||||
// provider label below; without this they read as one block.
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only what this machine actually has. A setup with none says so
|
|
||||||
// rather than showing an empty row that reads as a failure.
|
|
||||||
if (setup != null && setup.providers.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
"\"${setup.name}\" has no providers configured.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ChipGroup(
|
|
||||||
label = "Provider",
|
|
||||||
options = setup?.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 (isLlama) {
|
|
||||||
// A llama session names one of the models this backend has
|
|
||||||
// downloaded, so the choice is that list rather than free
|
|
||||||
// text -- there is nothing sensible to type here, and a name
|
|
||||||
// that is not on disk is a session that cannot start.
|
|
||||||
if (models.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
"No models downloaded yet. Get one from the Models screen first.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ChipGroup(
|
|
||||||
label = "Model",
|
|
||||||
// The file, not the whole key: the repository is the
|
|
||||||
// same for every quantisation of a model, so the file
|
|
||||||
// name is what tells two of them apart.
|
|
||||||
options = models.map { it.file },
|
|
||||||
selected = models.firstOrNull { it.key == modelKey }?.file,
|
|
||||||
onSelect = { file -> modelKey = models.first { it.file == file }.key },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = contextSize,
|
|
||||||
onValueChange = { contextSize = it },
|
|
||||||
label = { Text("Context size (blank = the model's default)") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = temperature,
|
|
||||||
onValueChange = { temperature = it },
|
|
||||||
label = { Text("Temperature (blank = llama.cpp's default)") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isClaude) {
|
|
||||||
if (current.models.isNotEmpty()) {
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
ChipGroup(
|
|
||||||
label = "Model",
|
|
||||||
options = current.models,
|
|
||||||
selected = model.ifEmpty { null },
|
|
||||||
onSelect = { chosen -> model = if (model == chosen) "" else chosen },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = model,
|
|
||||||
onValueChange = { model = it },
|
|
||||||
label = { Text("Model (blank = the CLI's default)") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = cwd,
|
|
||||||
onValueChange = { cwd = it },
|
|
||||||
label = { Text("Working directory") },
|
|
||||||
placeholder = { Text("/home/…") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
ChipGroup(
|
|
||||||
label = "Permissions",
|
|
||||||
options = PERMISSION_MODES,
|
|
||||||
selected = permissionMode,
|
|
||||||
onSelect = { permissionMode = it },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
spawnSession(
|
|
||||||
settings,
|
|
||||||
// The id, not the label: labels are
|
|
||||||
// editable and the server resolves by
|
|
||||||
// id.
|
|
||||||
// Non-null here: `chosen` came from
|
|
||||||
// `setup`'s own provider list, so
|
|
||||||
// reaching this point proves there was
|
|
||||||
// a setup to take it from.
|
|
||||||
setup = setup.id,
|
|
||||||
provider = chosen.name,
|
|
||||||
title = title.trim(),
|
|
||||||
model =
|
|
||||||
if (isLlama) modelKey else model.trim().takeIf { isClaude },
|
|
||||||
cwd = cwd.trim().takeIf { isClaude },
|
|
||||||
permissionMode = permissionMode.takeIf { isClaude },
|
|
||||||
// Sent only when set, so blank means
|
|
||||||
// "whatever llama.cpp does by default"
|
|
||||||
// rather than a zero.
|
|
||||||
params =
|
|
||||||
buildMap {
|
|
||||||
if (isLlama) {
|
|
||||||
contextSize
|
|
||||||
.trim()
|
|
||||||
.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put("contextSize", it) }
|
|
||||||
temperature
|
|
||||||
.trim()
|
|
||||||
.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put("temperature", it) }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onSpawned(spawned)
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
spawnError = e.message
|
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !busy && current != null && !(isLlama && modelKey == null),
|
|
||||||
) {
|
|
||||||
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,104 +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 (keep-alives) start with `:`, and a frame is either named with no
|
|
||||||
* payload or a payload with no name. Two screens follow two different streams — a session's
|
|
||||||
* transcript and what a machine's import list is doing — and neither should be re-deriving that.
|
|
||||||
*
|
|
||||||
* Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the
|
|
||||||
* cancellation path — it disconnects the socket, which unblocks the read, and [run] then returns
|
|
||||||
* rather than throwing, so a deliberate close is not reported as a connection error. Reconnecting
|
|
||||||
* belongs to the caller, which is the only one that knows where to resume from.
|
|
||||||
*/
|
|
||||||
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 -- a cursor it has
|
|
||||||
* advanced past -- and that lives in the query string.
|
|
||||||
*
|
|
||||||
* [onOpen] fires once the server has accepted the connection. That is the measured moment the
|
|
||||||
* stream is live, and the only honest thing to clear a previous failure on: clearing on the
|
|
||||||
* first *event* instead left an idle stream displaying a connection error it had already
|
|
||||||
* recovered from, indefinitely.
|
|
||||||
*/
|
|
||||||
fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) {
|
|
||||||
// Opening is inside the try, not before it. Everything this method can fail at owes the
|
|
||||||
// caller the same kind of failure -- both callers retry an [ApiException] and let anything
|
|
||||||
// else reach the top of the app -- and a connection that could not even be constructed
|
|
||||||
// used to escape as a raw `IOException` from a line no `catch` covered.
|
|
||||||
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})",
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
connection?.disconnect()
|
|
||||||
this.connection = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.foundation.gestures.Orientation
|
|
||||||
import androidx.compose.foundation.gestures.draggable
|
|
||||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dragging the screen to the right to step back to the one behind it.
|
|
||||||
*
|
|
||||||
* The platform's own back gesture is a swipe from the very edge, and only from there; on a phone
|
|
||||||
* held in one hand the way back from a session is either that narrow strip or the arrow at the top
|
|
||||||
* left, which is the far corner from the thumb. This is the same movement from anywhere on the
|
|
||||||
* screen.
|
|
||||||
*
|
|
||||||
* **It loses every argument.** The gesture is a plain horizontal [draggable] on the outside of the
|
|
||||||
* screen, so anything inside that wants horizontal drags has already taken them by the time this
|
|
||||||
* would see them: pointer events reach the innermost node first, and a drag a child has consumed
|
|
||||||
* never crosses this modifier's touch slop. That is what keeps a wide code fence, a table scrolled
|
|
||||||
* sideways or a text selection working -- they are the components the reader meant, and this is
|
|
||||||
* only what is left over. Vertical drags are not its orientation, so the transcript scrolls
|
|
||||||
* untouched.
|
|
||||||
*
|
|
||||||
* The screen follows the finger rather than jumping at the end, because a gesture with no feedback
|
|
||||||
* cannot be aborted: the reader has to be able to see it starting and change their mind. Released
|
|
||||||
* short of [SWIPE_BACK_TRAVEL] it slides back and nothing happens. Right rather than left, and only
|
|
||||||
* right, since there is nothing forward of these screens to go to.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Modifier.swipeBack(onBack: () -> Unit): Modifier {
|
|
||||||
val offset = remember { Animatable(0f) }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val travel = with(LocalDensity.current) { SWIPE_BACK_TRAVEL.toPx() }
|
|
||||||
return draggable(
|
|
||||||
state =
|
|
||||||
rememberDraggableState { delta ->
|
|
||||||
// Rightward only: a leftward drag stays at zero rather than lifting the
|
|
||||||
// screen off its left edge, which would look like a gesture that does
|
|
||||||
// something and does not.
|
|
||||||
scope.launch { offset.snapTo((offset.value + delta).coerceAtLeast(0f)) }
|
|
||||||
},
|
|
||||||
orientation = Orientation.Horizontal,
|
|
||||||
onDragStopped = {
|
|
||||||
if (offset.value >= travel) {
|
|
||||||
onBack()
|
|
||||||
// Straight back rather than animated: the screen this was moving is being
|
|
||||||
// replaced, and animating it home first would show the old one sliding back
|
|
||||||
// into place after the new one had arrived.
|
|
||||||
offset.snapTo(0f)
|
|
||||||
} else {
|
|
||||||
offset.animateTo(0f)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
// Read inside the block, so following the finger is a draw-phase change and costs no
|
|
||||||
// recomposition of the screen being dragged.
|
|
||||||
.graphicsLayer { translationX = offset.value }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How far the screen has to be pulled for letting go to mean "back" rather than "never mind". */
|
|
||||||
private val SWIPE_BACK_TRAVEL: Dp = 96.dp
|
|
||||||
@@ -1,355 +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, and the moment one wants a different accent the shared version
|
|
||||||
* becomes a thing to fight rather than a thing to use.
|
|
||||||
*
|
|
||||||
* The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle,
|
|
||||||
* Base, Surface 0, Surface 1 -- and Material asks for the same thing under different names, so the
|
|
||||||
* page is Base, a component's outlined card stays Base beside it, and a project's card is Surface
|
|
||||||
* 0: one visible step up, which is the whole of what the nesting has to say.
|
|
||||||
*
|
|
||||||
* Accents on this palette are light, so anything filled with one takes Crust for its text rather
|
|
||||||
* than the near-white the roles default to.
|
|
||||||
*/
|
|
||||||
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 -- an amber, a green and a red picked off Material's defaults -- so the same state
|
|
||||||
* was a slightly different colour depending which screen you looked at, and none of them belonged
|
|
||||||
* to this palette at all. A colour that carries meaning is part of the scheme, not a value typed
|
|
||||||
* where it happened to be needed.
|
|
||||||
*/
|
|
||||||
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 -- nothing here is a button.
|
|
||||||
*/
|
|
||||||
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 -- rewriting what it
|
|
||||||
* remembers, taking a new name -- and none of it appears in the transcript as an answer to
|
|
||||||
* anything. A reader who has learned that blue means "not stuck, but not replying to you either"
|
|
||||||
* has learned the thing that distinguishes it from a session that has hung.
|
|
||||||
*/
|
|
||||||
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. It is the same red as [failedColor] and [stopColor]
|
|
||||||
* for a third reason, which is worth naming rather than collapsing: this is neither a fault nor a
|
|
||||||
* button, it is the mark left where something was taken away. The reader never has to tell the
|
|
||||||
* three apart, because no two of them can appear as the same kind of thing.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
|
|
||||||
/** 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, or a
|
|
||||||
* compaction, has no limit to be near: it finishes. Only a bar measuring a *quota* escalates, and
|
|
||||||
* 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 whole point of
|
|
||||||
* colouring by consequence is that the reader learns the step once -- two bars showing the same 80%
|
|
||||||
* in different colours teaches nothing except that the colour cannot be trusted. It reads as a
|
|
||||||
* difference in degree, which is all colour can carry: the states that differ in *kind* from this
|
|
||||||
* -- a window nobody could read, a machine that meters nothing -- are said in words elsewhere,
|
|
||||||
* because a reader has no way to tell those from an ordinary low number by colour alone.
|
|
||||||
*
|
|
||||||
* [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent
|
|
||||||
* without each converting it first and one of them getting it wrong by a factor of a hundred.
|
|
||||||
*/
|
|
||||||
@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 whole point
|
|
||||||
* -- everything else on this screen is somebody's prose, and this is what a machine was handed and
|
|
||||||
* what it said back, character for character. Crust sits *below* Base, so the same colour reads as
|
|
||||||
* one clear step down both on the page, where a reply is drawn, and on a card, where a tool call
|
|
||||||
* is; a tint chosen upwards has to be picked twice and still collides with the card it lands on.
|
|
||||||
* The renderer's default code background was `surfaceVariant`, which is exactly a card's own fill
|
|
||||||
* -- so a code block inside a tool call had no background at all.
|
|
||||||
*
|
|
||||||
* One 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 in the app already uses, and
|
|
||||||
* splitting them out would make code the one surface whose palette came from somewhere else.
|
|
||||||
*
|
|
||||||
* Not a composable, because [highlight] runs off the drawing thread; these colours never vary with
|
|
||||||
* the theme.
|
|
||||||
*/
|
|
||||||
fun catppuccinSyntax(): SyntaxPalette =
|
|
||||||
SyntaxPalette(
|
|
||||||
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. A program that uses bright red to mean something
|
|
||||||
* other than red is relying on a distinction its own terminal may not draw either.
|
|
||||||
*
|
|
||||||
* 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 or a tool's output, on Crust, the same 40% composites to a barely-there smudge, so
|
|
||||||
* selecting a line of code looks like nothing happened even though the selection is there and
|
|
||||||
* copies correctly.
|
|
||||||
*
|
|
||||||
* Fixed and stronger, because "this is selected" is a meaning rather than decoration: a colour that
|
|
||||||
* means something must carry its own contrast instead of borrowing it from the surface it happens
|
|
||||||
* to land on. Raised only as far as it takes to read on the darkest of them -- past this the fill
|
|
||||||
* starts competing with the syntax colours it sits behind, which are the thing being read.
|
|
||||||
*/
|
|
||||||
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, and a nested list
|
|
||||||
* reads as a shape before it reads as text. Lavender is not one of the colours that mean something
|
|
||||||
* here -- green, red, peach and yellow are states and actions -- and it is the same at every depth,
|
|
||||||
* since depth is said by the glyph and the indent; a colour per depth would make a difference in
|
|
||||||
* degree look like one in kind.
|
|
||||||
*/
|
|
||||||
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 and worth
|
|
||||||
* naming rather than collapsing: [runningColor] is green because a session is working,
|
|
||||||
* [failedColor] is red because one fell over, [awaitingColor] is the same orange because a session
|
|
||||||
* is waiting on somebody -- those are *states*, and these are *actions*. A reader never has to tell
|
|
||||||
* them apart, because nothing here is a state and nothing there is pressable.
|
|
||||||
*/
|
|
||||||
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, and the next message starts a turn as though nothing had
|
|
||||||
* happened. Red is spent on [stopColor], which is the same button in the same place when what it
|
|
||||||
* would end is the session's process.
|
|
||||||
*/
|
|
||||||
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. Crust is what
|
|
||||||
* every accent on this palette takes, which is the same reason `onPrimary` is Crust above.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun actionButtonColors(fill: Color): ButtonColors =
|
|
||||||
ButtonDefaults.buttonColors(containerColor = fill, contentColor = Mocha.Crust)
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
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 -- the command a shell will run, what it is for, how long it may take --
|
|
||||||
* and anything left over is still shown, because dropping a field would be claiming the tool has no
|
|
||||||
* other input when it might.
|
|
||||||
*/
|
|
||||||
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 ([formatMillis]). 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", which is what the card always did.
|
|
||||||
*/
|
|
||||||
private val SUBJECTS: Map<String, Pair<String, Language?>> =
|
|
||||||
mapOf(
|
|
||||||
"Bash" to ("command" to Language.SHELL),
|
|
||||||
"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),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 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 {
|
|
||||||
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.
|
|
||||||
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.optString(it) }?.takeIf { it.isNotBlank() }
|
|
||||||
val description = DESCRIPTIONS.firstNotNullOfOrNull {
|
|
||||||
json.optString(it).takeIf { v -> v.isNotBlank() }
|
|
||||||
}
|
|
||||||
val timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
|
|
||||||
val rest =
|
|
||||||
json
|
|
||||||
.keys()
|
|
||||||
.asSequence()
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A tool call's input: its subject highlighted, then whatever else it carried.
|
|
||||||
*
|
|
||||||
* On the dark surface every verbatim thing in the app sits on -- see [RawBlock]. Drawn as nothing
|
|
||||||
* at all when the call carried neither, rather than as an empty block: a tinted rectangle with
|
|
||||||
* nothing in it is a rendering fault, and it is the shape a tool with no input actually has.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
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,
|
|
||||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
parsed.rest.forEach {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,467 +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.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 a value: it is rebuilt from the transcript rather than edited, and two rows describing
|
|
||||||
* the same events are equal. Compose infers stability from a class's fields, and a `List` field --
|
|
||||||
* which several of these carry -- makes it assume the worst, so every composable taking one
|
|
||||||
* recomposed whenever anything above it did. A page of history landing recomposed all 148 loaded
|
|
||||||
* rows including the markdown inside them, measured as 701 compositions for 148 rows in one scroll,
|
|
||||||
* and that is what a page landing costs on top of the fetch itself.
|
|
||||||
*
|
|
||||||
* 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. That makes it the load-bearing value on this
|
|
||||||
* screen: when a key changes, the list loses its anchor and the transcript steps under whoever
|
|
||||||
* is reading it.
|
|
||||||
*
|
|
||||||
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
|
|
||||||
* and it is the *same* value whether the run is drawn as one card or as a group. A lone call
|
|
||||||
* that gains a neighbour becomes a group without changing identity, which is the case a
|
|
||||||
* seq-based key got wrong: the row the reader was looking at was replaced rather than updated.
|
|
||||||
* Which value that is belongs to the item ([TranscriptItem.key]), not to a `when` here: a row
|
|
||||||
* is one item and the item is what knows what it is called.
|
|
||||||
*/
|
|
||||||
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 tool row is named after its run, and a run takes its name from whichever call
|
|
||||||
* was first when it was folded, which changes as pages arrive. A seq is the server's own
|
|
||||||
* numbering: it is assigned once, never moves, and means the same thing to every device. So
|
|
||||||
* anything that has to point at a place in the conversation and still find it later -- a saved
|
|
||||||
* scroll position is the one -- points with this, and anything that has to identify a row
|
|
||||||
* within one composition uses [key].
|
|
||||||
*/
|
|
||||||
abstract val startSeq: Long
|
|
||||||
|
|
||||||
data class Single(val item: TranscriptItem) : TranscriptRow() {
|
|
||||||
override val key: Any
|
|
||||||
get() = item.key
|
|
||||||
|
|
||||||
override val startSeq: Long
|
|
||||||
get() = item.seq
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Two or more calls with nothing between them; drawn as one collapsed card. */
|
|
||||||
data class Tools(val calls: List<TranscriptItem.ToolRun>) : TranscriptRow() {
|
|
||||||
/** The run's own name, which every call in it already carries. */
|
|
||||||
val id: String
|
|
||||||
get() = calls.first().runId
|
|
||||||
|
|
||||||
override val key: Any
|
|
||||||
get() = id
|
|
||||||
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> =
|
|
||||||
DebugStats.timed("grouped tool runs") { groupRuns(items) }
|
|
||||||
|
|
||||||
private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
|
|
||||||
val rows = mutableListOf<TranscriptRow>()
|
|
||||||
var run = mutableListOf<TranscriptItem.ToolRun>()
|
|
||||||
|
|
||||||
fun flush() {
|
|
||||||
when (run.size) {
|
|
||||||
0 -> {}
|
|
||||||
1 -> rows += TranscriptRow.Single(run.first())
|
|
||||||
else -> rows += TranscriptRow.Tools(run.toList())
|
|
||||||
}
|
|
||||||
run = mutableListOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
items.forEach { 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*, and a group named after its first member is a different
|
|
||||||
// group every time that happens.
|
|
||||||
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
|
|
||||||
run += item
|
|
||||||
} else {
|
|
||||||
flush()
|
|
||||||
if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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: they are the same rows
|
|
||||||
* they would be on their own, and a rounded corner drawn hard against a rounded corner reads as a
|
|
||||||
* notch.
|
|
||||||
*
|
|
||||||
* Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so
|
|
||||||
* the run reads as one thing broken into its parts; [GROUP_GAP] keeps the parts legible without
|
|
||||||
* separating them. See [connectedShape].
|
|
||||||
*
|
|
||||||
* It closes from either end. A long group's header scrolls off while its last call is still on
|
|
||||||
* screen, and the reader who wants it shut is looking at the bottom, not hunting for the top. The
|
|
||||||
* bar at the foot is the same height as the heading at the top, so the surface the calls sit on is
|
|
||||||
* as thick below them as above.
|
|
||||||
*/
|
|
||||||
@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, so the row records the touch
|
|
||||||
* itself and this just says that one happened.
|
|
||||||
*/
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
isToolExpanded: (String) -> Boolean,
|
|
||||||
onToolToggle: (String) -> 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
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.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 ->
|
|
||||||
ToolCard(
|
|
||||||
tool = call,
|
|
||||||
expanded = isToolExpanded(call.id),
|
|
||||||
onToggle = { onToolToggle(call.id) },
|
|
||||||
onAnswer = onAnswer,
|
|
||||||
image = image,
|
|
||||||
shape = connectedShape(index, group.calls.size),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The height of a group's heading, and so of the bar at its foot.
|
|
||||||
*
|
|
||||||
* Derived from the type the heading is set in rather than written down, because the two have to
|
|
||||||
* match and a pair of numbers chosen to look equal stops being equal the moment either the style or
|
|
||||||
* the density changes. Taking the line height also means the heading cannot be clipped by it.
|
|
||||||
*/
|
|
||||||
@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. See [groupBarHeight].
|
|
||||||
*/
|
|
||||||
@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 the case a hand-written first/last pair gets wrong
|
|
||||||
* when a run turns out to have three calls in it.
|
|
||||||
*/
|
|
||||||
@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 -- and the
|
|
||||||
* name plus the intent is what somebody scanning the transcript is reading for.
|
|
||||||
*
|
|
||||||
* Open, it shows the command, whatever else the input carried, and the output. The timeout sits at
|
|
||||||
* the top right: it is a limit on the call rather than part of what the call does, and it is worth
|
|
||||||
* seeing beside the command it constrains rather than buried in the fields below it.
|
|
||||||
*
|
|
||||||
* 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,
|
|
||||||
) {
|
|
||||||
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
|
|
||||||
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(tool.tool, 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, in the colour this
|
|
||||||
// app uses everywhere for that.
|
|
||||||
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 (tool.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 -- a directory listing, a diff, a table of numbers -- and a
|
|
||||||
// proportional font silently destroys the alignment that carried the meaning.
|
|
||||||
//
|
|
||||||
// Its terminal styling applied and the rest of the escapes taken out, since
|
|
||||||
// what a shell prints is written for a terminal: colour is often the whole of
|
|
||||||
// what a diff or a test run is saying, and the sequences that carry it are
|
|
||||||
// unreadable drawn verbatim. Remembered against the text, so a card that is
|
|
||||||
// open through a scroll parses once. See [ansiStyled].
|
|
||||||
val palette = remember { ansiPalette() }
|
|
||||||
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
|
|
||||||
RawBlock(Modifier.padding(top = 2.dp)) {
|
|
||||||
Text(
|
|
||||||
styled,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Shown open or closed. A call that produced a picture is one
|
|
||||||
// whose result *is* the picture, and a row that hides it says
|
|
||||||
// less than the one line it replaced -- unlike a command, which
|
|
||||||
// is what the closed line already summarises.
|
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 several choices and worth reviewing --
|
|
||||||
// so the press has to be its own acknowledgement or the row sits unchanged for a round trip
|
|
||||||
// and reads as having missed the tap. Cleared when the request settles: by then either the
|
|
||||||
// answer is in `ask.answers` and the mark stands on a measurement, or it failed and the
|
|
||||||
// buttons come back rather than leaving a decision marked that nothing recorded.
|
|
||||||
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, and
|
|
||||||
// whether a tool was allowed or refused is the thing a reader comes back to this row for.
|
|
||||||
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,574 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
|
|
||||||
* stream is the only data source -- opening a session screen replays from seq 0, and a reconnect
|
|
||||||
* resumes from the last seq seen, so there is no separate history fetch to drift from it.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
sealed class TranscriptItem {
|
|
||||||
/**
|
|
||||||
* The transcript sequence number this row started at, and its identity on screen.
|
|
||||||
*
|
|
||||||
* The list is drawn newest-first, so every new message is an insertion at index 0 and every
|
|
||||||
* page of history is an insertion at the far end. Without an identity that survives both, the
|
|
||||||
* list is addressed by position: whatever somebody had scrolled to keeps its index while the
|
|
||||||
* content underneath it slides, which reads as the view scrolling on its own.
|
|
||||||
*
|
|
||||||
* A seq is the right identity because it is what the transcript itself is ordered by, it never
|
|
||||||
* changes, and it is already carried by every event. A row built from several events -- a
|
|
||||||
* streaming message, a tool call and its result -- keeps the seq of the first, so it holds
|
|
||||||
* still while the rest of it arrives.
|
|
||||||
*/
|
|
||||||
abstract val seq: Long
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This item's identity on screen, which is its [seq] for everything that has one of its own.
|
|
||||||
*
|
|
||||||
* Here rather than in [TranscriptRow.Single] because the two items that need something else are
|
|
||||||
* the two that know why: a tool call is named after its run, and a peer note is *sorted* by the
|
|
||||||
* turn it started rather than by where it arrived. Asking each item what it is called is also
|
|
||||||
* what stops the next such item being missed -- a `when` over concrete types in the row would
|
|
||||||
* have to gain a case, silently, and nothing says when it did not.
|
|
||||||
*/
|
|
||||||
open val key: Any
|
|
||||||
get() = seq
|
|
||||||
|
|
||||||
data class UserMsg(
|
|
||||||
override val seq: Long,
|
|
||||||
val text: String,
|
|
||||||
/** Refs of what was attached, drawn inside the bubble. */
|
|
||||||
val attachments: List<String> = emptyList(),
|
|
||||||
) : TranscriptItem()
|
|
||||||
|
|
||||||
data class AssistantMsg(
|
|
||||||
override val seq: Long,
|
|
||||||
val text: String,
|
|
||||||
/**
|
|
||||||
* Whether this reply is finished: the session has stopped working since its last delta.
|
|
||||||
*
|
|
||||||
* What it buys is the split. [transcriptUnits] keeps the newest reply whole because a
|
|
||||||
* streaming reply's text changes per delta and splitting a changing text is a parse per
|
|
||||||
* delta -- but "newest" outlives the turn, so a session that ends on a long reply was
|
|
||||||
* drawing it as one item indefinitely, with every node of it alive. Measured on a Pixel 9
|
|
||||||
* Pro XL: one 34,996px reply on screen put the frame's draw phase at 13.8ms, 79% of it the
|
|
||||||
* framework's own bookkeeping, which grows with alive nodes.
|
|
||||||
*
|
|
||||||
* Folded from the status event that ended the turn, rather than read off the screen's
|
|
||||||
* status, because rows only change through the held-events gate: the split changes the
|
|
||||||
* newest row's list identity, and doing that from a status flip while somebody is reading
|
|
||||||
* inside that reply would step the list under them. An event has to wait for the reader to
|
|
||||||
* be at the newest end; a screen state does not.
|
|
||||||
*/
|
|
||||||
val settled: Boolean = false,
|
|
||||||
) : TranscriptItem()
|
|
||||||
|
|
||||||
data class ToolRun(
|
|
||||||
override val seq: Long,
|
|
||||||
val id: String,
|
|
||||||
/**
|
|
||||||
* The run of adjacent calls this one belongs to, named once when the call is folded in and
|
|
||||||
* never recomputed.
|
|
||||||
*
|
|
||||||
* Carried rather than derived because a run can gain members at *either* end -- a new call
|
|
||||||
* arriving beside it, or a page of history arriving in front of it -- so no function of its
|
|
||||||
* current members is stable. It is the first call's id at the moment the run started, which
|
|
||||||
* is a name rather than a description: [joinPages] hands it to older calls that turn out to
|
|
||||||
* belong to the same run, instead of renaming the run they joined.
|
|
||||||
*/
|
|
||||||
val runId: String,
|
|
||||||
val tool: String,
|
|
||||||
val input: String,
|
|
||||||
val output: String,
|
|
||||||
val done: Boolean,
|
|
||||||
/**
|
|
||||||
* The questions this call is waiting on, in the order they were asked.
|
|
||||||
*
|
|
||||||
* On the call's own row rather than beside it: an ask used to arrive as a second card
|
|
||||||
* repeating the input verbatim, so the reader saw the same command twice and had to work
|
|
||||||
* out that it was one event. The backend says which call a question is about, so this is a
|
|
||||||
* fact rather than a match on the input.
|
|
||||||
*
|
|
||||||
* A list because AskUserQuestion asks up to four at once, and they are one decision to make
|
|
||||||
* -- a permission is the case of exactly one, not a different shape.
|
|
||||||
*/
|
|
||||||
val asks: List<QuestionCard> = emptyList(),
|
|
||||||
/**
|
|
||||||
* Images this call's result carried, drawn under it.
|
|
||||||
*
|
|
||||||
* Beside it they had to be paired by position, and position is the thing a page boundary
|
|
||||||
* breaks -- a screenshot loaded on one page and its call on the next read as unrelated.
|
|
||||||
*/
|
|
||||||
val images: List<String> = emptyList(),
|
|
||||||
) : TranscriptItem() {
|
|
||||||
/** A run, not a seq: see [TranscriptRow.key] for what that identity has to survive. */
|
|
||||||
override val key: Any
|
|
||||||
get() = runId
|
|
||||||
}
|
|
||||||
|
|
||||||
data class QuestionCard(
|
|
||||||
override val seq: Long,
|
|
||||||
val id: String,
|
|
||||||
val prompt: String,
|
|
||||||
/** A few words naming what this 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,
|
|
||||||
/** What was chosen, once something was; empty until then. */
|
|
||||||
val answers: List<String>,
|
|
||||||
) : TranscriptItem()
|
|
||||||
|
|
||||||
data class ErrorMsg(override val seq: Long, val message: String) : TranscriptItem()
|
|
||||||
|
|
||||||
/** An image by server-side ref, fetched from the session's files route. */
|
|
||||||
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A message another agent sent this session.
|
|
||||||
*
|
|
||||||
* Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters.
|
|
||||||
*/
|
|
||||||
data class PeerNote(
|
|
||||||
override val seq: Long,
|
|
||||||
val from: String,
|
|
||||||
val text: String,
|
|
||||||
/**
|
|
||||||
* The seq of the event this note came in on, which is what makes it itself.
|
|
||||||
*
|
|
||||||
* [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began at
|
|
||||||
* so the note is drawn above the reply it caused. Two messages that arrive during one turn
|
|
||||||
* therefore share a seq -- and sharing an identity as well killed the app, because the
|
|
||||||
* transcript list refuses two items with one key. Two agents writing to a session mid-turn
|
|
||||||
* is an ordinary afternoon, not a corner.
|
|
||||||
*/
|
|
||||||
val arrived: Long = seq,
|
|
||||||
) : TranscriptItem() {
|
|
||||||
override val key: Any
|
|
||||||
get() = arrived
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command the session ran on itself -- `/compact`, `/rename`.
|
|
||||||
*
|
|
||||||
* Kept in the transcript rather than only shown while it waits, because it explains what
|
|
||||||
* follows: a conversation that suddenly has half the context, or a session with a new name.
|
|
||||||
*/
|
|
||||||
data class CommandRow(override val seq: Long, val text: String) : TranscriptItem()
|
|
||||||
|
|
||||||
/** Placeholder row for events this build can't render (newer kinds). */
|
|
||||||
data class Note(override val seq: Long, val text: String) : TranscriptItem()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A clear that happened: everything above it left the session's context and stayed on screen.
|
|
||||||
*
|
|
||||||
* Carries only its position, because that is all it means.
|
|
||||||
*/
|
|
||||||
data class ClearedNote(override val seq: Long) : TranscriptItem()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A compaction that happened, and what it recovered.
|
|
||||||
*
|
|
||||||
* In the transcript rather than only in the status line, because the status is gone the moment
|
|
||||||
* it finishes and this is the part worth keeping: it is the explanation for a gap in the
|
|
||||||
* conversation, and for a minute or two in which the session was busy with nothing to show.
|
|
||||||
*
|
|
||||||
* The wire also says what triggered it, and this deliberately does not carry that: the row says
|
|
||||||
* the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would
|
|
||||||
* be a field nothing can read.
|
|
||||||
*/
|
|
||||||
data class CompactedNote(
|
|
||||||
override val seq: Long,
|
|
||||||
val preTokens: Long?,
|
|
||||||
val postTokens: Long?,
|
|
||||||
) : TranscriptItem()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The run a call joins: the one it lands next to, or a new one named after itself.
|
|
||||||
*
|
|
||||||
* Only ever consulted when the call is first folded in. That is what makes the name stable -- a run
|
|
||||||
* keeps whatever it was called when it started, however many calls arrive at either end of it
|
|
||||||
* afterwards.
|
|
||||||
*
|
|
||||||
* A question to the reader is in a run of its own, which is what puts it on the transcript as a row
|
|
||||||
* rather than inside a collapsed "Called 6 tools" card. Two things follow from being alone: it is
|
|
||||||
* always visible, since a run of one is drawn as itself rather than as a group; and the calls
|
|
||||||
* around it fall into a group before it and a group after it, so where the reader was asked
|
|
||||||
* something is legible in the shape of the transcript without opening anything. It ends the run
|
|
||||||
* before it as well as starting a fresh one after -- the moment somebody was asked is a boundary in
|
|
||||||
* the work, not a gap in the middle of one run.
|
|
||||||
*/
|
|
||||||
private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String {
|
|
||||||
val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id
|
|
||||||
if (tool == ASK_USER_QUESTION || previous.tool == ASK_USER_QUESTION) return id
|
|
||||||
return previous.runId
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Puts a page of older items in front of the ones already loaded, healing whatever the page
|
|
||||||
* boundary cut in two.
|
|
||||||
*
|
|
||||||
* Two things straddle a boundary: a tool call separated from its result, and a message separated
|
|
||||||
* from the rest of itself. Both were one thing before the transcript was cut into pages, and both
|
|
||||||
* have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn
|
|
||||||
* twice, arriving from the same cause.
|
|
||||||
*
|
|
||||||
* A boundary lands wherever it lands, and roughly half the time that is between a call and its
|
|
||||||
* result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws
|
|
||||||
* as a row of its own -- correctly, because a call that renders as nothing is indistinguishable
|
|
||||||
* from one that never happened. When the older page arrives it brings the real `ToolStart`, and
|
|
||||||
* concatenating the two lists left *both*: the same call twice, once as a proper card and once as a
|
|
||||||
* nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than
|
|
||||||
* the miscount -- the extra row is at the join, so it also moves everything the reader was looking
|
|
||||||
* at.
|
|
||||||
*
|
|
||||||
* Merged by the call's own id rather than by position, because position is exactly what a page
|
|
||||||
* boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the
|
|
||||||
* newer on what an end knows (the output, and whether it finished), which is the only way round
|
|
||||||
* that loses nothing.
|
|
||||||
*
|
|
||||||
* The third thing is the *run*, and it is the one that used to be missed. Every page ends up here,
|
|
||||||
* but [adoptRun] only ran on the path where a split call had been found -- so the boundary that
|
|
||||||
* falls cleanly between two finished calls, which is most of them, went straight to concatenation
|
|
||||||
* and left the older page's calls under the run name they were folded with. On screen: one run of
|
|
||||||
* tool calls drawn as two groups, with the seam wherever the reader happened to have paged. The two
|
|
||||||
* early returns were an optimisation on a list the size of one page, and they were skipping work
|
|
||||||
* rather than saving it.
|
|
||||||
*/
|
|
||||||
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
|
|
||||||
val (older, newer) = healSplitMessage(earlier, later)
|
|
||||||
val startedEarlier =
|
|
||||||
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
|
|
||||||
val endedLater =
|
|
||||||
newer
|
|
||||||
.filterIsInstance<TranscriptItem.ToolRun>()
|
|
||||||
.associateBy { it.id }
|
|
||||||
.filterKeys { it in startedEarlier }
|
|
||||||
val healed = older.map { row ->
|
|
||||||
val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] }
|
|
||||||
if (row is TranscriptItem.ToolRun && half != null) {
|
|
||||||
row.copy(
|
|
||||||
output = half.output,
|
|
||||||
done = half.done,
|
|
||||||
// Kept from both halves: a question or an image can be attached to either,
|
|
||||||
// depending on which side of the boundary its event fell.
|
|
||||||
asks = row.asks + half.asks,
|
|
||||||
images = row.images + half.images,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
row
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val kept = newer.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater }
|
|
||||||
return adoptRun(healed, kept) + kept
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
|
|
||||||
*
|
|
||||||
* [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas
|
|
||||||
* accumulate into the message before them -- so two meeting at a join are always the two halves of
|
|
||||||
* one reply, and leaving them apart drew a single answer as two, with a paragraph break through the
|
|
||||||
* middle of a sentence.
|
|
||||||
*
|
|
||||||
* The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on
|
|
||||||
* screen, and renaming that is how the list loses its anchor. It grows by what the older half
|
|
||||||
* brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded,
|
|
||||||
* so the growth extends off the top of the screen, away from the row the list anchors to.
|
|
||||||
*/
|
|
||||||
private fun healSplitMessage(
|
|
||||||
earlier: List<TranscriptItem>,
|
|
||||||
later: List<TranscriptItem>,
|
|
||||||
): Pair<List<TranscriptItem>, List<TranscriptItem>> {
|
|
||||||
val head = earlier.lastOrNull()
|
|
||||||
val tail = later.firstOrNull()
|
|
||||||
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
|
|
||||||
return earlier to later
|
|
||||||
}
|
|
||||||
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hands the older calls at the join the name of the run they are joining.
|
|
||||||
*
|
|
||||||
* The two pages were folded separately, so a run split by the boundary came back as two runs with
|
|
||||||
* two names. Naming the joined run after the *older* half would be the obvious way round and is the
|
|
||||||
* wrong one: the newer half is the part already on screen, and renaming it is renaming the row the
|
|
||||||
* reader is looking at, which is how a list loses its anchor and steps under them. So the arriving
|
|
||||||
* calls take the name of the ones already there, and nothing visible changes identity.
|
|
||||||
*/
|
|
||||||
private fun adoptRun(
|
|
||||||
earlier: List<TranscriptItem>,
|
|
||||||
later: List<TranscriptItem>,
|
|
||||||
): List<TranscriptItem> {
|
|
||||||
val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier
|
|
||||||
// A question is in a run of its own on both sides of the join, the same as it would be had
|
|
||||||
// the two pages been folded as one -- see `runIdFor`. Without this the heal would merge a
|
|
||||||
// group straight through the row the reader was asked something on.
|
|
||||||
if (first.tool == ASK_USER_QUESTION) return earlier
|
|
||||||
val joining = first.runId
|
|
||||||
val tail = earlier.takeLastWhile {
|
|
||||||
it is TranscriptItem.ToolRun && it.tool != ASK_USER_QUESTION
|
|
||||||
}
|
|
||||||
if (tail.isEmpty()) return earlier
|
|
||||||
return earlier.dropLast(tail.size) +
|
|
||||||
tail.map { (it as TranscriptItem.ToolRun).copy(runId = joining) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A peer message goes above the turn it started, not where it happened to arrive.
|
|
||||||
*
|
|
||||||
* The live Claude Code path cannot record it in place: the CLI says nothing about a peer message
|
|
||||||
* until the turn's `result`, so the event lands below the whole reply it caused -- the answer
|
|
||||||
* printed above the question. The server stamps it with where that turn began
|
|
||||||
* ([SessionEvent.PeerMessage.turnStart]) and the note takes that seq, so it sorts into the list
|
|
||||||
* where it belongs rather than being drawn out of order at the end.
|
|
||||||
*
|
|
||||||
* Taking the turn's opening seq as its own is also what keeps the list sorted, which anchors and
|
|
||||||
* paging both depend on. It is only a *position*, though, and the note keeps its own arrival seq as
|
|
||||||
* its identity ([TranscriptItem.PeerNote.arrived]). The argument for sharing was that the turn's
|
|
||||||
* seq belongs to a status change and a status draws no row -- true, and it answered the wrong
|
|
||||||
* question: what two notes stamped with the same turn collide with is each other.
|
|
||||||
*
|
|
||||||
* Without a stamp -- a message replayed out of a session file, which is already in the right place
|
|
||||||
* -- it stays where it arrived.
|
|
||||||
*/
|
|
||||||
private fun placePeerNote(
|
|
||||||
items: List<TranscriptItem>,
|
|
||||||
seq: Long,
|
|
||||||
event: SessionEvent.PeerMessage,
|
|
||||||
): List<TranscriptItem> {
|
|
||||||
val at = event.turnStart ?: return items + TranscriptItem.PeerNote(seq, event.from, event.text)
|
|
||||||
val note = TranscriptItem.PeerNote(at, event.from, event.text, arrived = seq)
|
|
||||||
val index = items.indexOfFirst { it.seq > at }
|
|
||||||
if (index < 0) return items + note
|
|
||||||
val behind = (items.getOrNull(index - 1) as? TranscriptItem.ToolRun)?.runId
|
|
||||||
return items.subList(0, index) + note + splitRun(items.subList(index, items.size), behind)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The calls the note now sits in front of, renamed if they were sharing a run with the calls behind
|
|
||||||
* it.
|
|
||||||
*
|
|
||||||
* A run is named from what a call landed next to (see [runIdFor]), and nothing there knows about
|
|
||||||
* turns -- so a turn opening with a tool call, straight after one that ended with one, folds them
|
|
||||||
* into a single run. Left alone, [groupToolRuns] would flush at the note and hand both halves the
|
|
||||||
* same name: two rows with one key, which a keyed list cannot draw at all.
|
|
||||||
*
|
|
||||||
* The later half is the one renamed, which is the opposite of a page join ([adoptRun]) and right
|
|
||||||
* for the opposite reason. There the two halves were always one run and the newer was already on
|
|
||||||
* screen; here they were never one turn's work, and both halves change appearance at the same
|
|
||||||
* moment the note appears between them.
|
|
||||||
*/
|
|
||||||
private fun splitRun(tail: List<TranscriptItem>, behind: String?): List<TranscriptItem> {
|
|
||||||
val first = tail.firstOrNull() as? TranscriptItem.ToolRun ?: return tail
|
|
||||||
if (behind == null || first.runId != behind) return tail
|
|
||||||
val run = tail.takeWhile { it is TranscriptItem.ToolRun && it.runId == behind }
|
|
||||||
return run.map { (it as TranscriptItem.ToolRun).copy(runId = first.id) } + tail.drop(run.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
|
|
||||||
when (val event = entry.event) {
|
|
||||||
is SessionEvent.UserMessage ->
|
|
||||||
items + TranscriptItem.UserMsg(entry.seq, event.text, event.attachments)
|
|
||||||
is SessionEvent.AssistantText -> {
|
|
||||||
// Deltas accumulate into the message they're streaming, which keeps the seq of the
|
|
||||||
// first of them: a row whose identity changed with every delta would be a new row on
|
|
||||||
// every frame, and the list would jump for the whole of a streamed answer.
|
|
||||||
val last = items.lastOrNull()
|
|
||||||
if (last is TranscriptItem.AssistantMsg) {
|
|
||||||
// A message growing again is not finished, whatever a status said in between.
|
|
||||||
items.dropLast(1) + last.copy(text = last.text + event.delta, settled = false)
|
|
||||||
} else {
|
|
||||||
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is SessionEvent.ToolStart ->
|
|
||||||
items +
|
|
||||||
TranscriptItem.ToolRun(
|
|
||||||
entry.seq,
|
|
||||||
event.id,
|
|
||||||
runIdFor(items, event.id, event.tool),
|
|
||||||
event.tool,
|
|
||||||
event.input,
|
|
||||||
"",
|
|
||||||
done = false,
|
|
||||||
)
|
|
||||||
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
|
|
||||||
is SessionEvent.ToolEnd ->
|
|
||||||
// Created when its start is not here, rather than dropped. A
|
|
||||||
// fold that only ever *updates* loses the whole call when the
|
|
||||||
// start fell outside the loaded window, and a tool call that
|
|
||||||
// renders as nothing is indistinguishable from one that never
|
|
||||||
// happened. The name is unknown from an end alone; loading the
|
|
||||||
// page before this one replaces the row with the real thing.
|
|
||||||
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
|
|
||||||
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
|
|
||||||
} else {
|
|
||||||
items +
|
|
||||||
TranscriptItem.ToolRun(
|
|
||||||
entry.seq,
|
|
||||||
event.id,
|
|
||||||
// The name is not known from an end alone, so a call that was an ask
|
|
||||||
// cannot be recognised as one here; loading the page before this
|
|
||||||
// replaces the row with the real thing, which is when it splits out.
|
|
||||||
runIdFor(items, event.id, "tool"),
|
|
||||||
"tool",
|
|
||||||
"",
|
|
||||||
event.output,
|
|
||||||
done = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is SessionEvent.Question -> {
|
|
||||||
val card =
|
|
||||||
TranscriptItem.QuestionCard(
|
|
||||||
entry.seq,
|
|
||||||
event.id,
|
|
||||||
event.prompt,
|
|
||||||
event.header,
|
|
||||||
event.options,
|
|
||||||
event.multiSelect,
|
|
||||||
emptyList(),
|
|
||||||
)
|
|
||||||
// A question with no tool behind it -- AskUserQuestion, or an ask
|
|
||||||
// whose call fell outside the loaded window -- is a card of its
|
|
||||||
// own, which is what every question was before this.
|
|
||||||
if (
|
|
||||||
event.about != null &&
|
|
||||||
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
|
|
||||||
) {
|
|
||||||
updateTool(items, event.about) { it.copy(asks = it.asks + card) }
|
|
||||||
} else {
|
|
||||||
items + card
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is SessionEvent.Answered ->
|
|
||||||
// Resolved wherever it is drawn: a card of its own, or a tool
|
|
||||||
// row's ask. Missing the second left an Allow/Deny pair live on
|
|
||||||
// a question already answered from another device.
|
|
||||||
items.map {
|
|
||||||
when {
|
|
||||||
it is TranscriptItem.QuestionCard && it.id == event.id ->
|
|
||||||
it.copy(answers = event.answers)
|
|
||||||
it is TranscriptItem.ToolRun && it.asks.any { ask -> ask.id == event.id } ->
|
|
||||||
it.copy(
|
|
||||||
asks =
|
|
||||||
it.asks.map { ask ->
|
|
||||||
if (ask.id == event.id) ask.copy(answers = event.answers)
|
|
||||||
else ask
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else -> it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is SessionEvent.PeerMessage -> placePeerNote(items, entry.seq, event)
|
|
||||||
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
|
|
||||||
// Screen-level state, not transcript rows -- see SessionScreen.
|
|
||||||
is SessionEvent.CommandQueued -> items
|
|
||||||
// No row of its own: a message that is still waiting is drawn as a pending bubble below
|
|
||||||
// the transcript, and becomes an ordinary one where the session read it.
|
|
||||||
is SessionEvent.MessageQueued -> items
|
|
||||||
// The bubble goes away and nothing takes its place: the message was never read, so there
|
|
||||||
// is nothing it belongs above.
|
|
||||||
is SessionEvent.MessageDropped -> items
|
|
||||||
is SessionEvent.Settings -> items
|
|
||||||
is SessionEvent.Status -> settleReply(items, event.state)
|
|
||||||
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
|
|
||||||
is SessionEvent.Image ->
|
|
||||||
// Under the call that produced it when there is one, and a row of
|
|
||||||
// its own when there is not -- a person's own attachment belongs
|
|
||||||
// to no call, and neither does one whose call fell outside the
|
|
||||||
// loaded window.
|
|
||||||
if (
|
|
||||||
event.about != null &&
|
|
||||||
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
|
|
||||||
) {
|
|
||||||
updateTool(items, event.about) { it.copy(images = it.images + event.ref) }
|
|
||||||
} else {
|
|
||||||
items + TranscriptItem.ImageItem(entry.seq, event.ref)
|
|
||||||
}
|
|
||||||
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
|
|
||||||
is SessionEvent.Compacted ->
|
|
||||||
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
|
|
||||||
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
|
|
||||||
// Screen-level state, not transcript rows -- see SessionScreen.
|
|
||||||
is SessionEvent.UsageDelta -> items
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A status saying the session stopped working is the moment its newest reply is finished.
|
|
||||||
*
|
|
||||||
* See [TranscriptItem.AssistantMsg.settled] for what the mark buys and why it is made here in the
|
|
||||||
* fold. Status changes are transcript events with seqs of their own, so a replayed session settles
|
|
||||||
* its replies the same way a live one does.
|
|
||||||
*/
|
|
||||||
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
|
|
||||||
if (sessionWorking(state)) return items
|
|
||||||
val last = items.lastOrNull() as? TranscriptItem.AssistantMsg ?: return items
|
|
||||||
if (last.settled) return items
|
|
||||||
return items.dropLast(1) + last.copy(settled = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateTool(
|
|
||||||
items: List<TranscriptItem>,
|
|
||||||
id: String,
|
|
||||||
change: (TranscriptItem.ToolRun) -> TranscriptItem.ToolRun,
|
|
||||||
): List<TranscriptItem> = items.map {
|
|
||||||
if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where markdown is parsed ahead of being drawn: two threads, never all of them.
|
|
||||||
*
|
|
||||||
* The default dispatcher sizes itself to the machine, which is right for work somebody is waiting
|
|
||||||
* on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and
|
|
||||||
* taking every core for them leaves the thread that draws the frame queueing behind one -- measured
|
|
||||||
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile, which is the frame failing to
|
|
||||||
* *start* rather than taking too long once it had.
|
|
||||||
*/
|
|
||||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
|
||||||
private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the markdown among [rows], off whatever thread is drawing.
|
|
||||||
*
|
|
||||||
* Called where a page of transcript is folded rather than where a row is composed, which is the
|
|
||||||
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
|
|
||||||
* [ParsedReplies].
|
|
||||||
*
|
|
||||||
* What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer
|
|
||||||
* message, every one of them whole, since every piece of a message is drawn from its one parse --
|
|
||||||
* because a string warmed under a key no row ever looks up is a miss that nothing reports; see
|
|
||||||
* [transcriptUnits], which is the flatten this has to agree with. It reads the same
|
|
||||||
* [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages
|
|
||||||
* hand it back through here, while the whole loaded transcript crosses this on every page.
|
|
||||||
*
|
|
||||||
* Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer
|
|
||||||
* message was missing: this used to filter for assistant replies alone, so the one row type nobody
|
|
||||||
* had thought about paid its whole parse in the frame it appeared in, with no counter saying which
|
|
||||||
* row it was.
|
|
||||||
*/
|
|
||||||
suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
|
|
||||||
withContext(parsingThreads) {
|
|
||||||
val texts = rows.flatMap { row ->
|
|
||||||
when (row) {
|
|
||||||
is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text }
|
|
||||||
// A message from another agent is markdown too, and it is the longest thing
|
|
||||||
// in a transcript often enough that leaving it out was the whole of why one
|
|
||||||
// cost a fifth of a second to open: it was the only markdown in the app
|
|
||||||
// parsed on the thread that draws.
|
|
||||||
is TranscriptItem.PeerNote -> listOf(row.text)
|
|
||||||
else -> emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (texts.isNotEmpty()) replies.warm(texts)
|
|
||||||
// After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's
|
|
||||||
// licence to draw these as blocks on the composing thread.
|
|
||||||
rows.forEach { if (it is TranscriptItem.AssistantMsg) replies.markSplitReady(it.text) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
|
||||||
import androidx.compose.foundation.text.selection.SelectionState
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
|
||||||
import androidx.compose.ui.layout.layout
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The transcript: a lazy list of [TranscriptUnit]s, laid out in reverse.
|
|
||||||
*
|
|
||||||
* Reverse layout is what makes the two insertions this list gets free rather than corrected. Item
|
|
||||||
* zero is the newest content and sits at the bottom, so a message arriving extends the end the
|
|
||||||
* viewport is pinned to and following it is not an effect -- and a page of older history lands at
|
|
||||||
* indices past everything visible, which moves nothing on screen. The keyboard is the same case
|
|
||||||
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge. A
|
|
||||||
* conversation shorter than the screen stacks from the bottom, hanging from the composer.
|
|
||||||
*
|
|
||||||
* The lazy list is also the whole of the windowing. Only what is near the viewport is composed and
|
|
||||||
* alive, so the per-frame cost is bounded by the screen rather than by how much is loaded -- the
|
|
||||||
* property a plain column here had to approximate with retained ranges and stand-in spacers, each
|
|
||||||
* of which was a way to flicker. An item the framework composes is drawn the same frame it is
|
|
||||||
* placed, and an item off screen is not a node at all.
|
|
||||||
*
|
|
||||||
* What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a
|
|
||||||
* reply, and its parse is already made by [warm] before the fold that introduces it -- so entering
|
|
||||||
* composition costs laying out one paragraph, not parsing a message.
|
|
||||||
*
|
|
||||||
* The whole list sits in a [SelectionContainer], which is what makes every word in the transcript
|
|
||||||
* selectable by the platform's own press-and-hold. Here rather than at each place text is drawn: a
|
|
||||||
* transcript is one body of text to a reader, and a container per row would mean a selection could
|
|
||||||
* never cross from a reply into the tool output that follows it -- and would leave whatever was
|
|
||||||
* drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep
|
|
||||||
* their tap handlers: selection is a long press, and the container passes an ordinary click through
|
|
||||||
* to the card under it.
|
|
||||||
*
|
|
||||||
* [selection] is the container's own state, held by the caller rather than made here, because the
|
|
||||||
* rows have to be able to ask whether anything is selected before they act on a tap -- a tap whose
|
|
||||||
* job is to put a selection away is not also a tap on the card under it. See the caller's
|
|
||||||
* `expanding`.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun TranscriptList(
|
|
||||||
units: List<TranscriptUnit>,
|
|
||||||
state: LazyListState,
|
|
||||||
moreHistory: Boolean,
|
|
||||||
selection: SelectionState,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
below: @Composable () -> Unit,
|
|
||||||
unit: @Composable (TranscriptUnit) -> Unit,
|
|
||||||
) {
|
|
||||||
SelectionContainer(selection) {
|
|
||||||
LazyColumn(
|
|
||||||
state = state,
|
|
||||||
reverseLayout = true,
|
|
||||||
contentPadding = TRANSCRIPT_PADDING,
|
|
||||||
modifier =
|
|
||||||
// Timed in two halves because the frame's draw phase is where Compose's measurement
|
|
||||||
// lands, and "draw is high while nothing is being recorded" does not say which
|
|
||||||
// half;
|
|
||||||
// see [drawAccounting]. Measure includes composing the items that scrolled in.
|
|
||||||
modifier
|
|
||||||
.layout { measurable, constraints ->
|
|
||||||
val started = System.nanoTime()
|
|
||||||
val placeable = measurable.measure(constraints)
|
|
||||||
DebugStats.record(
|
|
||||||
"measure: the whole transcript",
|
|
||||||
System.nanoTime() - started,
|
|
||||||
)
|
|
||||||
layout(placeable.width, placeable.height) {
|
|
||||||
val placing = System.nanoTime()
|
|
||||||
placeable.place(0, 0)
|
|
||||||
DebugStats.record(
|
|
||||||
"place: the whole transcript",
|
|
||||||
System.nanoTime() - placing,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.drawWithContent {
|
|
||||||
val started = System.nanoTime()
|
|
||||||
drawContent()
|
|
||||||
DebugStats.record("draw: the whole transcript", System.nanoTime() - started)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
// The bottom of the screen: what is waiting to be read sits under the newest message.
|
|
||||||
// The one item before the units, which is what [UNITS_START] counts.
|
|
||||||
item(key = "below", contentType = "below") { below() }
|
|
||||||
items(count = units.size, key = { units[it].key }, contentType = { units[it]::class }) {
|
|
||||||
val u = units[it]
|
|
||||||
DebugStats.count("unit composed")
|
|
||||||
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
|
|
||||||
}
|
|
||||||
// Standing in for everything not fetched yet. Only here while there is more -- its
|
|
||||||
// appearance at the top edge is also roughly when the next page is asked for, so what
|
|
||||||
// it
|
|
||||||
// reports is a fetch in flight rather than an end reached.
|
|
||||||
if (moreHistory) {
|
|
||||||
item(key = "history", contentType = "history") {
|
|
||||||
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many of the list's own items come before the first unit -- the waiting-messages slot.
|
|
||||||
*
|
|
||||||
* Named once because two things count on it: the list building itself, and [visibleUnits] turning a
|
|
||||||
* list index back into the unit that was drawn there. Read off by hand at the second of those, it
|
|
||||||
* is an off-by-one that misnames every row in a report and looks like a plausible answer.
|
|
||||||
*/
|
|
||||||
const val UNITS_START = 1
|
|
||||||
|
|
||||||
/** The gap between rows, and the room around the whole conversation. */
|
|
||||||
val TRANSCRIPT_SPACING: Dp = 8.dp
|
|
||||||
|
|
||||||
val TRANSCRIPT_PADDING: PaddingValues = PaddingValues(16.dp)
|
|
||||||
|
|
||||||
/** Smaller than the whole-screen loading spinner: it stands in for a page, not for everything. */
|
|
||||||
private val HISTORY_SPINNER = 24.dp
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One item of the transcript list: a whole row, or one block of a settled reply.
|
|
||||||
*
|
|
||||||
* The unit of laziness is deliberately smaller than a message. A lazy list pays to compose an item
|
|
||||||
* at the moment it scrolls into view, and that cost is proportional to the item -- a reply can be
|
|
||||||
* twenty-five screens of markdown, which as one item is a hundred-millisecond frame exactly when
|
|
||||||
* the list is moving fastest. A *block* is a paragraph, a fence, a table: bounded, so the worst
|
|
||||||
* frame is bounded. This is the piece that was missing when a lazy list was last tried here; the
|
|
||||||
* block splitting existed only inside the row, where the list could not see it.
|
|
||||||
*
|
|
||||||
* Everything else about the row model is unchanged: rows come from [groupToolRuns], and a unit
|
|
||||||
* points back at its row. The list draws units; anchors and paging still speak seq.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
sealed class TranscriptUnit {
|
|
||||||
/** The list identity; must survive pages landing at either end. See [TranscriptRow.key]. */
|
|
||||||
abstract val key: Any
|
|
||||||
|
|
||||||
/** Where this unit's row starts in the transcript -- the anchor identity, never the key. */
|
|
||||||
abstract val seq: Long
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This unit's position within its row, counted from the row's oldest end.
|
|
||||||
*
|
|
||||||
* What a saved scroll position carries besides the seq: a reply split into forty blocks needs
|
|
||||||
* more than "somewhere in this row" to put a reader back where they stopped.
|
|
||||||
*/
|
|
||||||
abstract val ordinal: Int
|
|
||||||
|
|
||||||
/** The gap drawn above this unit -- between rows, or between blocks of one reply. */
|
|
||||||
abstract val gap: Dp
|
|
||||||
|
|
||||||
/** A row drawn as itself: a bubble, a tool card, a group -- or the reply still arriving. */
|
|
||||||
data class Whole(val row: TranscriptRow, override val gap: Dp) : TranscriptUnit() {
|
|
||||||
override val key: Any
|
|
||||||
get() = row.key
|
|
||||||
|
|
||||||
override val seq: Long
|
|
||||||
get() = row.startSeq
|
|
||||||
|
|
||||||
override val ordinal: Int
|
|
||||||
get() = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One [Piece] of a settled reply; [text] is the prose it is a piece of. */
|
|
||||||
data class Block(
|
|
||||||
override val seq: Long,
|
|
||||||
override val ordinal: Int,
|
|
||||||
val text: String,
|
|
||||||
val piece: Piece,
|
|
||||||
override val gap: Dp,
|
|
||||||
) : TranscriptUnit() {
|
|
||||||
override val key: Any
|
|
||||||
get() = "b$seq:$ordinal"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The heading of a message from another agent: who sent it, and the control that opens it.
|
|
||||||
*
|
|
||||||
* A peer message is the one row whose *opened* size is unbounded -- these are the longest
|
|
||||||
* things a transcript holds -- so it is flattened the same way a settled reply is, and for the
|
|
||||||
* same reason: as one item, every block of it is composed, measured, placed and kept alive
|
|
||||||
* while any part of it is on screen. Measured on the emulator, opening a 43KB one took the
|
|
||||||
* transcript's share of the draw phase from 0.81ms a frame to 3.85ms, and the framework's own
|
|
||||||
* per-frame bookkeeping -- which grows with how many nodes are *alive* -- from 0.39ms to
|
|
||||||
* 3.15ms.
|
|
||||||
*
|
|
||||||
* The card is drawn in pieces rather than given up: a filled Material card is elevation zero,
|
|
||||||
* so it has no shadow to break, and each piece paints the same fill with only the corners it
|
|
||||||
* owns. See [PeerHeadRow] and [PeerBlockRow].
|
|
||||||
*/
|
|
||||||
data class PeerHead(
|
|
||||||
override val seq: Long,
|
|
||||||
val item: TranscriptItem.PeerNote,
|
|
||||||
val open: Boolean,
|
|
||||||
override val gap: Dp,
|
|
||||||
) : TranscriptUnit() {
|
|
||||||
/**
|
|
||||||
* The note's own key, so opening and shutting does not change what the list is anchored on
|
|
||||||
* -- and so two notes stamped with one turn's seq are still two items. See
|
|
||||||
* [TranscriptItem.PeerNote].
|
|
||||||
*/
|
|
||||||
override val key: Any
|
|
||||||
get() = item.key
|
|
||||||
|
|
||||||
override val ordinal: Int
|
|
||||||
get() = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One [Piece] of an opened peer message; [last] is the piece that closes the card. Its [gap] is
|
|
||||||
* always zero -- the pieces are one card -- so the room between blocks is [spacing], drawn
|
|
||||||
* inside the piece where the card's fill covers it.
|
|
||||||
*/
|
|
||||||
data class PeerBlock(
|
|
||||||
override val seq: Long,
|
|
||||||
override val ordinal: Int,
|
|
||||||
val text: String,
|
|
||||||
val piece: Piece,
|
|
||||||
val last: Boolean,
|
|
||||||
val spacing: Dp,
|
|
||||||
override val gap: Dp,
|
|
||||||
/** The note this block belongs to; its key, not its seq. See [TranscriptItem.PeerNote]. */
|
|
||||||
val note: Any,
|
|
||||||
) : TranscriptUnit() {
|
|
||||||
override val key: Any
|
|
||||||
get() = "p$note:$ordinal"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One slice of a long user message; see [userChunks].
|
|
||||||
*
|
|
||||||
* A user message is plain text, so cutting it costs a scan rather than a parse -- but the
|
|
||||||
* reason is the same as for a settled reply: as one item, a pasted log is a hundred thousand
|
|
||||||
* pixels of `Text` whose layout lands in the frame the row scrolls into. Measured as the
|
|
||||||
* `measure: the whole transcript ... 112.1ms worst` in an otherwise smooth report.
|
|
||||||
*/
|
|
||||||
data class UserChunk(
|
|
||||||
override val seq: Long,
|
|
||||||
override val ordinal: Int,
|
|
||||||
val text: String,
|
|
||||||
val first: Boolean,
|
|
||||||
val last: Boolean,
|
|
||||||
/** The message's attachments, drawn under the words -- so only the last slice has any. */
|
|
||||||
val attachments: List<String>,
|
|
||||||
override val gap: Dp,
|
|
||||||
) : TranscriptUnit() {
|
|
||||||
override val key: Any
|
|
||||||
get() = "u$seq:$ordinal"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One memory note of a settled reply; see [MemoryNote]. */
|
|
||||||
data class Memory(
|
|
||||||
override val seq: Long,
|
|
||||||
override val ordinal: Int,
|
|
||||||
val part: MessagePart.Remembered,
|
|
||||||
override val gap: Dp,
|
|
||||||
) : TranscriptUnit() {
|
|
||||||
override val key: Any
|
|
||||||
get() = "m$seq:$ordinal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
|
|
||||||
* screen, which is what a reversed lazy list calls the start.
|
|
||||||
*
|
|
||||||
* Every settled reply is cut into its pieces ([pieces], via the caches on [replies] so a message is
|
|
||||||
* only ever cut once), and so is an *opened* peer message -- [openNotes] is which ones those are,
|
|
||||||
* which is why the flatten needs it. A shut one is a single heading and cannot be worth splitting.
|
|
||||||
* The reply still arriving -- the newest row, until the status event that ends its turn marks it
|
|
||||||
* [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, and
|
|
||||||
* splitting it here would parse the whole message per delta on whichever thread is composing.
|
|
||||||
* [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the
|
|
||||||
* live message a layer per piece. Once settled it splits like every other reply, which is what
|
|
||||||
* bounds the newest row's cost after a session ends on a long one.
|
|
||||||
*
|
|
||||||
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
|
|
||||||
* path: [ParsedReplies.partsOf] and [ParsedReplies.piecesOf] are lookups for any text [warm] has
|
|
||||||
* seen, and a miss -- the one message that just finished streaming -- costs its parse exactly once.
|
|
||||||
*/
|
|
||||||
fun transcriptUnits(
|
|
||||||
rows: List<TranscriptRow>,
|
|
||||||
replies: ParsedReplies,
|
|
||||||
openNotes: Set<Long>,
|
|
||||||
): List<TranscriptUnit> {
|
|
||||||
val started = System.nanoTime()
|
|
||||||
val units = ArrayList<TranscriptUnit>(rows.size)
|
|
||||||
rows.forEachIndexed { index, row ->
|
|
||||||
val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING
|
|
||||||
val item = (row as? TranscriptRow.Single)?.item
|
|
||||||
if (item is TranscriptItem.PeerNote) {
|
|
||||||
val open = item.seq in openNotes
|
|
||||||
units += TranscriptUnit.PeerHead(row.startSeq, item, open, rowGap)
|
|
||||||
// No gap between the pieces: they are one card, and a card with a stripe through it is
|
|
||||||
// what any spacing here would draw.
|
|
||||||
if (open) {
|
|
||||||
val pieces = replies.piecesOf(item.text)
|
|
||||||
var previous: Piece? = null
|
|
||||||
pieces.forEachIndexed { at, piece ->
|
|
||||||
units +=
|
|
||||||
TranscriptUnit.PeerBlock(
|
|
||||||
row.startSeq,
|
|
||||||
at + 1,
|
|
||||||
item.text,
|
|
||||||
piece,
|
|
||||||
last = at == pieces.lastIndex,
|
|
||||||
spacing = gapBefore(previous, piece),
|
|
||||||
gap = 0.dp,
|
|
||||||
note = item.key,
|
|
||||||
)
|
|
||||||
previous = piece
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
|
|
||||||
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like
|
|
||||||
// the markdown splits so the scan too happens once per message, not once per fold.
|
|
||||||
val chunks = replies.chunksOf(item.text)
|
|
||||||
chunks.forEachIndexed { at, chunk ->
|
|
||||||
units +=
|
|
||||||
TranscriptUnit.UserChunk(
|
|
||||||
row.startSeq,
|
|
||||||
at,
|
|
||||||
chunk,
|
|
||||||
first = at == 0,
|
|
||||||
last = at == chunks.lastIndex,
|
|
||||||
attachments = if (at == chunks.lastIndex) item.attachments else emptyList(),
|
|
||||||
gap = if (at == 0) rowGap else 0.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
item is TranscriptItem.AssistantMsg &&
|
|
||||||
splitWanted(item, index, rows.lastIndex) &&
|
|
||||||
replies.splitReady(item.text)
|
|
||||||
) {
|
|
||||||
var ordinal = 0
|
|
||||||
fun gap(within: Dp) = if (ordinal == 0) rowGap else within
|
|
||||||
replies.partsOf(item.text).forEach { part ->
|
|
||||||
when (part) {
|
|
||||||
is MessagePart.Prose -> {
|
|
||||||
var previous: Piece? = null
|
|
||||||
replies.piecesOf(part.text).forEach { piece ->
|
|
||||||
units +=
|
|
||||||
TranscriptUnit.Block(
|
|
||||||
row.startSeq,
|
|
||||||
ordinal,
|
|
||||||
part.text,
|
|
||||||
piece,
|
|
||||||
gap(gapBefore(previous, piece)),
|
|
||||||
)
|
|
||||||
ordinal++
|
|
||||||
previous = piece
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is MessagePart.Remembered -> {
|
|
||||||
units +=
|
|
||||||
TranscriptUnit.Memory(row.startSeq, ordinal, part, gap(BLOCK_SPACING))
|
|
||||||
ordinal++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
units += TranscriptUnit.Whole(row, rowGap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
units.reverse()
|
|
||||||
reportDuplicateKeys(units)
|
|
||||||
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy"
|
|
||||||
// is this number growing, and it was invisible until it was written down.
|
|
||||||
DebugStats.record("units flattened", System.nanoTime() - started)
|
|
||||||
return units
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this reply should be drawn as blocks: settled, or anywhere but the newest row.
|
|
||||||
*
|
|
||||||
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two
|
|
||||||
* questions are separate because they are answered by different things: this one by the fold, the
|
|
||||||
* other by whether [warm] has run for the text. [unwarmedReplies] is the gap between them.
|
|
||||||
*/
|
|
||||||
private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) =
|
|
||||||
item.settled || index != lastIndex
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The replies among [rows] that should draw as blocks but whose parses are not made yet.
|
|
||||||
*
|
|
||||||
* Normally empty: every page's rows are warmed before the fold lands. The one row that can be cold
|
|
||||||
* is the reply that just finished streaming -- nothing warms live deltas, so at the moment its turn
|
|
||||||
* ends its split would cost a whole-message parse on the composing thread. The session screen warms
|
|
||||||
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
|
|
||||||
* ready parses.
|
|
||||||
*/
|
|
||||||
fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<TranscriptItem> =
|
|
||||||
rows.mapIndexedNotNull { index, row ->
|
|
||||||
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
|
|
||||||
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Above this many characters, a user message is drawn in slices rather than as one bubble.
|
|
||||||
*
|
|
||||||
* Not zero, because a bubble's width wraps its content: slices have to fill the row to look like
|
|
||||||
* one bubble, and forcing that on a short message would visibly widen it. A message past this
|
|
||||||
* length has lines that wrap, so its bubble is at the full width already and the slices match it
|
|
||||||
* exactly. Below it, one item of at most a few screens is nothing the list minds composing.
|
|
||||||
*/
|
|
||||||
const val USER_SPLIT_CHARS = 4000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Roughly how much text one slice holds -- bounded, like a markdown block, is the whole point.
|
|
||||||
*
|
|
||||||
* About one viewport of wrapped text: a slice is composed whole in the frame it scrolls into, so
|
|
||||||
* its size is a frame-budget decision, and one screenful keeps that to a few milliseconds on the
|
|
||||||
* phone. Smaller buys nothing -- the seams are free -- but the units multiply.
|
|
||||||
*/
|
|
||||||
private const val USER_CHUNK_CHARS = 1000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A long user message cut at line starts into slices of roughly [USER_CHUNK_CHARS].
|
|
||||||
*
|
|
||||||
* At newlines only, never mid-line: text layout runs per line, so slices that own whole lines stack
|
|
||||||
* back into exactly the lines the single `Text` drew, and a cut inside one would reflow it. The
|
|
||||||
* newline at each cut is dropped -- the boundary between two stacked slices *is* that line break. A
|
|
||||||
* single line longer than a slice (minified JSON, a base64 blob) stays whole in its slice, so a
|
|
||||||
* slice is bounded by the longest line rather than absolutely.
|
|
||||||
*/
|
|
||||||
fun userChunks(text: String): List<String> {
|
|
||||||
val chunks = ArrayList<String>()
|
|
||||||
var start = 0
|
|
||||||
while (start < text.length) {
|
|
||||||
if (text.length - start <= USER_CHUNK_CHARS) {
|
|
||||||
chunks += text.substring(start)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
var cut = text.lastIndexOf('\n', start + USER_CHUNK_CHARS)
|
|
||||||
if (cut <= start) cut = text.indexOf('\n', start + USER_CHUNK_CHARS)
|
|
||||||
if (cut < 0) {
|
|
||||||
chunks += text.substring(start)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
chunks += text.substring(start, cut)
|
|
||||||
start = cut + 1
|
|
||||||
}
|
|
||||||
return chunks
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Says which two units share a key, before the list dies of it.
|
|
||||||
*
|
|
||||||
* A duplicate key is fatal -- `LazyColumn` throws, and the app goes down in the middle of somebody
|
|
||||||
* reading a conversation -- and all the framework's message carries is the key. When that key is a
|
|
||||||
* seq it names neither row, and there is no way back from it to how the two came to share one: it
|
|
||||||
* took an afternoon and a fixture that could reproduce it. Two lines here answered it immediately,
|
|
||||||
* naming both rows and the field they had in common ([TranscriptItem.PeerNote.arrived]).
|
|
||||||
*
|
|
||||||
* Always on, for the same reason [DebugStats] is: an instrument that is only in the build nobody is
|
|
||||||
* holding when it breaks is not an instrument. It costs one map over the units that were just
|
|
||||||
* built, beside a loop that already allocates one entry per unit.
|
|
||||||
*/
|
|
||||||
private fun reportDuplicateKeys(units: List<TranscriptUnit>) {
|
|
||||||
val seen = HashMap<Any, TranscriptUnit>()
|
|
||||||
units.forEach { unit ->
|
|
||||||
val had = seen.put(unit.key, unit)
|
|
||||||
if (had != null) {
|
|
||||||
android.util.Log.w("ai-app", "duplicate unit key ${unit.key}: $had AND $unit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What is on screen right now, a unit at a time: what each one is and how tall it is.
|
|
||||||
*
|
|
||||||
* For the render report, and it is the line every "it is slow here" report has needed. The
|
|
||||||
* framework's own per-frame cost grows with how many nodes are *alive* rather than how many are on
|
|
||||||
* screen, so a screen holding one enormous item is slow in a way that no counter of ours
|
|
||||||
* distinguishes from a screen holding twenty ordinary ones -- and "2 units visible" says one of
|
|
||||||
* them is enormous without saying which. This says which.
|
|
||||||
*
|
|
||||||
* [first] is the index the list gave the first *unit*: the list also holds the waiting-messages
|
|
||||||
* slot at index zero and the history spinner past the end, and both are named here rather than
|
|
||||||
* silently reported as whichever unit is nearest.
|
|
||||||
*/
|
|
||||||
fun visibleUnits(units: List<TranscriptUnit>, visible: List<LazyListItemInfo>, first: Int): String =
|
|
||||||
if (visible.isEmpty()) " nothing on screen"
|
|
||||||
else
|
|
||||||
" on screen: " +
|
|
||||||
visible.joinToString(", ") { info ->
|
|
||||||
"${units.getOrNull(info.index - first).kind} ${info.size}px"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a unit is, in a word, for [visibleUnits]. Null is one of the list's own non-unit items. */
|
|
||||||
private val TranscriptUnit?.kind: String
|
|
||||||
get() =
|
|
||||||
when (this) {
|
|
||||||
null -> "the list's own"
|
|
||||||
is TranscriptUnit.Block ->
|
|
||||||
if (piece.item == Piece.WHOLE_BLOCK) "reply block" else "list item"
|
|
||||||
is TranscriptUnit.PeerHead -> if (open) "peer heading (open)" else "peer heading"
|
|
||||||
is TranscriptUnit.PeerBlock -> "peer block"
|
|
||||||
is TranscriptUnit.UserChunk -> "user slice"
|
|
||||||
is TranscriptUnit.Memory -> "memory note"
|
|
||||||
is TranscriptUnit.Whole ->
|
|
||||||
when (val row = row) {
|
|
||||||
is TranscriptRow.Tools -> "tool group"
|
|
||||||
// The class name rather than a word per kind: this is a diagnostic, and a
|
|
||||||
// `when` here would be one more place that has to gain a case whenever the
|
|
||||||
// transcript does -- silently naming a new row after an old one until somebody
|
|
||||||
// noticed.
|
|
||||||
is TranscriptRow.Single -> row.item::class.simpleName.orEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the unit named by a saved position sits in [units], or null if its row is not loaded.
|
|
||||||
*
|
|
||||||
* The row is found by [seq] and the unit within it by [ordinal], settling for the nearest older
|
|
||||||
* unit when the exact one is gone -- a reply regrouped by a page boundary can split into a
|
|
||||||
* different number of blocks than it had when the position was saved, and "a little above where
|
|
||||||
* they stopped" loses less than the newest end does.
|
|
||||||
*/
|
|
||||||
fun unitIndexFor(units: List<TranscriptUnit>, seq: Long, ordinal: Int): Int? {
|
|
||||||
var best: Int? = null
|
|
||||||
var bestOrdinal = -1
|
|
||||||
units.forEachIndexed { index, unit ->
|
|
||||||
if (unit.seq == seq && unit.ordinal <= ordinal && unit.ordinal > bestOrdinal) {
|
|
||||||
best = index
|
|
||||||
bestOrdinal = unit.ordinal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best ?: units.indexOfFirst { it.seq == seq }.takeIf { it >= 0 }
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyItemScope
|
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keyed [items], with anything repeating a key already used left out.
|
|
||||||
*
|
|
||||||
* A lazy list throws when two of its items claim the same key, and the throw happens during measure
|
|
||||||
* on the main thread -- so it is not an error the screen can show, it closes the app. That is a
|
|
||||||
* disproportionate answer to a list with a repeat in it, and it lands on the reader rather than on
|
|
||||||
* whoever produced the repeat: on 2026-08-31 the import list crashed on a Claude Code session id
|
|
||||||
* recorded under two project directories, which is an ordinary state of a machine and not something
|
|
||||||
* the phone did.
|
|
||||||
*
|
|
||||||
* Every list in this app keyed on an id keyed it on an id *the server chose*, so all of them shared
|
|
||||||
* the hazard and none of them could rule it out locally. Hence one function they all go through
|
|
||||||
* rather than a `distinctBy` remembered at each call site.
|
|
||||||
*
|
|
||||||
* Dropping the repeat is the right answer here because the key is the whole identity: two rows with
|
|
||||||
* one id are two rows every action would treat as the same thing, so there is nothing to show about
|
|
||||||
* the second that the first is not already showing. Where the duplicate means something -- the
|
|
||||||
* import list's did -- the fix belongs at the source, and this is only what stops a data problem
|
|
||||||
* from being a crash. It is counted so the render report says it happened rather than leaving a
|
|
||||||
* silently shorter list.
|
|
||||||
*
|
|
||||||
* The transcript's own list is deliberately not on this: its keys are made here rather than
|
|
||||||
* received, and it is the one list where an extra pass over the items is measurable.
|
|
||||||
*/
|
|
||||||
inline fun <T> LazyListScope.uniqueItems(
|
|
||||||
items: List<T>,
|
|
||||||
crossinline key: (T) -> Any,
|
|
||||||
noinline contentType: (T) -> Any? = { null },
|
|
||||||
crossinline itemContent: @Composable LazyItemScope.(T) -> Unit,
|
|
||||||
) {
|
|
||||||
val seen = HashSet<Any>(items.size)
|
|
||||||
val unique = items.filter { seen.add(key(it)) }
|
|
||||||
if (unique.size != items.size) {
|
|
||||||
DebugStats.count("list items dropped for a repeated key")
|
|
||||||
}
|
|
||||||
items(unique, key = { key(it) }, contentType = contentType) { itemContent(it) }
|
|
||||||
}
|
|
||||||
@@ -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.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Window bars for the account's rate limits, with reset times.
|
|
||||||
*
|
|
||||||
* A dialog rather than a screen. Usage is something you check *against* what you were reading --
|
|
||||||
* "can I start this" is asked with the transcript still on screen -- and pushing a whole screen for
|
|
||||||
* it took the session away to answer a question about the session. It also has no navigation of its
|
|
||||||
* own: there is nothing here to open, so the only thing its Back could ever have meant was "put
|
|
||||||
* this away", which is what dismissing does. The system back gesture dismisses it, since a `Dialog`
|
|
||||||
* handles that itself.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
|
||||||
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the
|
|
||||||
// gaps between its title, its content and its buttons at sizes meant for a sentence of prose
|
|
||||||
// and a decision; this is a dense read-out, and those gaps left a band of empty dialog above
|
|
||||||
// Close that was taller than a bar. Everything else here is what AlertDialog would have
|
|
||||||
// drawn -- the same container colour, the same corner -- so nothing about it looks foreign.
|
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
|
||||||
Surface(
|
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
// Deliberately not subtitled with the provider this was opened from. These
|
|
||||||
// numbers belong to an account on a particular machine, reported by whichever
|
|
||||||
// paid service answered there -- naming the session's provider here made an
|
|
||||||
// echo session's screen read "echo" above a line reading "claude", which is a
|
|
||||||
// claim about echo that nothing measured. Each machine names itself and the
|
|
||||||
// service it came from, which is the true scope.
|
|
||||||
Text(
|
|
||||||
"Usage",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
// A spinner in the button's place while the answer is on its way, since the
|
|
||||||
// numbers under it stay put during a refresh -- without it, pressing refresh
|
|
||||||
// over an unchanged read-out looks like a button that does nothing.
|
|
||||||
if (feed.refreshing) {
|
|
||||||
GlyphSpinner("Refreshing usage")
|
|
||||||
} else {
|
|
||||||
GlyphButton(REFRESH_GLYPH, "Refresh usage", feed.refresh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
// Scrolls rather than being trimmed: a machine can report any number of windows
|
|
||||||
// and there can be any number of machines, and a dialog is the one place where
|
|
||||||
// running out of room is silent. `fill = false` so a short read-out keeps a short
|
|
||||||
// dialog instead of stretching to the window.
|
|
||||||
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
|
|
||||||
UsageBody(feed.snapshots)
|
|
||||||
}
|
|
||||||
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
|
||||||
Text("Close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
|
|
||||||
@Composable
|
|
||||||
private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
|
||||||
Column {
|
|
||||||
when (val current = state) {
|
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
|
||||||
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
|
||||||
is LoadState.Loaded ->
|
|
||||||
if (current.value.isEmpty()) {
|
|
||||||
// Not an error and not a blank screen: no machine offers a paid service,
|
|
||||||
// so there is genuinely nothing to report and saying so is the answer.
|
|
||||||
Text(
|
|
||||||
"No machine here runs anything with usage limits.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// No card around each machine. A card is a step up the surface ladder, and
|
|
||||||
// inside a dialog -- itself a raised surface -- the step barely renders while
|
|
||||||
// costing 16dp of padding on every side. What separates one machine from the
|
|
||||||
// next is the line naming it, which is enough for a list this short.
|
|
||||||
current.value.forEachIndexed { index, snapshot ->
|
|
||||||
if (index > 0) {
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
}
|
|
||||||
// Machine and service on one line: which account these numbers belong to
|
|
||||||
// is decided by both together, and stacked as a heading over a subtitle
|
|
||||||
// they read as a section of their own rather than as the label they are.
|
|
||||||
// Small and quiet, because the numbers below are what somebody opened
|
|
||||||
// this to see.
|
|
||||||
Text(
|
|
||||||
"${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
SnapshotState(snapshot)
|
|
||||||
snapshot.windows.forEachIndexed { windowIndex, window ->
|
|
||||||
// Between the bars, not after the last one: a trailing gap here is
|
|
||||||
// what put a band of empty dialog above the Close button.
|
|
||||||
if (windowIndex > 0) {
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
}
|
|
||||||
WindowBar(window)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Anything other than numbers: why this machine has none.
|
|
||||||
*
|
|
||||||
* The distinction the old single message could not draw. A machine nobody has logged in on is
|
|
||||||
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
|
|
||||||
* the interface nagging about a decision already made, and would dilute the marks that do mean
|
|
||||||
* something. Only the two faults are coloured as faults.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun SnapshotState(snapshot: UsageSnapshot) {
|
|
||||||
when (snapshot.state) {
|
|
||||||
"ok" -> {}
|
|
||||||
"notLoggedIn" ->
|
|
||||||
Text(
|
|
||||||
"No Claude account on this machine.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
// Reached but refused, versus never reached at all: different things to go and do,
|
|
||||||
// so they say different things rather than sharing one "unavailable".
|
|
||||||
"failed" ->
|
|
||||||
Text(
|
|
||||||
snapshot.detail ?: "Couldn't read the limits from this machine.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = failedColor,
|
|
||||||
)
|
|
||||||
else ->
|
|
||||||
Text(
|
|
||||||
snapshot.detail ?: "Couldn't reach this machine.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = failedColor,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun WindowBar(window: UsageWindow) {
|
|
||||||
Column {
|
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
window.label + if (window.active) " (active)" else "",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
|
||||||
color = quotaColor(window.percent),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
resetLine(window)?.let {
|
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing.
|
|
||||||
*
|
|
||||||
* Null for a window that is not running, which is the case this row has always drawn as nothing and
|
|
||||||
* is right to: there is no end to report. What it used to get wrong is the other missing case, a
|
|
||||||
* timestamp that arrived and could not be read: that was printed raw, so a parse failure appeared
|
|
||||||
* as an ISO string in the middle of a sentence written for a person. Both cases are named in
|
|
||||||
* [WindowEnd], and the session bar words them the same way.
|
|
||||||
*/
|
|
||||||
private fun resetLine(window: UsageWindow): String? =
|
|
||||||
when (val end = windowEnd(window.resetsAt, OffsetDateTime.now())) {
|
|
||||||
WindowEnd.NotRunning -> null
|
|
||||||
WindowEnd.Unreadable -> "reset time unreadable"
|
|
||||||
is WindowEnd.Ends ->
|
|
||||||
if (end.until.isNegative) "resets soon" else "resets in ${formatSpan(end.until)}"
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,87 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What survives a terminal's escape sequences, and what the styling reads as.
|
|
||||||
*
|
|
||||||
* Asserted as the plain text and as the style over a named substring, rather than as span offsets,
|
|
||||||
* so a failure prints the output that was got wrong instead of a pair of numbers.
|
|
||||||
*/
|
|
||||||
class AnsiTest {
|
|
||||||
private val palette =
|
|
||||||
AnsiPalette(
|
|
||||||
colours = (0..15).map { Color(it, 0, 0) },
|
|
||||||
foreground = Color(1f, 1f, 1f),
|
|
||||||
background = Color(0f, 0f, 0f),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun styled(text: String) = ansiStyled(text, palette)
|
|
||||||
|
|
||||||
/** The style covering the first character of [word], or null where nothing styles it. */
|
|
||||||
private fun styleOver(text: String, word: String) =
|
|
||||||
styled(text).let { annotated ->
|
|
||||||
val at = annotated.text.indexOf(word)
|
|
||||||
assertTrue(at >= 0, "no \"$word\" in ${annotated.text}")
|
|
||||||
annotated.spanStyles.firstOrNull { at >= it.start && at < it.end }?.item
|
|
||||||
}
|
|
||||||
|
|
||||||
private val esc = '\u001B'
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a colour becomes a span and the sequence itself disappears`() {
|
|
||||||
val text = "plain ${esc}[31mred${esc}[0m plain"
|
|
||||||
assertEquals("plain red plain", styled(text).text)
|
|
||||||
assertEquals(palette.colours[1], styleOver(text, "red")?.color)
|
|
||||||
assertNull(styleOver(text, "plain"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `bright, background and 256-colour forms all reach the same table`() {
|
|
||||||
assertEquals(palette.colours[9], styleOver("${esc}[91mx", "x")?.color)
|
|
||||||
assertEquals(palette.colours[4], styleOver("${esc}[44mx", "x")?.background)
|
|
||||||
// The first sixteen of the 256-colour table are the palette's own, so a program that
|
|
||||||
// spells a colour either way gets the same one.
|
|
||||||
assertEquals(palette.colours[1], styleOver("${esc}[38;5;1mx", "x")?.color)
|
|
||||||
// And past them, xterm's cube: 16 is its black corner, 231 its white one.
|
|
||||||
assertEquals(Color(0, 0, 0), styleOver("${esc}[38;5;16mx", "x")?.color)
|
|
||||||
assertEquals(Color(255, 255, 255), styleOver("${esc}[38;5;231mx", "x")?.color)
|
|
||||||
assertEquals(Color(10, 20, 30), styleOver("${esc}[38;2;10;20;30mx", "x")?.color)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `everything that is not styling is dropped rather than printed`() {
|
|
||||||
// A cursor move, an erase, an OSC window title with its bell, and a bare two-character
|
|
||||||
// escape. None of them mean anything in a scrolling document, and all of them would be
|
|
||||||
// line noise if the escape alone were stripped and the body left behind.
|
|
||||||
val text = "a${esc}[2Jb${esc}[Kc${esc}]0;a titled${esc}=e"
|
|
||||||
assertEquals("abcde", styled(text).text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a carriage return rewrites its line, as it does on a terminal`() {
|
|
||||||
// What a progress bar looks like: every state it passed through, ending on the last.
|
|
||||||
assertEquals("done\n", styled("10%\r50%\rdone\n").text)
|
|
||||||
// The line before it is untouched. A Windows line ending rewrites nothing and is not
|
|
||||||
// kept either: it is one line break, and passing the carriage return through would draw
|
|
||||||
// a stray control character in the middle of the output.
|
|
||||||
assertEquals("kept\nlast", styled("kept\r\nfirst\rlast").text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a sequence cut off mid-stream takes no text with it`() {
|
|
||||||
// Output still arriving ends anywhere, including inside an escape. The fragment goes and
|
|
||||||
// the whole sequence arrives with the next delta.
|
|
||||||
assertEquals("text ", styled("text ${esc}[3").text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `unstyled text costs no spans at all`() {
|
|
||||||
assertEquals(0, styled("nothing to do here").spanStyles.size)
|
|
||||||
assertEquals(0, styled("a${esc}[2Jb").spanStyles.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import java.time.Duration
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The two ways a span of time is written here, and the rule each of them follows.
|
|
||||||
*
|
|
||||||
* Both are read off a screen to make a decision -- how long a tool call may take, how long a quota
|
|
||||||
* has left -- so what matters is that the shortest form that answers the question is what appears.
|
|
||||||
*/
|
|
||||||
class DurationsTest {
|
|
||||||
@Test
|
|
||||||
fun `under a minute is the largest unit alone`() {
|
|
||||||
assertEquals("30ms", formatMillis(30))
|
|
||||||
assertEquals("999ms", formatMillis(999))
|
|
||||||
assertEquals("1s", formatMillis(1000))
|
|
||||||
assertEquals("2.5s", formatMillis(2500))
|
|
||||||
// One decimal, rounded rather than cut: 2.46s is nearer two and a half than two and four.
|
|
||||||
assertEquals("2.5s", formatMillis(2460))
|
|
||||||
assertEquals("59.9s", formatMillis(59_900))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a minute or more is every unit that has something in it`() {
|
|
||||||
// The figure this rule was written for: a tool timeout, which arrives as milliseconds and
|
|
||||||
// is unreadable as 480000.
|
|
||||||
assertEquals("8m", formatMillis(480_000))
|
|
||||||
assertEquals("1m", formatMillis(60_000))
|
|
||||||
assertEquals("1m 30s", formatMillis(90_000))
|
|
||||||
assertEquals("5d 12h 4m", formatMillis(475_440_000))
|
|
||||||
// Empty units are left out rather than written as zero: the labels say which is which,
|
|
||||||
// and "5d 0h 4m" is only longer.
|
|
||||||
assertEquals("5d 4m", formatMillis(432_240_000))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `only a whole number of milliseconds is rewritten`() {
|
|
||||||
assertEquals("8m", formatMillisText(" 480000 "))
|
|
||||||
// A timeout a tool expressed some other way is its own words, passed through rather than
|
|
||||||
// guessed at.
|
|
||||||
assertEquals("2 minutes", formatMillisText("2 minutes"))
|
|
||||||
assertEquals("", formatMillisText(""))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a countdown rounds up, so it never reports a minute already spent`() {
|
|
||||||
assertEquals("3h 13m", formatSpan(Duration.ofMinutes(192).plusSeconds(50)))
|
|
||||||
// Exactly on a minute is already the answer and is not pushed past it.
|
|
||||||
assertEquals("3h 12m", formatSpan(Duration.ofMinutes(192)))
|
|
||||||
assertEquals("12m", formatSpan(Duration.ofMinutes(12)))
|
|
||||||
// Rounding up carries, so a day's worth of minutes reads as a day.
|
|
||||||
assertEquals("1d 0h", formatSpan(Duration.ofHours(23).plusMinutes(59).plusSeconds(30)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the scanner colours, asserted as the text under each span rather than as offsets, so a
|
|
||||||
* failure prints the code that was got wrong instead of a pair of numbers.
|
|
||||||
*
|
|
||||||
* Most of these are the mistakes dev.snipme:highlights 1.1.0 made -- the library this scanner
|
|
||||||
* replaced -- measured against it directly before it was removed. They are here rather than in the
|
|
||||||
* `highlights-repro.sh` script they came from because a case that only a script can ask about is a
|
|
||||||
* case nobody asks about.
|
|
||||||
*/
|
|
||||||
class HighlighterTest {
|
|
||||||
/** Every span of [kind] in [code], as the text it covers. */
|
|
||||||
private fun spans(code: String, language: Language, kind: Kind): List<String> =
|
|
||||||
scan(code, rulesOf(language))
|
|
||||||
.filter { it.kind == kind }
|
|
||||||
.map { code.substring(it.start, it.end) }
|
|
||||||
|
|
||||||
private fun assertSpans(
|
|
||||||
code: String,
|
|
||||||
language: Language,
|
|
||||||
kind: Kind,
|
|
||||||
vararg expected: String,
|
|
||||||
) {
|
|
||||||
assertEquals(expected.toList(), spans(code, language, kind), "$kind in: $code")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The four inputs the repro script asked the library about, and what it answered.
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a quoted glob is one string, not a comment`() {
|
|
||||||
// The library answered a span whose end preceded its start here, which crashed the app.
|
|
||||||
assertSpans("x '*/a/*'", Language.SHELL, Kind.STRING, "'*/a/*'")
|
|
||||||
assertSpans("x '*/a/*'", Language.SHELL, Kind.COMMENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a find with globs has no comment in it`() {
|
|
||||||
val code = "find . -path '*/.git/*' -prune -o -name '*.kt' -print"
|
|
||||||
assertSpans(code, Language.SHELL, Kind.STRING, "'*/.git/*'", "'*.kt'")
|
|
||||||
assertSpans(code, Language.SHELL, Kind.COMMENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a URL does not comment out the rest of a shell line`() {
|
|
||||||
val code = "curl https://example.com/x && echo done"
|
|
||||||
assertSpans(code, Language.SHELL, Kind.COMMENT)
|
|
||||||
assertSpans(code, Language.SHELL, Kind.KEYWORD, "echo")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a URL inside a Kotlin string stays a string`() {
|
|
||||||
val code = "val url = \"https://example.com\"\nfun f() = 1"
|
|
||||||
assertSpans(code, Language.KOTLIN, Kind.COMMENT)
|
|
||||||
assertSpans(code, Language.KOTLIN, Kind.STRING, "\"https://example.com\"")
|
|
||||||
assertSpans(code, Language.KOTLIN, Kind.KEYWORD, "val", "fun")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attributes, which the library greyed out as comments.
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a Rust attribute is metadata and the struct after it still colours`() {
|
|
||||||
val code = "#[derive(Debug)]\nstruct A { b: u8 }"
|
|
||||||
assertSpans(code, Language.RUST, Kind.METADATA, "#[derive(Debug)]")
|
|
||||||
assertSpans(code, Language.RUST, Kind.COMMENT)
|
|
||||||
assertSpans(code, Language.RUST, Kind.KEYWORD, "struct")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an inner Rust attribute closes at its own bracket`() {
|
|
||||||
val code = "#![allow(dead_code)]\nfn f() {}"
|
|
||||||
assertSpans(code, Language.RUST, Kind.METADATA, "#![allow(dead_code)]")
|
|
||||||
assertSpans(code, Language.RUST, Kind.KEYWORD, "fn")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a C preprocessor line is metadata rather than a comment`() {
|
|
||||||
val code = "#include <stdio.h>\nint main() { return 0; }"
|
|
||||||
assertSpans(code, Language.C, Kind.METADATA, "#include <stdio.h>")
|
|
||||||
assertSpans(code, Language.C, Kind.COMMENT)
|
|
||||||
assertSpans(code, Language.C, Kind.KEYWORD, "int", "return")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a Kotlin annotation is metadata`() {
|
|
||||||
assertSpans("@Composable fun f() {}", Language.KOTLIN, Kind.METADATA, "@Composable")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strings whose contents the library read as code.
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a hash inside a Kotlin string is not a comment`() {
|
|
||||||
val code = "val c = \"#FF0000\"\nval d = 1"
|
|
||||||
assertSpans(code, Language.KOTLIN, Kind.COMMENT)
|
|
||||||
assertSpans(code, Language.KOTLIN, Kind.STRING, "\"#FF0000\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an apostrophe inside a Kotlin string does not open one`() {
|
|
||||||
val code = "val a = \"don't\"\nval b = \"x\""
|
|
||||||
assertSpans(code, Language.KOTLIN, Kind.STRING, "\"don't\"", "\"x\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a Rust lifetime does not open a string but a character literal does`() {
|
|
||||||
val code = "fn f<'a>(x: &'a str) { let c = 'x'; }"
|
|
||||||
assertSpans(code, Language.RUST, Kind.STRING, "'x'")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an escaped quote is inside the Rust character literal`() {
|
|
||||||
assertSpans("let c = '\\'';", Language.RUST, Kind.STRING, "'\\''")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a Rust raw string keeps its inner quotes`() {
|
|
||||||
val code = "let s = r#\"a \"quoted\" b\"#;"
|
|
||||||
assertSpans(code, Language.RUST, Kind.STRING, "r#\"a \"quoted\" b\"#")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a Kotlin triple quoted string is one string`() {
|
|
||||||
assertSpans(
|
|
||||||
"val s = \"\"\"a \"b\" c\"\"\"",
|
|
||||||
Language.KOTLIN,
|
|
||||||
Kind.STRING,
|
|
||||||
"\"\"\"a \"b\" c\"\"\"",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a shell single quoted string takes no escapes`() {
|
|
||||||
// `\` is literal inside shell single quotes, so the string ends at the next apostrophe.
|
|
||||||
assertSpans("echo 'a\\' b", Language.SHELL, Kind.STRING, "'a\\'")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comments.
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `Rust and Kotlin nest block comments`() {
|
|
||||||
val code = "/* a /* b */ c */ x"
|
|
||||||
assertSpans(code, Language.RUST, Kind.COMMENT, "/* a /* b */ c */")
|
|
||||||
assertSpans(code, Language.KOTLIN, Kind.COMMENT, "/* a /* b */ c */")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `C ends a block comment at the first close`() {
|
|
||||||
assertSpans("/* a /* b */ c */ x", Language.C, Kind.COMMENT, "/* a /* b */")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a shell comment starts only at a word boundary`() {
|
|
||||||
val code = "\${#x} \$# a#b # real"
|
|
||||||
assertSpans(code, Language.SHELL, Kind.COMMENT, "# real")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a hash anywhere is a Python comment`() {
|
|
||||||
assertSpans("x = 1 # note", Language.PYTHON, Kind.COMMENT, "# note")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TOML, which the library has no rules for at all.
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a TOML table header is metadata and a hash in a value is not a comment`() {
|
|
||||||
val code = "[server]\ncolour = \"#FF0000\"\nport = 8080 # the real one"
|
|
||||||
assertSpans(code, Language.TOML, Kind.METADATA, "[server]")
|
|
||||||
assertSpans(code, Language.TOML, Kind.STRING, "\"#FF0000\"")
|
|
||||||
assertSpans(code, Language.TOML, Kind.COMMENT, "# the real one")
|
|
||||||
assertSpans(code, Language.TOML, Kind.LITERAL, "8080")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a RON attribute and its values colour`() {
|
|
||||||
val code = "#![enable(implicit_some)]\n(count: 3, on: true)"
|
|
||||||
assertSpans(code, Language.RON, Kind.METADATA, "#![enable(implicit_some)]")
|
|
||||||
assertSpans(code, Language.RON, Kind.KEYWORD, "true")
|
|
||||||
assertSpans(code, Language.RON, Kind.LITERAL, "3")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an unknown fence language is drawn plain`() {
|
|
||||||
assertEquals(null, fenceLanguage("brainfuck"))
|
|
||||||
assertEquals("+[-]", highlight("+[-]", fenceLanguage("brainfuck")).text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `every alias the fence table knows has rules`() {
|
|
||||||
Language.entries.forEach { rulesOf(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The scanner must never throw and must never answer a span the code does not contain: the
|
|
||||||
* library's reversed range is exactly the shape that crashed a card, and a fence still being
|
|
||||||
* written is an unterminated string or comment on every keystroke.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun `spans stay inside the code for every language and every nasty input`() {
|
|
||||||
val nasty =
|
|
||||||
listOf(
|
|
||||||
"",
|
|
||||||
"'",
|
|
||||||
"\"",
|
|
||||||
"\"unterminated",
|
|
||||||
"/* unterminated",
|
|
||||||
"###",
|
|
||||||
"#",
|
|
||||||
"#![",
|
|
||||||
"[",
|
|
||||||
"r#\"",
|
|
||||||
"\\",
|
|
||||||
"'''",
|
|
||||||
"\"\"\"",
|
|
||||||
"0x",
|
|
||||||
"1.2.3",
|
|
||||||
"a#b//c/*d*/'e\"f",
|
|
||||||
"\n\n \n",
|
|
||||||
)
|
|
||||||
for (language in Language.entries) {
|
|
||||||
for (code in nasty) {
|
|
||||||
val spans = scan(code, rulesOf(language))
|
|
||||||
spans.forEach {
|
|
||||||
assertTrue(
|
|
||||||
it.start in 0..it.end && it.end <= code.length,
|
|
||||||
"$language answered $it for ${code.replace("\n", "\\n")}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
assertEquals(
|
|
||||||
spans.sortedBy { it.start },
|
|
||||||
spans,
|
|
||||||
"$language answered spans out of order for ${code.replace("\n", "\\n")}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2014 Ryan L McIntyre
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
Binary file not shown.
@@ -0,0 +1,44 @@
|
|||||||
|
# The P0 benchmark fixture
|
||||||
|
|
||||||
|
`transcript.jsonl` is a synthetic transcript in the app's event model (the JSON lines
|
||||||
|
`GET /sessions/{id}/transcript` returns; see `event-model` and
|
||||||
|
`server/src/session/driver.rs`) -- never a real one. The `bench` build opens it without a
|
||||||
|
server so renderer measurements use deterministic data.
|
||||||
|
|
||||||
|
Generated by `./generate.py` (Python stdlib only, seeded -- `SEED = 20260905` -- so re-running it
|
||||||
|
reproduces the same file byte for byte). It writes into `assets/`; only those generated assets are
|
||||||
|
embedded in the benchmark APK.
|
||||||
|
|
||||||
|
- `transcript.jsonl` -- 3,603 events. The first 3,202 (`BACKLOG_COUNT`) are the scrolled-back
|
||||||
|
history the benchmark opens with: user turns, tool calls with kilobyte-scale input/output,
|
||||||
|
assistant replies built from headings, bold/italic/inline code, a link, fenced code blocks that
|
||||||
|
rotate through rust/kotlin/python/sh/json/toml, a markdown table, two embedded images, and
|
||||||
|
periodic `usageDelta`/`compacted` events. The remaining 400 (`STREAM_COUNT`) are not part of the
|
||||||
|
opening window -- both bench harnesses replay them at a fixed rate (20/s) through the same live
|
||||||
|
fold path a real SSE reply arrives on, which is P0's "streaming phase."
|
||||||
|
|
||||||
|
**The streamed reply has a blank line every few deltas** (2026-09-09), so the markdown block a
|
||||||
|
delta lands in stays the size a real reply's blocks are -- 53 blocks, longest 502 characters,
|
||||||
|
against a measured p50 of 147 and a largest-ever 1,580 over 7,706 blocks of real assistant
|
||||||
|
messages. It used to be one run-on 14,888-character block, and since a row re-shapes the block a
|
||||||
|
delta lands in, every delta re-shaped all of it: quadratic in the reply's length, and 9.5ms of
|
||||||
|
frame time on a phone spent on a shape that does not occur. docs/RUST.md's "Incremental text"
|
||||||
|
has the measurements.
|
||||||
|
|
||||||
|
**The run-on message is kept**, as the first two events of the backlog: 14,824 characters in a
|
||||||
|
single block, just under `text_cap`'s 16 KiB `MESSAGE_BYTES` so it draws in full rather than
|
||||||
|
behind a "Show all". It is deliberately *not* streamed -- the repeated-reshape pathology needs a
|
||||||
|
growing block, and that lives in the UI profiling rig where it can be iterated on
|
||||||
|
in a second rather than in a two-minute phone run. It is emitted with the random state saved and
|
||||||
|
restored around it, so adding it left every other backlog event byte-identical; that is what
|
||||||
|
keeps `phone_screen.rs`'s recorded gestures landing on the content they were recorded against.
|
||||||
|
- `bench1.png`, `bench2.png` -- tiny (8x8) flat-colour PNGs, base64-free on disk but served the
|
||||||
|
same way a real attachment is (`GET /sessions/{id}/files/{name}`), referenced by the two
|
||||||
|
`"type":"image"` events in the transcript.
|
||||||
|
|
||||||
|
`BACKLOG_COUNT` lives here, in `generate.py`, and in `app/src/ui/fixture.rs`; change all three
|
||||||
|
together. The split is by line index.
|
||||||
|
|
||||||
|
Regenerate after changing the shape (a new event type, a different backlog/stream split) with
|
||||||
|
`./generate.py`, and commit the result -- it is checked in rather than generated at build time so
|
||||||
|
the benchmark embeds identical bytes without needing this script at build time.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 74 B |
Binary file not shown.
|
After Width: | Height: | Size: 74 B |
File diff suppressed because it is too large.
Load diff
Executable
+247
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generates transcript.jsonl -- the synthetic fixture the benchmark opens.
|
||||||
|
|
||||||
|
Deterministic (fixed seed), so runs draw byte-identical content.
|
||||||
|
|
||||||
|
Never a real transcript -- see AGENTS.md's ui-sandbox.sh, which this borrows its vocabulary
|
||||||
|
style from (headings, code fences, a table, a link) rather than reusing its Claude-Code JSONL
|
||||||
|
shape. This file's shape is the *app's own event model* instead: one JSON object per line,
|
||||||
|
matching what GET /sessions/{id}/transcript returns
|
||||||
|
(server/src/session/driver.rs is the source of truth for the field names).
|
||||||
|
|
||||||
|
./generate.py writes transcript.jsonl and bench1.png/bench2.png here
|
||||||
|
|
||||||
|
BACKLOG_COUNT events (seq 1..BACKLOG_COUNT) are the scrolled-back history the benchmark opens
|
||||||
|
with. A further STREAM_COUNT events (seq BACKLOG_COUNT+1..) are not part of the opening window;
|
||||||
|
both bench harnesses replay them at a fixed rate as the "streaming reply" phase, appended through
|
||||||
|
the same live path a real SSE reply arrives on. Keeping both halves in one file means one
|
||||||
|
generator and one seed to keep in sync, rather than two fixtures that can drift apart.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SEED = 20260905
|
||||||
|
# The stress message below, two events, added to the 3,200 the backlog used to be. Both apps
|
||||||
|
# hardcode this to split the file by line index (`fixture.rs`'s `BACKLOG_COUNT`,
|
||||||
|
# `BenchFixture.kt`'s), so it moves in three places or none.
|
||||||
|
STRESS_EVENTS = 2
|
||||||
|
BACKLOG_COUNT = 3200 + STRESS_EVENTS
|
||||||
|
STREAM_COUNT = 400
|
||||||
|
|
||||||
|
# How many deltas fold into one markdown block of the streamed reply.
|
||||||
|
#
|
||||||
|
# **The blank line between them is the point** (2026-09-09). Until this run the streaming tail
|
||||||
|
# appended `paragraph(5) + " "` STREAM_COUNT times with no blank line anywhere, so all 400 deltas
|
||||||
|
# folded into a *single* 14,888-character block -- and a row re-shapes the block a delta lands in
|
||||||
|
# (`RowBlocks::apply_delta`), so every delta re-shaped the whole thing. That is quadratic in the
|
||||||
|
# reply's length, and it put 9.5ms of frame time on Iris's phone into a shape that does not occur:
|
||||||
|
# measured over 7,706 top-level blocks from 3,675 real assistant messages, block length is p50 147
|
||||||
|
# characters, p90 449, p99 836, largest 1,580, nothing above 4,000. `paragraph(5)` is ~35
|
||||||
|
# characters, so 4-12 of them per block lands in that range.
|
||||||
|
#
|
||||||
|
# The run-on version is kept, as STRESS_CHARS below -- Iris, 2026-09-09: "let's switch to new
|
||||||
|
# lines for the test, and also let's keep the single line around for stress".
|
||||||
|
DELTAS_PER_BLOCK = (4, 12)
|
||||||
|
|
||||||
|
# One deliberately pathological message in the backlog: a single markdown block with no blank line
|
||||||
|
# in it, the shape the streaming tail used to have. Sized just under `text_cap`'s MESSAGE_BYTES
|
||||||
|
# (16 KiB) so it is drawn in full rather than behind a "Show all" -- which makes it a genuine
|
||||||
|
# stress for one-shot shaping and puts a row right on the cap boundary, where nothing else is.
|
||||||
|
#
|
||||||
|
# It is *not* streamed: the repeated-reshape pathology needs a growing block, and that lives in
|
||||||
|
# the UI profiling rig's `what_reshaping_a_growing_message_costs`, where it can be
|
||||||
|
# iterated on in a second rather than in a two-minute phone run. Note that the cap would not save
|
||||||
|
# a real one anyway -- `row::build_row`'s `cap` is deliberately `false` for the live tail, because
|
||||||
|
# a row that grew while capped would appear to stop growing, so a streamed block's shaping cost
|
||||||
|
# has no ceiling.
|
||||||
|
STRESS_CHARS = 14_800
|
||||||
|
HERE = Path(__file__).resolve().parent / "assets"
|
||||||
|
|
||||||
|
random.seed(SEED)
|
||||||
|
|
||||||
|
LANGUAGES = ["rust", "kotlin", "python", "sh", "json", "toml"]
|
||||||
|
|
||||||
|
CODE_SNIPPETS = {
|
||||||
|
"rust": '''fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
|
||||||
|
// a comment worth keeping: this is the fold the app's own screen runs
|
||||||
|
let mut out = items;
|
||||||
|
out.push(Item::new(seq));
|
||||||
|
out
|
||||||
|
}''',
|
||||||
|
"kotlin": '''fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> {
|
||||||
|
// mirrors the server's own event model, one item per line
|
||||||
|
return items + TranscriptItem.from(entry)
|
||||||
|
}''',
|
||||||
|
"python": '''def render_report(frames, cpu_ms, rss_kb):
|
||||||
|
# printed for a human to paste back, so every number carries its unit
|
||||||
|
return f"{frames} frames, {cpu_ms}ms cpu, {rss_kb}kb peak rss"''',
|
||||||
|
"sh": '''#!/bin/sh
|
||||||
|
# scripted scroll loop, the shape transcript-bench.sh drives on a phone
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
ui-trace record --do "swipe 540 700 540 1600 200"
|
||||||
|
done''',
|
||||||
|
"json": '{"seq": 1, "type": "status", "state": "running"}',
|
||||||
|
"toml": '''[package]
|
||||||
|
name = "bench-fixture"
|
||||||
|
version = "0.1.0"''',
|
||||||
|
}
|
||||||
|
|
||||||
|
HEADINGS = [
|
||||||
|
"## Plan",
|
||||||
|
"## What changed",
|
||||||
|
"## Why this approach",
|
||||||
|
"### Open questions",
|
||||||
|
"## Results",
|
||||||
|
]
|
||||||
|
|
||||||
|
WORDS = (
|
||||||
|
"session render report frame budget scroll transcript fold event cache "
|
||||||
|
"cursor probe stream backlog swipe fixture bench compose iris widget layout "
|
||||||
|
"measure place draw tool call token context window anchor"
|
||||||
|
).split()
|
||||||
|
|
||||||
|
|
||||||
|
def paragraph(n=24):
|
||||||
|
words = [random.choice(WORDS) for _ in range(n)]
|
||||||
|
words[0] = words[0].capitalize()
|
||||||
|
text = " ".join(words) + "."
|
||||||
|
# Sprinkle markdown inline spans so the syntax highlighter/markdown parser sees a real mix.
|
||||||
|
text = text.replace(" fold ", " **fold** ", 1)
|
||||||
|
text = text.replace(" cursor ", " *cursor* ", 1)
|
||||||
|
text = text.replace(" cache ", " `cache` ", 1)
|
||||||
|
if "bench" in text:
|
||||||
|
text = text.replace(
|
||||||
|
" bench ", " [bench](https://example.com/bench) ", 1
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def make_png(rgb, size=8):
|
||||||
|
"""A tiny, valid PNG -- flat colour, no external dependency."""
|
||||||
|
|
||||||
|
def chunk(tag, data):
|
||||||
|
c = tag + data
|
||||||
|
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c))
|
||||||
|
|
||||||
|
sig = b"\x89PNG\r\n\x1a\n"
|
||||||
|
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
|
||||||
|
raw = b""
|
||||||
|
for _ in range(size):
|
||||||
|
raw += b"\x00" + bytes(rgb) * size
|
||||||
|
idat = zlib.compress(raw)
|
||||||
|
return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
HERE.mkdir(exist_ok=True)
|
||||||
|
lines = []
|
||||||
|
seq = 1
|
||||||
|
ts = 1_788_000_000.0
|
||||||
|
|
||||||
|
def emit(type_, **fields):
|
||||||
|
nonlocal seq, ts
|
||||||
|
obj = {"seq": seq, "ts": round(ts, 3), "type": type_}
|
||||||
|
obj.update(fields)
|
||||||
|
lines.append(json.dumps(obj, separators=(",", ":")))
|
||||||
|
seq += 1
|
||||||
|
ts += random.uniform(0.05, 2.0)
|
||||||
|
|
||||||
|
emit("status", state="running")
|
||||||
|
emit("settings", model="bench-model", permissionMode="auto")
|
||||||
|
|
||||||
|
# The stress message, emitted first and with the random state put back afterwards, so that
|
||||||
|
# adding it is **purely additive**: every turn the loop below generates is byte-identical to
|
||||||
|
# what it generated before this existed, and only the streaming tail changed. That matters
|
||||||
|
# because the fixture's opening view is what `phone_screen.rs`'s recorded gestures press --
|
||||||
|
# `a_long_press_and_drag_selects_text` drives a real recording at (300, 1000) and fails the
|
||||||
|
# moment different content lands under it. `ts` still shifts by the two draws, which nothing
|
||||||
|
# reads.
|
||||||
|
rng_state = random.getstate()
|
||||||
|
emit(
|
||||||
|
"userMessage",
|
||||||
|
text="And the pathological one: a reply with no paragraph break in it.",
|
||||||
|
id=None,
|
||||||
|
attachments=[],
|
||||||
|
)
|
||||||
|
run_on = ""
|
||||||
|
while len(run_on) < STRESS_CHARS:
|
||||||
|
run_on += paragraph(5) + " "
|
||||||
|
emit("assistantText", delta=run_on.rstrip())
|
||||||
|
random.setstate(rng_state)
|
||||||
|
|
||||||
|
image_refs = []
|
||||||
|
turn = 0
|
||||||
|
while seq <= BACKLOG_COUNT:
|
||||||
|
turn += 1
|
||||||
|
emit("userMessage", text=f"Turn {turn}: {paragraph(12)}", id=None, attachments=[])
|
||||||
|
|
||||||
|
# A tool call with kilobyte-scale input/output every few turns.
|
||||||
|
if turn % 3 == 0:
|
||||||
|
tool_id = f"tool-{turn}"
|
||||||
|
big_input = json.dumps({"path": f"/repo/file_{turn}.rs", "content": paragraph(400)})
|
||||||
|
emit("toolStart", id=tool_id, tool="Edit", input=big_input)
|
||||||
|
big_output = "\n".join(paragraph(60) for _ in range(20))
|
||||||
|
emit("toolUpdate", id=tool_id, output=big_output[: len(big_output) // 2])
|
||||||
|
emit("toolEnd", id=tool_id, output=big_output)
|
||||||
|
|
||||||
|
# A reply: a heading, prose, a fenced block in a rotating language, a table, then deltas.
|
||||||
|
emit("assistantText", delta=f"{random.choice(HEADINGS)}\n\n")
|
||||||
|
emit("assistantText", delta=paragraph(30) + "\n\n")
|
||||||
|
lang = LANGUAGES[turn % len(LANGUAGES)]
|
||||||
|
emit("assistantText", delta=f"```{lang}\n{CODE_SNIPPETS[lang]}\n```\n\n")
|
||||||
|
if turn % 5 == 0:
|
||||||
|
emit(
|
||||||
|
"assistantText",
|
||||||
|
delta="| column | value |\n|---|---|\n| a | " + paragraph(3) + " |\n\n",
|
||||||
|
)
|
||||||
|
# A run of small deltas -- the shape a live reply actually streams in.
|
||||||
|
for _ in range(random.randint(3, 8)):
|
||||||
|
emit("assistantText", delta=paragraph(6) + " ")
|
||||||
|
|
||||||
|
# A couple of images, base64 PNGs, the way a real transcript embeds a screenshot.
|
||||||
|
if turn in (10, 40):
|
||||||
|
ref = f"bench{len(image_refs) + 1}.png"
|
||||||
|
image_refs.append(ref)
|
||||||
|
emit("image", ref=ref, about=None)
|
||||||
|
|
||||||
|
emit("usageDelta", tokens=random.randint(200, 4000), context=random.randint(2000, 180000))
|
||||||
|
|
||||||
|
if turn % 15 == 0:
|
||||||
|
emit(
|
||||||
|
"compacted",
|
||||||
|
preTokens=180000,
|
||||||
|
postTokens=20000,
|
||||||
|
trigger="auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The streaming-phase tail: one reply built entirely from text deltas, the shape a bench
|
||||||
|
# harness replays at a fixed events/sec through the live fold path -- with a blank line every
|
||||||
|
# few deltas, so the block a delta lands in stays the size a real reply's blocks are. See
|
||||||
|
# DELTAS_PER_BLOCK for what the alternative measured.
|
||||||
|
emit("userMessage", text="One more, streamed live for the benchmark's timing phase.", id=None, attachments=[])
|
||||||
|
until_break = random.randint(*DELTAS_PER_BLOCK)
|
||||||
|
while seq <= BACKLOG_COUNT + STREAM_COUNT:
|
||||||
|
until_break -= 1
|
||||||
|
if until_break <= 0:
|
||||||
|
# The break rides on the last delta of the block rather than being an event of its
|
||||||
|
# own, so STREAM_COUNT still counts deltas a reader sees text arrive from.
|
||||||
|
emit("assistantText", delta=paragraph(5) + "\n\n")
|
||||||
|
until_break = random.randint(*DELTAS_PER_BLOCK)
|
||||||
|
else:
|
||||||
|
emit("assistantText", delta=paragraph(5) + " ")
|
||||||
|
emit("status", state="idle")
|
||||||
|
|
||||||
|
(HERE / "transcript.jsonl").write_text("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
(HERE / "bench1.png").write_bytes(make_png((220, 90, 90)))
|
||||||
|
(HERE / "bench2.png").write_bytes(make_png((90, 150, 220)))
|
||||||
|
|
||||||
|
print(f"wrote {len(lines)} events ({BACKLOG_COUNT} backlog + {STREAM_COUNT} stream) to transcript.jsonl")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+100
-145
@@ -1,153 +1,108 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Builds the app's APK, ready to install on a phone through Dev Updater.
|
# Builds the Rust cdylib with cargo-ndk, packages it with Gradle, and
|
||||||
|
# verifies the resulting APK.
|
||||||
#
|
#
|
||||||
# ./build-apk.sh the release build, signed (what the phone runs)
|
# Usage: ./build-apk.sh [debug|release] [--abi arm64-v8a|x86_64] [--features "a b c"]
|
||||||
# ./build-apk.sh debug the debug build, for reproducing something the
|
# debug/release default to debug (matches this-machine-android's "the
|
||||||
# emulator scripts would build anyway
|
# emulator stays on debug" rule -- pass `release` explicitly for a phone
|
||||||
#
|
# build). --abi defaults to arm64-v8a (a phone/real device); pass
|
||||||
# Dev Updater's `.dev-updater.ron` at the checkout root spells these out as
|
# x86_64 for this checkout's own AVD. --features defaults to
|
||||||
# build modes, one command line each; it passes nothing else, so the word
|
# "screens". Pass "screens bench" for the retained benchmark app. Never
|
||||||
# here is the whole interface.
|
# force GLES for a phone or emulator build; Iris's
|
||||||
#
|
# runtime selects the available hardware backend.
|
||||||
# The APK pins the CA on *this* machine ($XDG_CONFIG_HOME/ai-app/certs/ca.pem,
|
|
||||||
# or AI_APP_CA), so build it on the machine that runs the backend: an app
|
|
||||||
# built somewhere else trusts a CA that backend can't present, and simply
|
|
||||||
# won't connect. Start ai-server once first if there are no certificates
|
|
||||||
# yet -- it generates them; the build stops with that instruction if it
|
|
||||||
# can't find one.
|
|
||||||
#
|
|
||||||
# Unlike ./run-android.sh, this touches no emulator: it only produces the
|
|
||||||
# file. Installing on a real phone goes through Dev Updater, which serves
|
|
||||||
# whatever is under this project's build directory.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
VARIANT=${1:-release}
|
BUILD_TYPE="debug"
|
||||||
case "$VARIANT" in
|
ABI="arm64-v8a"
|
||||||
release) TASK=assembleRelease ;;
|
FEATURES="screens"
|
||||||
debug) TASK=assembleDebug ;;
|
case "${1:-}" in
|
||||||
*)
|
debug|release) BUILD_TYPE="$1"; shift ;;
|
||||||
echo "build-apk.sh: unknown variant '$VARIANT' (release, debug)" >&2
|
esac
|
||||||
exit 2
|
while [ $# -gt 0 ]; do
|
||||||
;;
|
case "$1" in
|
||||||
|
--abi) ABI="$2"; shift 2 ;;
|
||||||
|
--features) FEATURES="$2"; shift 2 ;;
|
||||||
|
*) echo "build-apk.sh: unknown argument: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
SDK_ROOT="$HOME/Android/Sdk"
|
||||||
|
export ANDROID_HOME="$SDK_ROOT"
|
||||||
|
export ANDROID_SDK_ROOT="$SDK_ROOT"
|
||||||
|
NDK_DIR=$(ls -d "$SDK_ROOT"/ndk/*/ 2>/dev/null | sort -V | tail -1)
|
||||||
|
if [ -z "$NDK_DIR" ]; then
|
||||||
|
echo "build-apk.sh: no NDK found under $SDK_ROOT/ndk" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export ANDROID_NDK_HOME="$NDK_DIR"
|
||||||
|
|
||||||
|
# Only the ABI asked for goes into the APK. cargo ndk adds its output beside
|
||||||
|
# whatever earlier builds left here, and Gradle packages every directory it
|
||||||
|
# finds -- a debug x86_64 emulator build left behind made an arm64 "release"
|
||||||
|
# 339 MB on 2026-09-06.
|
||||||
|
rm -rf android-project/app/src/main/jniLibs
|
||||||
|
# ...and Gradle's own copy of them, which `rm -rf jniLibs` does not reach.
|
||||||
|
# `mergeReleaseNativeLibs` is *up to date* against its cached inputs, so a
|
||||||
|
# build that switches ABI packages the previous ABI: an `--abi x86_64`
|
||||||
|
# release APK containing `lib/arm64-v8a/libmain.so` installed fine and
|
||||||
|
# aborted at startup with `Could not get adapter!: NotFound {
|
||||||
|
# active_backends: VULKAN }` under libndk_translation -- which reads
|
||||||
|
# exactly like the phone's own Vulkan problem and is nothing of the kind.
|
||||||
|
# Scoped to the merge task's directory rather than all of `app/build`, so
|
||||||
|
# an ABI change costs the native merge and not the whole Gradle build.
|
||||||
|
rm -rf android-project/app/build/intermediates/merged_native_libs \
|
||||||
|
android-project/app/build/intermediates/stripped_native_libs \
|
||||||
|
android-project/app/build/intermediates/merged_jni_libs
|
||||||
|
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \
|
||||||
|
--profile android-release --no-default-features --features "$FEATURES"
|
||||||
|
else
|
||||||
|
cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \
|
||||||
|
--profile android-dev --no-default-features --features "$FEATURES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
GRADLE_TASK="assembleDebug"
|
||||||
|
APK_DIR="android-project/app/build/outputs/apk/debug"
|
||||||
|
APK_NAME="app-debug.apk"
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
GRADLE_TASK="assembleRelease"
|
||||||
|
APK_DIR="android-project/app/build/outputs/apk/release"
|
||||||
|
APK_NAME="app-release.apk"
|
||||||
|
export AI_APP_KEYSTORE="${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/release.jks"
|
||||||
|
if [ ! -f "$AI_APP_KEYSTORE" ]; then
|
||||||
|
KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/keytool}"
|
||||||
|
KEYTOOL="${KEYTOOL:-keytool}"
|
||||||
|
if ! command -v "$KEYTOOL" >/dev/null 2>&1; then
|
||||||
|
echo "build-apk.sh: no release key and no keytool to create one" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "$AI_APP_KEYSTORE")"
|
||||||
|
AI_APP_KEYSTORE_PASSWORD=$(head -c 24 /dev/urandom | base64 | tr -d '/+=')
|
||||||
|
(umask 077 && printf '%s\n' "$AI_APP_KEYSTORE_PASSWORD" > "$AI_APP_KEYSTORE.password")
|
||||||
|
(umask 077 && "$KEYTOOL" -genkeypair -keystore "$AI_APP_KEYSTORE" \
|
||||||
|
-alias ai-app -keyalg RSA -keysize 2048 -validity 10000 \
|
||||||
|
-storepass "$AI_APP_KEYSTORE_PASSWORD" -keypass "$AI_APP_KEYSTORE_PASSWORD" \
|
||||||
|
-dname "CN=ai-app" >/dev/null 2>&1)
|
||||||
|
fi
|
||||||
|
export AI_APP_KEYSTORE_PASSWORD
|
||||||
|
AI_APP_KEYSTORE_PASSWORD=$(cat "$AI_APP_KEYSTORE.password")
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset AI_APP_BENCH
|
||||||
|
case " $FEATURES " in
|
||||||
|
*" bench "*) export AI_APP_BENCH=1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
(cd android-project && gradle ":app:$GRADLE_TASK" --console=plain)
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
# Prefer an SDK this machine has already configured -- the host and the dev
|
APK_PATH="$(pwd)/$APK_DIR/$APK_NAME"
|
||||||
# VM don't keep it in the same place, and android-env.sh is written for the
|
BUILD_TOOLS=$(ls -d "$SDK_ROOT"/build-tools/*/ | sort -V | tail -1)
|
||||||
# VM's layout (it also installs missing packages, which isn't wanted here).
|
echo "--- aapt2 dump badging ---"
|
||||||
if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}" ]; then
|
"${BUILD_TOOLS}aapt2" dump badging "$APK_PATH" | head -5
|
||||||
echo "==> Using ANDROID_HOME=$ANDROID_HOME"
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}" ]; then
|
echo "--- apksigner verify ---"
|
||||||
ANDROID_HOME="$ANDROID_SDK_ROOT"
|
"${BUILD_TOOLS}apksigner" verify --print-certs "$APK_PATH"
|
||||||
export ANDROID_HOME
|
|
||||||
echo "==> Using ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
|
||||||
elif [ -d "$HOME/Android/Sdk" ]; then
|
|
||||||
ANDROID_HOME="$HOME/Android/Sdk"
|
|
||||||
ANDROID_SDK_ROOT="$ANDROID_HOME"
|
|
||||||
export ANDROID_HOME ANDROID_SDK_ROOT
|
|
||||||
echo "==> Using $ANDROID_HOME"
|
|
||||||
else
|
|
||||||
echo "No Android SDK found. Set ANDROID_HOME to it, or install one" >&2
|
|
||||||
echo "(Android Studio's default location is ~/Android/Sdk)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
echo "$APK_PATH"
|
||||||
CA="${AI_APP_CA:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem}"
|
|
||||||
if [ -f "$CA" ]; then
|
|
||||||
# Printed so a wrong or stale certificate is visible here rather than
|
|
||||||
# as a handshake failure on the phone -- compare it against the CA the
|
|
||||||
# backend is actually presenting.
|
|
||||||
FINGERPRINT=$(openssl x509 -in "$CA" -pubkey -noout 2>/dev/null \
|
|
||||||
| openssl pkey -pubin -outform der 2>/dev/null \
|
|
||||||
| openssl dgst -sha256 -binary 2>/dev/null \
|
|
||||||
| openssl base64 2>/dev/null || echo "(openssl unavailable)")
|
|
||||||
echo "==> Pinning the CA at $CA"
|
|
||||||
echo " fingerprint: $FINGERPRINT"
|
|
||||||
else
|
|
||||||
echo "No CA certificate at $CA -- start ai-server once on this machine" >&2
|
|
||||||
echo "(it generates them), or set AI_APP_CA. The APK embeds it at build time." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# The phone runs the release build. A debuggable build runs Compose at a
|
|
||||||
# fraction of the speed -- ART keeps the process debugger-friendly and the
|
|
||||||
# compiler leaves its inspection hooks in -- so a frame time measured on one
|
|
||||||
# says little about the app; that cost a day of tuning against the wrong
|
|
||||||
# number. A release build must be signed, and the key is what the phone
|
|
||||||
# recognises the app by, so it lives beside the CA, outside any checkout,
|
|
||||||
# and is generated once here. Switching from an installed debug build means
|
|
||||||
# uninstalling it first: the signatures differ, and Android refuses to
|
|
||||||
# update across them.
|
|
||||||
KEYSTORE="${AI_APP_KEYSTORE:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/release.jks}"
|
|
||||||
if [ "$VARIANT" = release ] && [ ! -f "$KEYSTORE" ]; then
|
|
||||||
KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/keytool}"
|
|
||||||
KEYTOOL="${KEYTOOL:-keytool}"
|
|
||||||
if ! command -v "$KEYTOOL" >/dev/null 2>&1; then
|
|
||||||
echo "No signing key at $KEYSTORE and no keytool to make one -- set" >&2
|
|
||||||
echo "JAVA_HOME to the JDK Gradle uses, or AI_APP_KEYSTORE to an existing key." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "==> No signing key at $KEYSTORE -- generating one"
|
|
||||||
mkdir -p "$(dirname "$KEYSTORE")"
|
|
||||||
PASSWORD=$(head -c 24 /dev/urandom | base64 | tr -d '/+=')
|
|
||||||
(umask 077 && printf '%s\n' "$PASSWORD" > "$KEYSTORE.password")
|
|
||||||
(umask 077 && "$KEYTOOL" -genkeypair -keystore "$KEYSTORE" -alias ai-app \
|
|
||||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
|
||||||
-storepass "$PASSWORD" -keypass "$PASSWORD" -dname "CN=ai-app" >/dev/null 2>&1)
|
|
||||||
fi
|
|
||||||
if [ "$VARIANT" = release ]; then
|
|
||||||
AI_APP_KEYSTORE="$KEYSTORE"
|
|
||||||
AI_APP_KEYSTORE_PASSWORD=$(cat "$KEYSTORE.password")
|
|
||||||
export AI_APP_KEYSTORE AI_APP_KEYSTORE_PASSWORD
|
|
||||||
echo "==> Signing with $KEYSTORE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Dev Updater draws a real progress bar from "@@progress done/total" lines,
|
|
||||||
# and ignores anything that isn't exactly that shape. Gradle can't be asked
|
|
||||||
# for this directly: an init script using taskGraph.afterTask is rejected
|
|
||||||
# outright by the configuration cache, and whenReady never fires on a cache
|
|
||||||
# hit. --dry-run costs about a second, is cache-friendly, and prints one
|
|
||||||
# ":task SKIPPED" line per task the real build will run, which is exactly
|
|
||||||
# the total. The build then prints one "> Task :x" line per task as it
|
|
||||||
# goes, so counting those against it is the whole mechanism.
|
|
||||||
#
|
|
||||||
# Task count is not time -- the Kotlin compile and dexBuilder are most of
|
|
||||||
# the wall clock -- so the bar moves unevenly. It is still counted work
|
|
||||||
# rather than a guess at how long last time took.
|
|
||||||
TASKS=$(./gradlew :androidApp:$TASK --dry-run --console=plain 2>/dev/null \
|
|
||||||
| grep -c '^:[A-Za-z:]* SKIPPED' || true)
|
|
||||||
|
|
||||||
echo "==> Building"
|
|
||||||
if [ "${TASKS:-0}" -gt 0 ]; then
|
|
||||||
echo "@@progress 0/$TASKS"
|
|
||||||
DONE=0
|
|
||||||
./gradlew :androidApp:$TASK --console=plain 2>&1 | while IFS= read -r line; do
|
|
||||||
echo "$line"
|
|
||||||
case "$line" in
|
|
||||||
"> Task "*)
|
|
||||||
DONE=$((DONE + 1))
|
|
||||||
echo "@@progress $DONE/$TASKS"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
# The pipeline's exit status is the shell's, not gradle's, so ask
|
|
||||||
# gradle again rather than reporting a failed build as a success. It is
|
|
||||||
# up to date by now, so this is a second or two.
|
|
||||||
./gradlew :androidApp:$TASK --console=plain >/dev/null
|
|
||||||
else
|
|
||||||
./gradlew :androidApp:$TASK
|
|
||||||
fi
|
|
||||||
|
|
||||||
APK="$SCRIPT_DIR/androidApp/build/outputs/apk/$VARIANT/androidApp-$VARIANT.apk"
|
|
||||||
echo
|
|
||||||
echo "==> Built $APK"
|
|
||||||
[ -f "$APK" ] && ls -lh "$APK" | awk '{print " " $5}'
|
|
||||||
echo
|
|
||||||
echo "To get it onto the phone: add this project to Dev Updater (or hit"
|
|
||||||
echo "Update on it if it's already there) and install from there."
|
|
||||||
echo "Debug and release are signed with different keys, so switching from"
|
|
||||||
echo "one to the other means uninstalling the installed one first."
|
|
||||||
echo "Then start the backend and scan the enrollment QR it prints:"
|
|
||||||
echo " ./server/target/release/ai-server --rotate-token"
|
|
||||||
+23
-45
@@ -1,49 +1,36 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Rebuilds androidApp/src/main/res/font/nerd_icons.ttf.
|
# Rebuilds app/assets/fonts/nerd_icons.ttf.
|
||||||
#
|
#
|
||||||
# The app draws a handful of icons -- a cog, a refresh arrow, send, stop --
|
# ai-app draws its icons as glyphs in a Nerd Fonts subset it ships, rather
|
||||||
# as text in a Nerd Fonts glyph rather than as vector assets or as ordinary
|
# than as ordinary Unicode out of whatever the platform resolved. Unicode's
|
||||||
# Unicode. Unicode has no character for most of these, and the ones it does
|
# own geometric shapes are what this replaced: `tool.rs` set its disclosure
|
||||||
# have are not reliably in an Android system font, so they land as tofu
|
# mark with U+25B8/25BE/25B4, and once iris stopped bundling fonts
|
||||||
# boxes on somebody's phone. Shipping the subset removes the hope: the
|
# (2026-09-07) Iris's phone drew an empty box for them and this VM drew a
|
||||||
# glyph is in the APK.
|
# dot. UI_RULES: "don't rely on characters the platform might not have --
|
||||||
|
# ship the glyph or the asset rather than hoping."
|
||||||
#
|
#
|
||||||
# The whole symbols font is 3 MB for the handful below, so what is
|
# The whole symbols font is 3 MB for the handful below, so what is
|
||||||
# committed is a subset. Add a codepoint to GLYPHS below and to NerdIcons.kt
|
# committed is a subset. Add a codepoint to GLYPHS *and* to
|
||||||
# (the two lists have to agree -- a codepoint in the Kotlin but not here is
|
# `app/src/ui/icon.rs` (the two lists have to agree -- a codepoint in
|
||||||
# a glyph that silently doesn't exist), then run this and commit the result.
|
# the Rust that this script did not subset is a glyph that silently isn't
|
||||||
|
# there), then run this and commit the result.
|
||||||
#
|
#
|
||||||
# Needs python3 and network access; fontTools is fetched into a temporary
|
# Needs python3 and network access; fontTools is fetched into a temporary
|
||||||
# venv, so nothing has to be installed on the machine.
|
# venv, so nothing has to be installed on the machine.
|
||||||
#
|
#
|
||||||
# Copied from dev-updater's script of the same name rather than shared
|
# The Mono face makes icon metrics stable; the Material Design family keeps
|
||||||
# through wg-app-link, for the reason Theme.kt gives about the palette: the
|
# their meanings conventional.
|
||||||
# link is the tunnel, the pinned CA and enrollment, and an icon set is a
|
|
||||||
# preference rather than part of that contract.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Codepoint, then the Nerd Fonts glyph name it came from. Material Design
|
|
||||||
# Icons bar one, so they read as one family -- and the first two are
|
|
||||||
# deliberately the same two dev-updater uses, since a cog and a refresh
|
|
||||||
# arrow mean the same thing in both apps. The exception is noted on its
|
|
||||||
# own line, as dev-updater's script does with its two.
|
|
||||||
GLYPHS=(
|
GLYPHS=(
|
||||||
U+F0493 # md-cog
|
U+F035D # md-menu_down -- a card that is open
|
||||||
U+F0450 # md-refresh
|
U+F035F # md-menu_right -- a card that opens
|
||||||
U+F048A # md-send
|
U+F0360 # md-menu_up -- collapse this group again
|
||||||
U+F04DB # md-stop
|
|
||||||
U+F03E4 # md-pause
|
|
||||||
U+F040A # md-play
|
|
||||||
U+F1163 # md-send_clock
|
|
||||||
U+F0156 # md-close
|
|
||||||
U+F004D # md-arrow_left
|
|
||||||
U+F009A # md-bell
|
|
||||||
U+F04C5 # md-speedometer
|
|
||||||
U+F201 # fa-line_chart -- Font Awesome's, asked for by name
|
|
||||||
)
|
)
|
||||||
|
|
||||||
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
|
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
|
||||||
out="$(cd "$(dirname "$0")" && pwd)/androidApp/src/main/res/font/nerd_icons.ttf"
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
out="$here/assets/fonts/nerd_icons.ttf"
|
||||||
work="$(mktemp -d)"
|
work="$(mktemp -d)"
|
||||||
trap 'rm -rf "$work"' EXIT
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
@@ -56,23 +43,14 @@ python3 -m venv "$work/venv"
|
|||||||
|
|
||||||
unicodes="$(IFS=,; echo "${GLYPHS[*]}")"
|
unicodes="$(IFS=,; echo "${GLYPHS[*]}")"
|
||||||
mkdir -p "$(dirname "$out")"
|
mkdir -p "$(dirname "$out")"
|
||||||
# The Mono face rather than the proportional one, which this used until
|
# The Mono face, where every glyph is one em wide and one em tall, so two
|
||||||
# 2026-08-30. Every glyph in it is one em wide and one em tall, so two
|
# icons at the same font size are the same size without either being given
|
||||||
# icons drawn at the same size are the same size -- which is what makes two
|
# one, which makes an icon's box predictable beside a line of text.
|
||||||
# icon buttons beside each other match without either of them being told a
|
|
||||||
# width. In the proportional face the advances run from 0.46 em (play) to
|
|
||||||
# 0.92 em (line chart), so the composer's Send button came out visibly wider
|
|
||||||
# than the Stop button next to it, and any fix at the call site would have
|
|
||||||
# been one measurement hardcoded per pair.
|
|
||||||
#
|
|
||||||
# The trade is the one the old comment named: an icon inline beside text is
|
|
||||||
# padded out to a cell. That is worth it, and it is also why GLYPH_SIZE in
|
|
||||||
# NerdIcons.kt came down when this changed -- a glyph that fills its em
|
|
||||||
# draws bigger at the same point size than one that does not.
|
|
||||||
"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFontMono-Regular.ttf" \
|
"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFontMono-Regular.ttf" \
|
||||||
--unicodes="$unicodes" \
|
--unicodes="$unicodes" \
|
||||||
--layout-features= \
|
--layout-features= \
|
||||||
--drop-tables+=DSIG \
|
--drop-tables+=DSIG \
|
||||||
--output-file="$out"
|
--output-file="$out"
|
||||||
|
cp "$work/LICENSE" "$here/assets/fonts/NERD_FONTS_LICENSE.txt"
|
||||||
|
|
||||||
echo "Wrote $out ($(stat -c %s "$out") bytes) with ${#GLYPHS[@]} glyphs"
|
echo "Wrote $out ($(stat -c %s "$out") bytes) with ${#GLYPHS[@]} glyphs"
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
plugins {
|
|
||||||
alias(libs.plugins.androidApplication) apply false
|
|
||||||
alias(libs.plugins.androidLibrary) apply false
|
|
||||||
alias(libs.plugins.composeMultiplatform) apply false
|
|
||||||
alias(libs.plugins.composeCompiler) apply false
|
|
||||||
}
|
|
||||||
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user