Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62199aa3a7 | ||
|
|
b133d85943 | ||
|
|
ba6817fee5 | ||
|
|
73ee63bc1b | ||
|
|
8f0aec449a | ||
|
|
6d5fd64bb0 | ||
|
|
e5880c33f4 | ||
|
|
a853eb5a4d | ||
|
|
22d5c6585a | ||
|
|
b063fbd7f9 | ||
|
|
3f25e7ebca | ||
|
|
0af4c88d08 | ||
|
|
ceabd00805 | ||
|
|
32a5256a0d | ||
|
|
4cfe0ef6e6 | ||
|
|
c9b273ff16 | ||
|
|
8adda94a7a | ||
|
|
3a9208f38b | ||
|
|
e898370bf4 | ||
|
|
6e0bd06e4d | ||
|
|
03da47e550 | ||
|
|
a2cd119985 | ||
|
|
19c36e37f2 | ||
|
|
e2873df92e | ||
|
|
ea13889a21 | ||
|
|
6317685d1a | ||
|
|
f79bd7ca71 | ||
|
|
9c935f8ce8 | ||
|
|
982449293d | ||
|
|
288853c094 | ||
|
|
fba572427d | ||
|
|
85ec5416b6 | ||
|
|
643daf5637 | ||
|
|
8db0184384 | ||
|
|
1a6599e1b2 | ||
|
|
0a2f4fa1fe | ||
|
|
237886c11e | ||
|
|
e8dbcaa7db | ||
|
|
26163b25b2 | ||
|
|
762c1290a1 | ||
|
|
62dd6b7912 | ||
|
|
bc3db183e3 | ||
|
|
e0a473e090 | ||
|
|
1c937e2f48 | ||
|
|
d194d73439 | ||
|
|
4400966928 | ||
|
|
6e49ce8c92 | ||
|
|
79b9cd789a | ||
|
|
c70a670356 | ||
|
|
43743ba171 | ||
|
|
1a97d0ef5c | ||
|
|
ff7e9c0435 | ||
|
|
68a7f41ed0 | ||
|
|
9b331a5e93 | ||
|
|
3fc224b584 | ||
|
|
10500ae8aa | ||
|
|
8d441d3d59 | ||
|
|
e0ee7d6e94 | ||
|
|
b6b0928087 | ||
|
|
12221ea025 | ||
|
|
5e23c8b0c0 | ||
|
|
caaa733caa | ||
|
|
4ab26f068e | ||
|
|
0f8ba49f4a | ||
|
|
1fcaa2d72d | ||
|
|
edc39c7371 | ||
|
|
79682f03a7 | ||
|
|
e3e02d55f7 | ||
|
|
a802522039 | ||
|
|
74110b4d72 | ||
|
|
45e631ab96 | ||
|
|
7997eeb7f8 | ||
|
|
68c5180260 | ||
|
|
a401e6a7e3 | ||
|
|
7b08a71e64 | ||
|
|
457907087c | ||
|
|
a074975d6f | ||
|
|
ffc266bf3e | ||
|
|
121a47da6e | ||
|
|
2c12274285 | ||
|
|
9c4d33273b | ||
|
|
db55ed4a8f | ||
|
|
8881a40919 | ||
|
|
9fdab777b4 | ||
|
|
3bb178363d | ||
|
|
4a9c547293 | ||
|
|
cc7e4f63ef | ||
|
|
9c4df43951 | ||
|
|
aa6d9b256e | ||
|
|
1fc0f5c129 | ||
|
|
5b1121be16 | ||
|
|
f6bee1b8a5 | ||
|
|
32c57a47a5 | ||
|
|
acdf00ab1d | ||
|
|
359649bc73 | ||
|
|
a2b11d516f | ||
|
|
a383c19dd5 | ||
|
|
8dcd2cb708 | ||
|
|
a4d512af26 | ||
|
|
32b2a4871a |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
|
||||
# every crate in this repo into one workspace -- they are deliberately
|
||||
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
|
||||
# from the repo root runs xtask/src/main.rs directly.
|
||||
[alias]
|
||||
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
|
||||
@@ -55,4 +55,21 @@ components: [
|
||||
// the terminal the QR would be printed on.
|
||||
enroll: "server/enroll-link.sh",
|
||||
),
|
||||
// E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle
|
||||
// (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed
|
||||
// with the same release key as "app" above so the two can install
|
||||
// over each other -- a separate component, not a mode of "app" above,
|
||||
// because it is a different applicationId (com.example.aiapp.shell)
|
||||
// built by a different tool from different sources. No `cwd`: it
|
||||
// defaults to this checkout's root, which both the `cargo xtask`
|
||||
// alias (`.cargo/config.toml`, resolved relative to the working
|
||||
// directory cargo is run from) and `cargo xtask apk`'s own publishing
|
||||
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
|
||||
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
|
||||
// both need.
|
||||
Apk(
|
||||
name: "shell",
|
||||
modes: ["release", "debug"],
|
||||
build: "cargo xtask apk",
|
||||
),
|
||||
],
|
||||
+19
@@ -1,12 +1,20 @@
|
||||
.gradle/
|
||||
build/
|
||||
app/androidApp/build/
|
||||
app/shellApp/build/
|
||||
local.properties
|
||||
.kotlin/
|
||||
*.iml
|
||||
.idea/
|
||||
.DS_Store
|
||||
server/target/
|
||||
event-model/target/
|
||||
client-core/target/
|
||||
android-shell/target/
|
||||
|
||||
# E3's native library, built by cargo-ndk straight into the Gradle module
|
||||
# (RUST.md) -- an artifact, like server/target/ above, not source.
|
||||
app/shellApp/src/main/jniLibs/
|
||||
|
||||
# Server logs from a development run (ai-server.log by convention,
|
||||
# wg-test.log from ./test-wg-tunnel.sh).
|
||||
@@ -21,3 +29,14 @@ certs/
|
||||
config.ron
|
||||
config.json
|
||||
sessions/
|
||||
|
||||
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
||||
iris/target/
|
||||
iris/android-app/target/
|
||||
|
||||
# E5's packaging xtask (RUST.md). `build/` above already covers
|
||||
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
|
||||
# The repo root has no Cargo workspace, so this is xtask's own
|
||||
# intermediate working files (target/xtask/apk/...), not a shared one.
|
||||
xtask/target/
|
||||
/target/
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
# client-core
|
||||
|
||||
`client-core/` is the app's pure logic held once instead of twice, per
|
||||
RUST.md's recommendation item 1. It is a plain Rust library crate with no UI
|
||||
framework dependency of any kind, so it can outlive whichever one the app
|
||||
ends up drawing with (Masonry, iris, or something else -- see RUST.md).
|
||||
`event-model/` is its sibling: the wire shape both this crate and `server/`
|
||||
share, extracted from `server/src/session/driver.rs` and
|
||||
`session/transcript.rs` on 2026-09-04.
|
||||
|
||||
Neither crate is wired into anything yet. `server/` re-exports `event-model`
|
||||
so its own behaviour is unchanged (`./run-tests.sh` covers it); `client-core`
|
||||
has no caller -- it exists for whichever experiment in RUST.md picks it up
|
||||
next (a Masonry or iris transcript screen, most likely).
|
||||
|
||||
## What's here, and what Kotlin file it replaces
|
||||
|
||||
| `client-core/src/…` | Kotlin original | Status |
|
||||
|------------------------------------------|-------------------------------------------|--------|
|
||||
| `event-model/src/lib.rs` (shared crate) | `Events.kt` (the enum mirror) | Done |
|
||||
| `ansi.rs` | `Ansi.kt` | Done, ported test-for-test |
|
||||
| `highlight/mod.rs`, `languages.rs` | `Highlighter.kt`, `Languages.kt` | Done, ported test-for-test |
|
||||
| `highlight/markdown.rs` | `MarkdownSyntax.kt` | Done, ported test-for-test |
|
||||
| `transcript_cache.rs` | `TranscriptCache.kt` | Done, ported test-for-test |
|
||||
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
|
||||
| `api.rs` | `Api.kt` | Partial -- see below |
|
||||
| `event_stream.rs` | `EventStream.kt` | Done |
|
||||
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below |
|
||||
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
|
||||
| *(not started)* | `TranscriptSource.kt` | Not started |
|
||||
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
||||
|
||||
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
|
||||
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
|
||||
cases ported alongside it, plus new tests for the pieces that had none
|
||||
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by
|
||||
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its
|
||||
types carry no logic of their own to test -- `server/`'s own tests exercise
|
||||
them via `session::transcript`'s round-trip coverage).
|
||||
|
||||
## Correspondence notes worth knowing before touching either side
|
||||
|
||||
- **`ansi.rs`'s `StyledText`/`Style`/`Rgb`** stand in for Compose's
|
||||
`AnnotatedString`/`SpanStyle`/`Color`, since this crate has no Compose.
|
||||
`StyledText` is plain text plus a `Vec<(Range<usize>, Style)>` of
|
||||
non-overlapping spans. Whatever UI framework ends up consuming this
|
||||
crate maps `Style` onto its own text-styling type; nothing here should
|
||||
change to accommodate a particular one.
|
||||
- **`highlight`'s `Span`/`Kind`** use **char indices, not byte offsets**
|
||||
(`Vec<char>` internally), mirroring the Kotlin original's `Char`-indexed
|
||||
strings. `highlight::span_text` turns a `Span` back into text for a
|
||||
caller working the same way; a caller that wants byte offsets into a
|
||||
`&str` has to convert.
|
||||
- **`transcript_cache.rs`'s `SessionCache::guard`** found a real
|
||||
translation bug while it was being written: an early draft let a
|
||||
*damaged* chunk (one file unreadable, discard just this session) and a
|
||||
genuine I/O failure (disk gone, disable the whole cache) both surface as
|
||||
the same `Err` from one closure, which would have disabled every
|
||||
session's cache over a single corrupt chunk. Fixed by checking a
|
||||
thread-local "was this damage" flag before deciding which failure mode
|
||||
it was -- see the comment on `guard` and the commit message for
|
||||
`transcript_cache.rs`.
|
||||
|
||||
## What `api.rs` covers, and what it does not yet
|
||||
|
||||
`ApiClient` wraps a `Transport` trait (network I/O kept out from behind, so
|
||||
`ApiClient` and `event_stream::follow_session_events` are tested with a
|
||||
fake transport and no server). `UreqTransport` is the only real
|
||||
implementation, backed by `ureq` -- see its Cargo.toml comment for why
|
||||
(blocking, already a project dependency, no extra TLS crate needed since
|
||||
`ureq::tls::Certificate::from_pem` reads the pinned CA directly).
|
||||
|
||||
Covered: session list/read, message send, unqueue, answer, interrupt,
|
||||
stop, start, rename, cwd, model, permission-mode, notify, command,
|
||||
compact, delete, and one transcript page.
|
||||
|
||||
**Not covered, and each is real work rather than a stub to fill in:**
|
||||
setups (`/setups*`, machine and provider discovery), the file explorer
|
||||
(`/setups/{id}/dir|file`), usage (`/usage`), models
|
||||
(`/models*`, HuggingFace browsing and downloads), attachments
|
||||
(`/sessions/{id}/attachments`), importing (`/setups/{id}/importable*`),
|
||||
and the `/notifications` stream. `server/src/routes.rs`'s module doc is
|
||||
the full table to work from when one of these is next.
|
||||
|
||||
## What `transcript_fold.rs` covers, and what it does not yet
|
||||
|
||||
`fold_event` covers every `Event` variant server/ can produce today,
|
||||
including tool-call/question/image attachment and peer-message placement.
|
||||
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
|
||||
|
||||
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its
|
||||
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that
|
||||
merges a tool call split across two fetched pages and re-merges a run a
|
||||
boundary cut through. This matters the moment paging backward through
|
||||
history is exercised; it is deliberately left rather than rushed, since
|
||||
it is exactly the kind of boundary logic this project's own "things that
|
||||
have bitten" section warns reads fine and is wrong at the edges.
|
||||
|
||||
**Known gap, and a decision for whoever closes it:** `event_model::Event`
|
||||
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
|
||||
A server newer than this build that adds an event type will fail to parse
|
||||
that line rather than degrading to a placeholder row. Closing this means
|
||||
deciding how `event_model` itself represents "a shape I don't recognise"
|
||||
-- a shared-model decision affecting `server/` too, not a `client-core`-only
|
||||
fix, so it is recorded here rather than silently worked around.
|
||||
|
||||
## `config.rs`: `EnrolledServer`
|
||||
|
||||
`EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads
|
||||
the exact `aiapp://enroll?host=H&port=P&token=T` deep link
|
||||
`wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment`
|
||||
parses on the phone -- so any Rust client enrols from the same text a
|
||||
phone would scan as a QR, with no second format invented for it (RUST.md's
|
||||
E4, DECISIONS.md 2026-09-05). Deliberately does not decide where it is
|
||||
persisted or under what file permissions -- a phone seals its token in the
|
||||
Android Keystore, `iris/desktop-app/src/config.rs` writes it to
|
||||
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is
|
||||
caller-specific (the code rules' "ask for the least you need"). Its only
|
||||
caller today is `desktop-app`; a future Android build of this crate would
|
||||
be a second one, not a reason to move the type.
|
||||
|
||||
## What is not started at all
|
||||
|
||||
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
|
||||
from the transcript cache or the server, and stitches the two. Needs
|
||||
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
|
||||
which exist now, so this is unblocked whenever picked up.
|
||||
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
|
||||
colours a `.md` file or fence for the highlighter, but does not build the
|
||||
block tree (headings, lists, tables, fences as distinct nodes) that a
|
||||
renderer walks to lay out prose versus code versus a table.
|
||||
`CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark
|
||||
AST is Compose rendering plumbing, not something to port as-is; a Rust
|
||||
UI layer will want its own block parser or a crate for it, decided
|
||||
alongside the framework choice in RUST.md.
|
||||
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
||||
it flattens a row into bounded units for a *specific* lazy-list
|
||||
framework's composition cost, which is a fact about that framework
|
||||
rather than about the transcript.
|
||||
|
||||
## Verifying
|
||||
|
||||
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
|
||||
and `server` in that order (each `cargo test`, forwarding arguments the
|
||||
same way it always has). From `client-core/` directly: `cargo test`,
|
||||
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Decisions taken for Iris to review
|
||||
|
||||
Short list of design choices made by the design agent without asking, so
|
||||
they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
|
||||
for iris API changes); this file is only the summary. Newest first. Items
|
||||
marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||
|
||||
## 2026-09-05
|
||||
|
||||
- **Touch drag on a transcript row follows Android's own rule**: a vertical
|
||||
drag pans the list immediately; a stationary press held 500 ms starts a
|
||||
text selection which further dragging extends; a horizontal drag while
|
||||
something is already selected extends that selection without the wait.
|
||||
One `DragArbiter` per list decides it (`iris/src/sense.rs`). Chosen over a
|
||||
"text layer always wins" or "list always wins" rule because either loses
|
||||
one of the two gestures a reader expects.
|
||||
- **E4's desktop shape is a new `iris/desktop-app` crate**: a winit window
|
||||
holding `transcript-ui`'s screen beside a session list, talking to a real
|
||||
`ai-server` through `client-core`. It enrols by pasting the same
|
||||
`aiapp://enroll?…` link a phone scans (`client-core::config::EnrolledServer`)
|
||||
and keeps it owner-only under `$XDG_CONFIG_HOME/ai-app-desktop/`. The
|
||||
pinned CA is a path given on the command line, not baked in. Chosen so
|
||||
the phone and desktop share one enrolment format and no second one is
|
||||
invented.
|
||||
- **Order of remaining work**: finish the two in-flight pieces above, then
|
||||
the transcript screen's Android integration and the `transcript-bench.sh`
|
||||
comparison against Compose — the numbers the recommendation still lacks.
|
||||
- **DEFERRED — whether to commit to iris over Masonry for `ai-app`.** Waits
|
||||
on the bench numbers above; RUST.md's recommendation says what the
|
||||
measurements must show.
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
# The file explorer
|
||||
|
||||
Asked for by Bryan on 2026-09-03 and built the same day: browse a machine's
|
||||
directories, open files with the existing syntax highlighting and line
|
||||
numbers, edit behind a pencil, create through a modal, work over ssh, and
|
||||
open at the session's working directory.
|
||||
|
||||
This is the design, decision by decision with the reason and what was
|
||||
rejected, so that when one changes it is changed here rather than re-argued.
|
||||
The operational half — how to run it and what to produce on purpose — is in
|
||||
AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` /
|
||||
`FileViewer.kt` / `FileEditor.kt` / `FileLines.kt` are the app.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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`; 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 else would need a
|
||||
session to exist first.
|
||||
|
||||
### 2. One shell script per operation, over `Transport`, on both transports
|
||||
|
||||
Each operation is a small POSIX script handed to `sh -c script sh "$path" …`
|
||||
through `Transport::capture` (or `capture_with_input`). The path and every
|
||||
other value cross as **positional arguments**, never interpolated into the
|
||||
script — the same rule `import::find` follows and the same reason
|
||||
`ssh::quote` exists: a path is attacker-adjacent input in a server whose job
|
||||
is running commands. `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.
|
||||
|
||||
The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`,
|
||||
`sha256sum`, `chmod --reference`) — already what `import.rs` assumes, 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 cost is an `sh` process
|
||||
per operation locally, which is under a millisecond.
|
||||
|
||||
Rejected: a Rust SSH or SFTP library. The system `ssh` inherits
|
||||
`~/.ssh/config`, agents and jump hosts, and there is one place to configure a
|
||||
connection; SFTP would need a second.
|
||||
|
||||
### 3. The token can now name a path, and that is written down
|
||||
|
||||
Elsewhere the phone picks an **id** and the server resolves which file it
|
||||
names, so an enrolled token cannot become "read me an arbitrary file". The
|
||||
explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's
|
||||
Security section in these terms: the token already gates spawning a
|
||||
bypass-permissions agent in any directory on any machine a setup names, and
|
||||
that agent can already read and write every file its user can. The explorer
|
||||
is a shorter path to authority the token already holds, not new authority.
|
||||
The import rule stands where it is, because there a path was unnecessary and
|
||||
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`, with the same wording, because where
|
||||
a relative path would be depends on something the reader cannot see. Every
|
||||
listing answers with `pwd -P` of the directory it listed, so the phone
|
||||
navigates on a resolved absolute path — the parent is a string operation on
|
||||
that, and a `~` the session was spawned with is shown as what it turned out
|
||||
to be. The phone never resolves `..` itself.
|
||||
|
||||
### 5. A read is capped and typed, and every state it can be in has a word
|
||||
|
||||
`GET /setups/{id}/file` answers with one of `text` (content, size, mtime,
|
||||
sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over
|
||||
`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
|
||||
at), or the machine's own error.
|
||||
|
||||
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, drawn as one empty line numbered 1, which is
|
||||
what it is.
|
||||
|
||||
### 6. A write is conditional on what the reader saw
|
||||
|
||||
`PUT /setups/{id}/file` carries the sha256 the read reported. The script
|
||||
compares it against the file as it is now and exits distinctly if it differs;
|
||||
the server answers **409**. Agents edit files while people read them; this is
|
||||
the common case, not the exotic one, and silently overwriting an agent's edit
|
||||
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).
|
||||
|
||||
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1" … && mv -f`,
|
||||
with the bytes on stdin: a temp file and a rename, so a connection dropped
|
||||
mid-write leaves the old file whole rather than truncated, and
|
||||
`chmod --reference` keeps the mode a fresh file would lose (an executable
|
||||
script would stop being one). What this trades away is the inode, 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; accepted, 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` runs under `set -C` (noclobber) and `: > "$1"`, so a
|
||||
name that exists fails with the shell's own message rather than truncating
|
||||
somebody's file; `POST /setups/{id}/dir` is `mkdir --` with the same
|
||||
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 where 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`; 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, so a 20,000-line
|
||||
file in one `Text` measures all of it to draw a screenful.
|
||||
|
||||
**Every row is given the same width**, and that is what makes the shared
|
||||
horizontal scroll work. `horizontalScroll` is a node per row, and each one
|
||||
coerces the shared offset into *its own* range — content width less viewport
|
||||
— so with rows at their natural widths a short line's range is zero and it
|
||||
does not move at all while the long line beside it does. Each row also writes
|
||||
`maxValue` as it measures, so how far the file could be dragged was decided
|
||||
by whichever row measured last and changed as the list scrolled. The width is
|
||||
the longest line in columns times one character's advance, which is
|
||||
arithmetic rather than twenty thousand measurements because the face is
|
||||
monospace. A tab counts as eight columns and deliberately upwards —
|
||||
over-estimating leaves a little empty space past the longest line,
|
||||
under-estimating puts the end of that line out of reach — and the width is
|
||||
capped well under what `Constraints` can carry, so a minified file is a
|
||||
scroll that stops early rather than a crash. Reported by Iris on 2026-09-04
|
||||
as "it seems to affect different rows differently", which is precisely what a
|
||||
per-row range looks like.
|
||||
|
||||
**The stretch at the ends is one effect too**, shared by every row and
|
||||
rendered once on the box around the list — `horizontalScroll` makes its own
|
||||
per node otherwise, so only the line under the finger bent while the rest of
|
||||
the file sat still. It cannot be seen from this VM: the emulator's
|
||||
screenshots come back with no stretch in them at all, for any scrollable, so
|
||||
that one is checked on the phone.
|
||||
|
||||
**The numbers sit outside that box**, so they neither travel with the text
|
||||
nor bend with it. The rows leave a spacer where the numbers go and a
|
||||
`SubcomposeLayout` beside the list draws them. That is the one arrangement
|
||||
that keeps them level: which numbers exist *and* where each goes both come
|
||||
from the list's own `layoutInfo`, read in the measure block, and
|
||||
subcomposition happens during measurement — so it composes from the answer
|
||||
the list has just produced rather than one it read a frame ago. A column
|
||||
translated by the scroll position could not, since the translation would be
|
||||
current while the set of numbers was a composition behind, and during a fling
|
||||
the numbers would slide against their lines. Checked at about 1kHz through a
|
||||
fling: 23,520 row observations over 552 frames, every one with its number at
|
||||
exactly its own top. A consequence worth having: the numbers are outside the
|
||||
`SelectionContainer`, so copying part of a file gives the code rather than
|
||||
the code with a number in front of every line.
|
||||
|
||||
The gutter is right-aligned, its 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. 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 — one table, not two, so a language added for fences is
|
||||
added for files. A file with no entry is drawn plain.
|
||||
|
||||
### 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…` beside the field, aligned for the same reason as the
|
||||
viewer.
|
||||
|
||||
Save is a glyph in the header, **disabled** until the text differs from what
|
||||
was loaded — never hidden, since a control that comes and goes makes its own
|
||||
absence the signal. Back with unsaved changes asks, and says the edits will
|
||||
be lost. The explorer draws over the session, which deliberately has no
|
||||
`imePadding`, so the explorer's own box adds it.
|
||||
|
||||
### 10. The explorer draws over the session, and back closes it first
|
||||
|
||||
`Screen.Session` in `AppRoot` gains a `files: FilesTarget?`. When set, the
|
||||
`FilesScreen` is composed **on top of** the session in the same `Box`, and
|
||||
the session stays composed under it: its event stream keeps flowing, its
|
||||
scroll position and draft stay where they were, and returning from a file
|
||||
costs nothing. Back — the button and the platform gesture — clears `files`
|
||||
when set and goes to the list otherwise. Inside the explorer the same back
|
||||
steps one level: editor → viewer (with the unsaved question) → listing →
|
||||
parent directory, and only from the starting directory does it close. "Back
|
||||
returns; it does not exit."
|
||||
|
||||
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. 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, 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: `md-folder` U+F024B (the header button and
|
||||
directory rows), `md-plus` U+F0415, `md-pencil` U+F03EB,
|
||||
`md-content_save` U+F0193, `md-file_outline` U+F0224. The folder and the plus
|
||||
are the same codepoints dev-updater uses and must not drift from it, as the
|
||||
cog and the refresh arrow already must not. All five were looked up in Nerd
|
||||
Fonts' own `glyphnames.json` rather than copied from memory, which is the
|
||||
check that a codepoint means the glyph its comment names.
|
||||
|
||||
**The folder button sits between the usage chart and the cog**, so the header
|
||||
reads widest scope to narrowest and the cog stays at the end where every
|
||||
other screen keeps it. Asked for in that order by Iris on 2026-09-03.
|
||||
|
||||
### 13. The render report moved, and the benches moved with it
|
||||
|
||||
The speedometer went; the report is a "Copy render timings" row in
|
||||
`SessionSettingsDialog`, where the session's other about-the-session controls
|
||||
already are. **Moving it is where the no-coordinate-taps rule got enforced**
|
||||
(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
|
||||
|
||||
## HTTP surface
|
||||
|
||||
In `routes.rs`'s module doc with the rest. 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}]}
|
||||
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; 409 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.
|
||||
|
||||
## What the measurements said (2026-09-04)
|
||||
|
||||
Taken on the emulator in a **debug** build, which runs Compose at a fraction
|
||||
of release speed and renders in software — so these rank correctly against
|
||||
each other and are pessimistic in absolute terms. Generated Rust, through the
|
||||
app's own render report.
|
||||
|
||||
| file | lines | scan + cut | scan per keystroke | worst frame record |
|
||||
|--------|--------|------------|--------------------|--------------------|
|
||||
| 32 kB | 917 | 11ms | 10ms | 183ms |
|
||||
| 128 kB | 3,633 | -- | 40ms | 2,027ms |
|
||||
| 1 MB | 28,660 | 460ms | -- | -- |
|
||||
|
||||
Three things followed.
|
||||
|
||||
**The viewer's scan had to leave the main thread.** Decision 8 said "off the
|
||||
main thread" and the first version did it in a `remember` inside the
|
||||
composition, which is not that: 460ms of frozen screen at the size the server
|
||||
is willing to send, long enough that the accessibility tree cannot be read —
|
||||
which is exactly what "the app has stopped" looks like from outside.
|
||||
|
||||
**`FILE_LIMIT` at 1 MiB is right for reading.** Time to first line for a
|
||||
1 MiB file, tap to text on screen, was **2.4s** against the sandbox — 1.2s of
|
||||
which is that server's deliberate `--delay`, and 460ms the scan. The transfer
|
||||
is not what dominates, so the route gains nothing from streaming.
|
||||
|
||||
**Edit mode needed a cap, and not the one that was expected.** The plan
|
||||
expected to be deciding a size below which highlighting stays on. That is not
|
||||
the cost that matters: highlighting 128 kB costs 40ms a keystroke, which is
|
||||
survivable, while laying the same text out in one `BasicTextField` costs two
|
||||
seconds — characters typed into it were dropped, and a 1 MiB file stopped the
|
||||
app responding altogether. Since every arrangement of a single text field
|
||||
pays that, switching highlighting off would have saved nothing. So
|
||||
`EDIT_LIMIT` is **32 kB**, the largest size measured as usable, and above it
|
||||
the pencil is disabled with the reason said in words beside it — a disabled
|
||||
control teaches what the thing can do but cannot say why it is off, and a
|
||||
reader who cannot edit a file they can plainly read would otherwise conclude
|
||||
the app is broken.
|
||||
|
||||
Reading is unaffected: the viewer opens and scrolls the 1 MiB file fine,
|
||||
because it is a `LazyColumn` of lines rather than one text object. That
|
||||
difference is the whole of decision 8.
|
||||
|
||||
## Later, deliberately not now
|
||||
|
||||
- Delete, rename and move. Destructive controls belong here eventually, shown
|
||||
and confirmed rather than hidden, but none 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.
|
||||
- Search within a file, and find-in-files.
|
||||
- **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The viewer
|
||||
already draws a file as rows and stays fast on a megabyte; an editor built
|
||||
the same way — a field per line, or a field over the lines on screen —
|
||||
would not pay Compose's cost of laying out one enormous text. It is a good
|
||||
deal more than this feature needed, and 32 kB covers the config files,
|
||||
notes and ordinary source files anybody edits from a phone.
|
||||
@@ -1,246 +0,0 @@
|
||||
# Replacing the code highlighter with a scanner of our own
|
||||
|
||||
Written 2026-09-03 for whichever session does the work. Bryan decided on
|
||||
this after the assessment below; `TRANSCRIPT_RENDERING.md` records the
|
||||
measurements that led here and stays the record of the transcript work,
|
||||
and `PLAN.md` stays the design source of truth for the app as a whole.
|
||||
Nothing here contradicts either. Work goes on `main`, committed and pushed
|
||||
when it is verified, as usual.
|
||||
|
||||
## Why
|
||||
|
||||
The app colours code in two places -- a fenced block in a reply and the
|
||||
command in a tool call's card -- through one function, `highlight` in
|
||||
`app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt`, backed by
|
||||
the library `dev.snipme:highlights` 1.1.0. That library finds comments
|
||||
before it knows the language, using `//`, `#` and `/* */` for every
|
||||
language, then suppresses keywords and strings under whatever it found;
|
||||
it pairs quotes by ordinal per quote character; and it pairs `/*` with
|
||||
`*/` by ordinal too. Measured against the library directly on 2026-09-03:
|
||||
|
||||
- Any language: `//` inside a URL comments out the rest of the line, and a
|
||||
string holding a URL loses its colour inside the comment.
|
||||
- Rust: every attribute line, `#[derive(Debug)]`, is greyed as a comment.
|
||||
- Kotlin: a `#` inside a string (`"#FF0000"`) comments out the rest of the
|
||||
line; an apostrophe inside a double-quoted string (`"don't"`) opens a
|
||||
bogus single-quoted string that runs to the next apostrophe in the block.
|
||||
- Shell: `x '*/a/*'` produced a span whose end preceded its start, which
|
||||
crashed the app until `highlight` started dropping such spans.
|
||||
|
||||
Upstream merged a one-line fix for the last of these in August 2026 and has
|
||||
not released it; nothing else moved. Post-processing the library's output
|
||||
cannot restore what it already suppressed, and forking it means rewriting
|
||||
its analyzer, which is the part that is wrong. No maintained pure-Kotlin
|
||||
alternative exists on Maven Central; tree-sitter would cost the NDK on
|
||||
every machine that builds the APK and one to five megabytes per language
|
||||
per ABI. So the highlighter becomes ours: one sequential scanner and a
|
||||
table of languages. The rule in `prefer-libraries-for-specs` still holds
|
||||
in general; this is the case it allows, where the only library is a worse
|
||||
hand-rolled scanner than the one we would write.
|
||||
|
||||
Counting fence words across the Claude Code transcripts on the dev VM
|
||||
says which languages matter: shell in its variants 181, kotlin 119, rust
|
||||
98, ron 67, then toml, python, java, js, groovy, json and diff at three to
|
||||
eight each. RON, TOML and fish are not known to the library at all today,
|
||||
so those fences are drawn plain; the scanner colours them from day one.
|
||||
|
||||
## Shape
|
||||
|
||||
Two new files under `app/androidApp/src/main/kotlin/com/example/aiapp/`,
|
||||
and the library removed.
|
||||
|
||||
**`Highlighter.kt`** holds the scanner, the palette type, and `highlight`.
|
||||
The signature stays `fun highlight(code: String, language: Language?):
|
||||
AnnotatedString`, returning plain text for a null language, so the two
|
||||
callers and the cache do not change shape. It is not a composable and
|
||||
takes no colour from the theme, for the reason `CodeFence.kt` already
|
||||
states: `ParsedReplies.warm` runs it off the drawing thread. The palette
|
||||
is a plain `SyntaxPalette` value class of our own -- keyword, string,
|
||||
literal, comment, metadata, punctuation, mark, as `Color` -- returned by
|
||||
`catppuccinSyntax()` in `Theme.kt`, which currently builds the library's
|
||||
`SyntaxTheme` from ARGB ints. The library's separate multiline-comment
|
||||
colour collapses into comment; the palette already gave both `Overlay0`.
|
||||
|
||||
The scanner is one pass over the code with a small state: at each
|
||||
position it is in a line comment, a block comment (with a nesting depth
|
||||
where the language nests them), a string (with its closing delimiter and
|
||||
whether backslash escapes apply), or ordinary code. In ordinary code it
|
||||
recognises, in this order, a comment opener, a string opener, an
|
||||
attribute, a number, a word, and finally single punctuation or mark
|
||||
characters. Every span it emits is produced by advancing an index
|
||||
forward, so spans are non-overlapping and ordered by construction and the
|
||||
reversed-range filter goes with the library. Spans map onto the palette
|
||||
as: keyword, string, literal (numbers), comment, metadata (attributes and
|
||||
annotations), punctuation (`, . : ;`) and mark (`( ) = { } < > - + [ ] |
|
||||
&`). Those two character sets are the library's, kept so nothing the
|
||||
reader is used to changes colour except where it was wrong; punctuation
|
||||
and marks are only coloured in ordinary code, never inside a string or a
|
||||
comment, which is the one visible difference on correct input (a `.` in a
|
||||
URL inside a string used to take the punctuation colour).
|
||||
|
||||
A word is `[A-Za-z_][A-Za-z0-9_]*` and is a keyword if the language's set
|
||||
contains it. A number is a word starting with a digit and continuing
|
||||
through letters, digits, `_` and `.`, which covers `0xFF`, `1_000`,
|
||||
`1u32` and `3.14` without a grammar. A date in TOML comes out as digits
|
||||
with marks between; leave that, it is untidy rather than misleading.
|
||||
|
||||
**`Languages.kt`** holds `enum class Language` and a `Rules` value per
|
||||
member. Keep the vocabulary the code already uses: the enum replaces the
|
||||
library's `SyntaxLanguage` under the name `Language`, and `fenceLanguage`
|
||||
in `CodeFence.kt` keeps its name and its alias table, retargeted. `Rules`
|
||||
is data, not code, and is what makes a new language a table row:
|
||||
|
||||
- `lineComments`: the tokens that open a comment to end of line, and
|
||||
whether they count only at the start of a word. Shell and fish need
|
||||
the word-start rule, since `$#`, `${#x}` and `a#b` are not comments; a
|
||||
`#` counts there when it is preceded by nothing, whitespace, or one of
|
||||
`;`, `|`, `&`, `(`. Python, Ruby, Perl, TOML: `#` anywhere. C-family,
|
||||
Kotlin, Rust, RON, Go, Swift, Dart, JavaScript, TypeScript, Java,
|
||||
C#, PHP: `//`; PHP also `#`.
|
||||
- `blockComment`: the pair, or none, and whether it nests. `/* */` for
|
||||
the C-family and friends; Rust, Kotlin and RON nest; C, Java,
|
||||
JavaScript, Go do not. Python, Ruby, shell, fish, TOML: none.
|
||||
- `quotes`: each string form as opener, closer, and whether backslash
|
||||
escapes the closer. Double quotes with escapes nearly everywhere;
|
||||
single quotes with escapes in the C-family (a character literal reads
|
||||
fine as a string); single quotes with *no* escapes in shell and TOML;
|
||||
fish single quotes escape only `\'` and `\\`; triple quotes in Kotlin
|
||||
(`"""`, no escapes), Python (`"""` and `'''`) and TOML (`"""` and
|
||||
`'''`). Longest opener wins, so `"""` is tried before `"`.
|
||||
- Two string forms need a line of code rather than a row, and belong in
|
||||
the scanner behind a flag on `Rules`. Rust and RON raw strings:
|
||||
optional `b`, then `r`, then `n` hashes, then `"`, closing at `"`
|
||||
followed by `n` hashes, no escapes. Rust lifetimes: a `'` followed by
|
||||
a backslash or by one character and a `'` is a character literal;
|
||||
otherwise it is a lifetime or label and no string starts. Without that
|
||||
rule `'a` opens a string that runs to the next apostrophe.
|
||||
- `attributes`: what opens a metadata span. `@` followed by a word for
|
||||
Kotlin, Java, Dart, Swift, TypeScript, PHP, Python (decorators);
|
||||
`#[` or `#![` through the matching `]` for Rust and RON; a `#` at the
|
||||
start of a line through end of line for C and C++ (preprocessor
|
||||
directives, which the library greyed out as comments); a `[` at the
|
||||
start of a line through `]` for TOML (table headers). Nothing for the
|
||||
rest.
|
||||
- `keywords`: the set. Bring the lists over from the library's
|
||||
`SyntaxTokens.kt` (Apache-2.0; say so in a comment at the table), for
|
||||
every language the alias table names today, so no fence that is
|
||||
coloured now turns plain. Drop entries that are not plain words --
|
||||
Kotlin's `as?`, `!in`, `!is`; Swift's `#if` family; Ruby's `defined?`;
|
||||
CoffeeScript's `=` and `->` -- since the word scanner cannot reach
|
||||
them and the library only matched them by luck. Add rows for RON
|
||||
(`true false Some None inf NaN`), TOML (`true false inf nan`), fish
|
||||
(`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`) and JSON (`true false null`). Add `ron`, `toml`, `fish`
|
||||
and `json` to the alias table in `CodeFence.kt`.
|
||||
|
||||
The `streaming` arrangement stays as it is: a fence still being written
|
||||
is drawn plain and coloured when it freezes. That decision was made
|
||||
against a lexer costing 174ms for two hundred lines; a linear scanner may
|
||||
make live colouring affordable, but that is a follow-up to measure with
|
||||
`app/stream-bench.sh`, not something to assume here.
|
||||
|
||||
## Files
|
||||
|
||||
- `Highlighter.kt`, new: scanner, `SyntaxPalette`, `highlight`. The
|
||||
`DebugStats.timed("code highlighted")` wrapper stays, as the number the
|
||||
before-and-after is read from.
|
||||
- `Languages.kt`, new: `Language`, `Rules`, the rows and keyword sets.
|
||||
- `CodeFence.kt`: remove `highlight` and the library imports; retarget
|
||||
`fenceLanguage` and `FENCE_LANGUAGES`; add the four aliases.
|
||||
- `ToolInput.kt`: `SyntaxLanguage` becomes `Language` in `ToolInput` and
|
||||
`SUBJECTS`. `ToolInputView` keeps calling `highlight` uncached.
|
||||
- `Markdown.kt`: the import at line 58 and the signature of
|
||||
`ParsedReplies.highlighted`. The cache key `"$language\n$code"` works
|
||||
unchanged with an enum.
|
||||
- `Theme.kt`: `catppuccinSyntax()` returns `SyntaxPalette` of `Color`s
|
||||
rather than the library's theme of ints.
|
||||
- `app/gradle/libs.versions.toml` and `app/androidApp/build.gradle.kts`:
|
||||
remove `highlights` and its comment.
|
||||
- `app/highlights-repro.sh`: delete. It exists only to interrogate the
|
||||
library, and its cases move into the test below; this is the harness
|
||||
being replaced by a better one, not thrown away.
|
||||
- `app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt`,
|
||||
new: see next section.
|
||||
- Docs, in the same commit: in `TRANSCRIPT_RENDERING.md` rewrite the
|
||||
fences paragraph's mention of the library, replace the "highlights
|
||||
1.1.0 finds comments before it knows the language" bullet with a short
|
||||
description of the scanner and where its rows live, and drop item 2 of
|
||||
"What is next". In `AGENTS.md`, add `:androidApp:testDebugUnitTest` to
|
||||
the app line under "Checking your work". Prune rather than append.
|
||||
|
||||
## Tests
|
||||
|
||||
The app has no unit test source set today. The scanner is pure logic
|
||||
with no Android imports, which is exactly the case CODE_RULES names for
|
||||
adding tests, and the cost of the harness is one line,
|
||||
`testImplementation(kotlin("test"))`, run by
|
||||
`./gradlew :androidApp:testDebugUnitTest`. Keep `Highlighter.kt` and
|
||||
`Languages.kt` free of Android and Compose imports other than
|
||||
`AnnotatedString`, `SpanStyle` and `Color` (which are plain JVM classes),
|
||||
so the test runs on the JVM without Robolectric. Better still, have the
|
||||
scanner return a list of (range, kind) spans and let `highlight` be the
|
||||
thin wrapper that turns them into an `AnnotatedString`: the test then
|
||||
asserts on the list, which prints legibly on failure, and the test does
|
||||
not depend on Compose loading at all.
|
||||
|
||||
Cases, each asserting the spans against the code so a failure prints
|
||||
what was coloured:
|
||||
|
||||
- The four inputs from the deleted script: `x '*/a/*'` (shell), the
|
||||
`find . -path '*/.git/*'` line, `curl https://example.com/x && echo
|
||||
done`, and `val url = "https://example.com"\nfun f() = 1`.
|
||||
- `#[derive(Debug)]` then a struct in Rust: metadata, then keywords.
|
||||
- `"#FF0000"` and `"don't"` in Kotlin: two strings, no comment.
|
||||
- `'a` as a Rust lifetime beside `'x'` as a character: one string.
|
||||
- `r#"a "quoted" b"#` in Rust: one string.
|
||||
- Nested `/* a /* b */ c */` in Rust and Kotlin: one comment to the end;
|
||||
the same in C: comment ends at the first `*/`.
|
||||
- `${#x} $# a#b # real` in shell: one comment, starting at the last `#`.
|
||||
- `"""a "b" c"""` in Kotlin: one string.
|
||||
- A TOML table header and a key with a `#` in a quoted value.
|
||||
- A language the alias table does not know: `highlight` returns the
|
||||
plain string.
|
||||
- Every emitted span is inside the code and starts before it ends, over
|
||||
a handful of nasty inputs (unterminated string, unterminated block
|
||||
comment, a lone `'`, empty code). The scanner must never throw; an
|
||||
unterminated thing runs to the end of the code.
|
||||
|
||||
## Verification
|
||||
|
||||
1. From `app/`: `. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
||||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
||||
:androidApp:testDebugUnitTest`. Lint must stay clean; an unused
|
||||
dependency or import it reports is a finding.
|
||||
2. Timing. `TRANSCRIPT_RENDERING.md` gives the library at 174ms for a
|
||||
two-hundred-line Kotlin fence on the emulator. Regenerate
|
||||
`/tmp/longfence.md` from that description and read `code highlighted`
|
||||
from the render report after `app/transcript-bench.sh`; expect single
|
||||
milliseconds. Run `app/transcript-bench.sh` and `app/stream-bench.sh`
|
||||
before and after, and put the report lines named in that doc's item 3
|
||||
into the commit message.
|
||||
3. Look at it. `app/run-android.sh`, send `/tmp/fixture.md` through
|
||||
`app/ui-sandbox.sh` (both described in `TRANSCRIPT_RENDERING.md`), and
|
||||
screenshot a fence holding a URL in a string, a Rust attribute, a
|
||||
shell line with a glob and a `$#`, a RON fence, and a TOML fence. Check
|
||||
a tool card's Bash command still colours, since that is the other
|
||||
caller. Check a fence in an unknown language is plain, and that a
|
||||
fence still streaming is plain and colours when the next block starts.
|
||||
Per the code rule about trying a fix on the half that cannot fail,
|
||||
look at a fence the change had no reason to touch -- a plain Python
|
||||
function -- and confirm it looks as it did.
|
||||
4. Commit on `main` with `git add -A`, and push.
|
||||
|
||||
## Traps
|
||||
|
||||
- `fenceContent` is shared by the drawing and the warming and must stay
|
||||
the single extraction; nothing here should touch it.
|
||||
- The `Color` constructor in `highlight` currently ORs in an alpha byte
|
||||
because the library's ints had none. With `Color` values in the palette
|
||||
that goes away; do not carry the mask over.
|
||||
- Keyword lists are per language in the library's `DEFAULT` case merged
|
||||
into one; there is no `DEFAULT` here, since a null language is plain.
|
||||
- The two callers pass different things: fences pass what the alias
|
||||
table answered for the info word, tool cards pass `SHELL` for Bash and
|
||||
null for everything else. Both must keep compiling without a cast.
|
||||
- ktfmt will reflow the keyword tables; let it.
|
||||
@@ -0,0 +1,277 @@
|
||||
# iris: notable public API changes
|
||||
|
||||
For Iris to read on her own time. Each entry is a change to iris's public
|
||||
surface that a widget author or app author would notice: a trait method
|
||||
added, removed or re-shaped; a type that callers construct differently; a
|
||||
capability that moved. Small and trivial changes do not go here.
|
||||
|
||||
An entry gives the date, what changed, why, and a short before/after where
|
||||
it helps judge the change without the session that made it. Newest first.
|
||||
|
||||
## 2026-09-05: `transcript_ui::build_tree` (RUST.md's E4)
|
||||
|
||||
`transcript_ui::build` claimed the whole window (`ui_state.set_root(tree)`)
|
||||
as its last step, which is right for a window that *is* the transcript
|
||||
screen (the winit example, an eventual Android cdylib) and wrong for the
|
||||
desktop app, which puts a session list beside it. `build_tree` is `build`
|
||||
minus that last step: it returns `(TranscriptScreen, StrongWidget)` instead
|
||||
of just `TranscriptScreen`, and the caller decides where the tree goes —
|
||||
into `ui_state.set_root`, or into a `WidgetPtr` alongside something else
|
||||
(`iris/desktop-app`'s `rebuild_transcript`). `build` is now one line calling
|
||||
`build_tree` and doing the `set_root` itself, so existing callers are
|
||||
unaffected.
|
||||
|
||||
```rust
|
||||
// before, and still available, for a caller that wants to *be* the window:
|
||||
let screen = transcript_ui::build(rsc, &mut ui_state, rows);
|
||||
|
||||
// new, for a caller embedding the screen beside something else:
|
||||
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
||||
some_widget_ptr(rsc).set(tree);
|
||||
```
|
||||
|
||||
|
||||
## 2026-09-05: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5)
|
||||
|
||||
New public type, `iris::sense::DragArbiter`. Why: a widget author who
|
||||
registers both a list-level pan and a row-level drag-to-select on the same
|
||||
touch gesture has no way to arbitrate between them — `core/src/sense.rs`'s
|
||||
`run_sensors` always gives the innermost layer first refusal, so the inner
|
||||
one wins every frame it is pressed, not just the frame the press started
|
||||
(this is exactly what left transcript-ui's touch-drag panning unreachable
|
||||
until now). `DragArbiter` is one small state machine, one instance per
|
||||
gesture surface (a whole list, not per row), that a caller drives with its
|
||||
own `press_start`/`update`/`release` calls and a caller-supplied `Instant`
|
||||
(so it is unit-testable without a real clock or a render harness). It
|
||||
decides the way Android itself does: an ordinary vertical drag pans
|
||||
immediately; a stationary press held `LONG_PRESS` (500ms) starts a
|
||||
selection, which any further drag then extends; a horizontal drag while
|
||||
something is already selected extends it immediately, skipping the wait.
|
||||
|
||||
```rust
|
||||
// One per list, held alongside whatever state coordinates the rows:
|
||||
let mut arbiter = DragArbiter::new();
|
||||
|
||||
// On press-down:
|
||||
arbiter.press_start(pos, Instant::now(), already_selected);
|
||||
// Every frame the button/finger stays down:
|
||||
match arbiter.update(pos, Instant::now()) {
|
||||
DragOutcome::Pan(dy) => list.scroll(-dy),
|
||||
DragOutcome::SelectStart => selection.begin(...),
|
||||
DragOutcome::SelectExtend => selection.extend(...),
|
||||
DragOutcome::Undecided => {}
|
||||
}
|
||||
// On release:
|
||||
arbiter.release();
|
||||
```
|
||||
|
||||
`transcript-ui`'s `Selection::drag` (`transcript-ui/src/selection.rs`) is
|
||||
the reference caller: every row's `CursorSense::click_or_drag() |
|
||||
CursorSense::unclick()` handler routes through one `Selection`-owned
|
||||
arbiter instead of calling `begin`/`extend` directly, so a drag that starts
|
||||
on a row's own rendered text now pans the list correctly instead of
|
||||
always starting a selection. 8 new unit tests in `iris/src/sense.rs`'s
|
||||
`drag_arbiter_tests` module.
|
||||
|
||||
## 2026-09-05: `SpanStyle`, per-range text styling (RUST.md's I5)
|
||||
|
||||
A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size,
|
||||
family, ...) for its whole string, applied via `push_default` into parley's
|
||||
ranged builder. `SpanStyle` is a second, optional layer: a byte range plus
|
||||
whichever of colour/family/font size/bold/italic/underline it overrides,
|
||||
pushed with parley's own `push(property, range)` instead. Why: a transcript
|
||||
row's markdown (a heading, **bold**, `inline code`, a link) all inside one
|
||||
wrapped paragraph needs each to carry its own look while the paragraph
|
||||
still wraps and selects as a single buffer — the thing `masonry`'s
|
||||
`TextArea` cannot do (`StyleSet` is one style for the whole editor,
|
||||
`text_area.rs:43-44`'s `// TODO: RichTextInput`), and the reason this
|
||||
existed at all.
|
||||
|
||||
```rust
|
||||
let (text, spans) = transcript_ui::markdown::render_markdown(src, 16.0);
|
||||
wtext(text)
|
||||
.spans(spans) // new: TextBuilder::spans, on both Text and TextEdit
|
||||
.editable(EditMode::MultiLine)
|
||||
.add(rsc);
|
||||
```
|
||||
|
||||
Two things a widget author should know before reaching for it:
|
||||
|
||||
- **Call `.spans()` before or after `.editable()`, both work** — the field
|
||||
lives on `TextBuilder` itself, not either output type, and both
|
||||
`TextOutput::run` and `TextEditOutput::run` apply it to the buffer via
|
||||
`TextBuffer::set_spans`. **These two call sites are a pair**: adding a
|
||||
third `TextBuilderOutput` impl without also calling `set_spans` there
|
||||
reproduces the exact bug this box shipped once already (spans silently
|
||||
dropped for `TextEdit`, found only by screenshotting, not by any test —
|
||||
`markdown.rs`'s own unit tests check string/range logic, which is
|
||||
correct in isolation and proves nothing about whether the render path
|
||||
ever sees it).
|
||||
- **Colour is now per-glyph, not per-buffer.** `PlacedGlyph` gained a
|
||||
`color: UiColor` field (from parley's own per-run `Style::brush`), and
|
||||
`Painter::glyphs` draws each glyph in its own colour instead of
|
||||
`RenderedText::color` uniformly. `RenderedText::color` still exists (the
|
||||
buffer's *base* colour, for a caller that wants it as a whole, e.g. to
|
||||
tint a cursor) but no longer drives what a glyph actually renders as.
|
||||
|
||||
## 2026-09-05: accessibility names via AccessKit (RUST.md's I4)
|
||||
|
||||
`.label()` (already in `trait_fns.rs`, previously unused anywhere in-tree)
|
||||
is now load-bearing: it's the one thing that puts a widget in the AccessKit
|
||||
tree `iris_core::ui::access::AccessTree` builds and both backends push
|
||||
out. A widget author who wants a control to be findable by name (and
|
||||
tappable by name, through `ui-trace`/a real screen reader) calls `.label()`
|
||||
on it; nothing else is required, and a widget nobody labels is invisible
|
||||
to this system at zero cost, not just zero UI.
|
||||
|
||||
```rust
|
||||
let button = rect(Color::LIME)
|
||||
.on(CursorSense::click(), move |_, rsc| { ... })
|
||||
.label("Add task"); // now findable by uiautomator/AccessKit as "Add task"
|
||||
```
|
||||
|
||||
Two new things a widget author might touch directly:
|
||||
|
||||
- **`Widget::access_role(&self) -> accesskit::Role`**, default `Unknown`.
|
||||
Override it if your widget has a real platform equivalent —
|
||||
`TextEdit` now returns `TextInput`/`MultilineTextInput` by `EditMode`.
|
||||
Only consulted for a widget that also has a `.label()`; an unlabelled
|
||||
widget's `access_role` is never called.
|
||||
- **`Widgets::named() -> impl Iterator<Item = WidgetId>`** — every widget
|
||||
with an explicit label, for anything else that wants to walk the same
|
||||
set `AccessTree` does.
|
||||
|
||||
Nothing about `Painter`, `draw`, or the layout/move machinery changed —
|
||||
this sits entirely beside them, reading `resolved_region`'s output rather
|
||||
than participating in producing it.
|
||||
|
||||
## 2026-09-05: `List`, a virtualised bottom-anchored list (RUST.md's I3)
|
||||
|
||||
A new widget, `iris::widget::List` (`iris/src/widget/list.rs` -- read its
|
||||
module doc first), for the transcript's kind of screen: variable-height
|
||||
rows, keyed by a `u64`, composed only while visible, moved rather than
|
||||
re-laid-out on scroll, a scroll anchor that survives a row inserted above
|
||||
it, "more" sentinels at each end, and "hold the edge nearest the tap" when
|
||||
a row's height changes (`note_tap`, resolved in the layout pass).
|
||||
|
||||
```rust
|
||||
let mut list = List::new(Axis::Y);
|
||||
list.push_back(ListRow::new(key, row_widget)); // O(1)
|
||||
list.push_front(ListRow::new(older_key, row)); // O(1), anchor unaffected
|
||||
list.set_more_before(Some(spinner_widget)); // sentinel, drawn at the edge
|
||||
list.note_tap(viewport_y); // before mutating a row's height
|
||||
let (top, bottom) = list.extent(key).unwrap(); // last frame's on-screen box, if visible
|
||||
```
|
||||
|
||||
Built entirely out of existing primitives (`Painter::widget`/`widget_within`/
|
||||
`reposition`/`draw_twice`, and `draw_inner`'s own old-children diffing) --
|
||||
no new mechanism was added to the render core for it. One correctness
|
||||
lesson worth reading even for other widgets: a row that fills whatever
|
||||
region it is offered (`Rect`, `is_size_independent`) cannot be measured at
|
||||
a throwaway oversized region and then merely `reposition`ed into place --
|
||||
`reposition` only ever writes an offset, never a size, so the oversized
|
||||
primitive stays oversized. `List` fixes this by caching each row's real
|
||||
height once measured and placing an already-known row directly at its
|
||||
exact box; see `list.rs`'s `place` for the full reasoning and
|
||||
`a_fill_shaped_background_is_not_left_oversized` for the regression test.
|
||||
|
||||
## 2026-09-05: a second backend (android-view), and what moved to make room for it
|
||||
|
||||
RUST.md's I2. Three changes a widget or app author would notice, all in
|
||||
service of the same thing: `default` (winit) and the new `android`
|
||||
(android-view) backends sharing what does not depend on windowing.
|
||||
|
||||
- **`Selector`/`Selectable`'s bound changed from `Rsc::State:
|
||||
HasDefaultUiState` to `Rsc::State: FocusHost`** (new trait, `attr.rs`).
|
||||
`HasDefaultUiState` still exists and still works — `default/attr.rs` now
|
||||
implements `FocusHost` for anything that has it — so a winit app's
|
||||
existing code is unaffected. An Android app implements `FocusHost` via
|
||||
`HasAndroidUiState` instead. Affects only an app that referenced
|
||||
`HasDefaultUiState` directly at a `Selectable`/`Selector` call site
|
||||
rather than through `.attr::<Selectable>(())`, which nothing in-tree
|
||||
does.
|
||||
- **`Tasks::init` takes `Arc<dyn RequestRedraw>` instead of
|
||||
`Arc<winit::window::Window>`.** `RequestRedraw` (`task.rs`) is one method,
|
||||
`fn request_redraw(&self)`; `winit::window::Window` implements it
|
||||
(`default/render.rs`), so `Tasks::init(window)` at a call site is
|
||||
unchanged by inference. Only matters if something constructed a `Tasks`
|
||||
directly rather than through `DefaultRsc`/`AndroidRsc`.
|
||||
- **`TextEdit::apply_event`/`TextInputResult` are `#[cfg(not(target_os =
|
||||
"android"))]`** — they take a `winit::event::KeyEvent`, which does not
|
||||
exist on Android; `android/input.rs` drives the same primitives
|
||||
(`backspace`/`delete`/`motion`/`insert`, all still unconditional) from
|
||||
`ndk::event::Keycode` directly instead. New unconditional getters on the
|
||||
way: `TextEdit::text()`/`selection_range()`/`caret()`, and
|
||||
`TextEditCtx::delete_byte_range`/`set_cursor_byte` — the primitives
|
||||
`android/ime.rs`'s `InputConnection` bridge needed and that were not
|
||||
previously exposed publicly.
|
||||
|
||||
## 2026-09-04: `Widget::draw` reports the size it used; `desired_width`/`desired_height` are gone
|
||||
|
||||
A widget used to implement three methods (`draw`, `desired_width`,
|
||||
`desired_height`); it now implements one, `fn draw(&mut self, painter: &mut
|
||||
Painter) -> Size`, which draws into `painter.region()` and returns how much
|
||||
of it was used. Why: the two extra methods routinely re-simulated what
|
||||
`draw` was about to do anyway (`Span::desired_ortho` copied its own draw
|
||||
loop to get cross-axis sizing right) — one visit per widget per frame
|
||||
instead of up to three. A container that needs a child's size before
|
||||
placing it (alignment, centering) draws the child once at a provisional
|
||||
region, reads the returned `Size`, and calls the new `Painter::reposition`
|
||||
to move it into its final spot — an O(1) offset write, not a second draw. A
|
||||
widget whose drawn output never depends on the size it's given (a
|
||||
fixed-size `Rect`, a decoded `Image`) overrides the new `fn
|
||||
is_size_independent(&self) -> bool { false }` to `true`, which skips
|
||||
redrawing it when only its offered region changes shape.
|
||||
|
||||
```rust
|
||||
// before
|
||||
fn draw(&mut self, painter: &mut Painter) { /* ... */ }
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||
|
||||
// after
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
|
||||
```
|
||||
|
||||
`SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
||||
design, the move-offset mechanism this shipped alongside, and the file
|
||||
list.
|
||||
|
||||
## 2026-09-04: texture pipeline rebuilt off the binding array
|
||||
|
||||
`Textures`/`TextureHandle`, `GlyphPrimitive`, and `UiRenderNode::new` all
|
||||
changed shape. Why: the old pipeline bound every texture ever drawn in one
|
||||
`binding_array<texture_2d<f32>>` and asked every device, unconditionally,
|
||||
for `VK_EXT_descriptor_indexing` — a real share of Android GPUs lack it,
|
||||
and it failed outright on the Android emulator's software Vulkan. See
|
||||
TEXTURES.md's "Recommended shape" and "Implemented, 2026-09-04".
|
||||
|
||||
- **`UiRenderNode::new` drops its `limits: UiLimits` parameter, and
|
||||
`UiLimits` is gone.** Before: `UiRenderNode::new(&device, &queue,
|
||||
&config, UiLimits::default())`. After: `UiRenderNode::new(&device,
|
||||
&queue, &config)`. Nothing replaces it — there are no more
|
||||
binding-array limits to size.
|
||||
- **`src/default/render.rs`'s device request asks for no features and no
|
||||
binding-array limits.** Before: `required_features:
|
||||
Features::TEXTURE_BINDING_ARRAY | Features::PARTIALLY_BOUND_BINDING_ARRAY
|
||||
| Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`
|
||||
plus two `max_binding_array_*` limits. After: `Features::empty()` (the
|
||||
`DeviceDescriptor` default) and only `max_buffer_size` set, which was
|
||||
never about the binding array.
|
||||
- **`TextureHandle` has no `primitive()` method any more**; a caller
|
||||
outside `iris` shouldn't have been calling it (it fed the old renderer's
|
||||
internals), but if something did: use `image_index()` for a standalone
|
||||
image's bind-group index. There is no equivalent for a page — a page has
|
||||
no bind group of its own now, see below.
|
||||
- **`GlyphPrimitive` has no public constructor from a struct literal.**
|
||||
Before: `GlyphPrimitive { uv_min, uv_max, view_idx, sampler_idx, color,
|
||||
flags }`. After: `GlyphPrimitive::new(uv_min, uv_max, layer, color,
|
||||
flags)` — one `layer` (the shared atlas array's layer) instead of a
|
||||
`view_idx`/`sampler_idx` pair, since a page is now a layer of one array
|
||||
texture rather than its own bound texture.
|
||||
- **A widget author drawing images is unaffected**: `Painter::texture`/
|
||||
`texture_at`/`texture_within` and `Textures::add` keep their signatures.
|
||||
What changed underneath is that each standalone image now gets its own
|
||||
`wgpu::BindGroup` and draw call instead of a slot in the shared array —
|
||||
invisible from the widget API, visible only in `UiRenderNode`'s internals
|
||||
and in `iris`'s device requirements.
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
# iris: known problems and things still to build
|
||||
|
||||
Iris's own list for the library, recorded 2026-09-04 in her words where it
|
||||
matters, so the agents working through RUST.md pick these up in a sensible
|
||||
order rather than rediscovering them. Each item says where it sits in the
|
||||
order and what "done" looks like. Tick and date them in place.
|
||||
|
||||
## Fix
|
||||
|
||||
- [x] **Input does not fall through by input type (2026-09-04).**
|
||||
`SensorUi::run_sensors` (`src/default/sense.rs`) used to set "consumed,
|
||||
stop checking lower layers" from mere hover — a widget registered for
|
||||
nothing but `click()` blocked a `Scroll` meant for whatever was behind
|
||||
it, since "the cursor is over this widget" and "this widget handled the
|
||||
event" were the same check. Fixed by judging consumption per input
|
||||
kind: with no button transition and no scroll happening this frame
|
||||
("momentary" activity), the topmost hovered widget still wins, same as
|
||||
before; when something momentary *is* happening, only a widget whose
|
||||
registered senses actually include a matching non-hover one (checked
|
||||
via a new `TypeEventManager::registered`, which lists what a widget
|
||||
registered without running anything) consumes it, so a widget with only
|
||||
`Hovering`/click handlers can no longer block a scroll from reaching a
|
||||
list underneath. `iris/src/sense_tests.rs` builds a button-over-a-list
|
||||
`Stack` with a plain `HasEvents` impl (no GPU or window) and checks both
|
||||
directions: a scroll over the button reaches the list, and a real click
|
||||
still reaches the button — confirmed to fail on the pre-fix code and
|
||||
pass after.
|
||||
|
||||
- [x] **Appending one image to an already-loaded list rebuilds every other
|
||||
image's bind group (2026-09-05, fixed 2026-09-05).** Found by the
|
||||
benchmark below: `GpuTextures::update` (`core/src/render/texture.rs`)
|
||||
triggered `rebuild_image_bind_groups` — a loop over *every live
|
||||
standalone image*, rebuilding its `BindGroup` — whenever the shared
|
||||
`masks` or `move_offsets` GPU buffer was resized (`masks_resized ||
|
||||
moves_resized` in `UiRenderNode::update`, `core/src/render/mod.rs`), and
|
||||
a widget getting its *first* move-offset slot (LAYOUT.md section 2 —
|
||||
every widget gets one on first draw) could be exactly what grows that
|
||||
buffer. So one new message with one new image, appended to a transcript
|
||||
that already has N images loaded, did not cost O(1): it cost one
|
||||
`create_image` for the new image plus one `make_image_bind_group` per
|
||||
*existing* image, because the new widget's own move slot pushed the
|
||||
arena past its capacity. Measured directly in
|
||||
`iris/examples/bench_images.rs`: appending a 1,001st image to 1,000
|
||||
already-settled ones reported **1,001** bind-group creates for that one
|
||||
frame, not 1 (`./run-bench.sh images`, frame 5 in the transcript below).
|
||||
|
||||
**Fix**: `masks`/`move_offsets` never belonged in a standalone image's own
|
||||
bind group (group 2) in the first place — the group also holds that
|
||||
image's own texture view, which is the only thing that is genuinely
|
||||
per-image, so a buffer shared by *everything* forced a rebuild of
|
||||
*every* group the moment it moved. Gave masks/move_offsets their own
|
||||
bind group (group 3 in `shader.wgsl` and `UiRenderNode`: `masks_layout`/
|
||||
`masks_group`), bound once per frame in `UiRenderNode::draw` rather than
|
||||
once per draw call, instead of duplicating them into every per-image
|
||||
group. `GpuTextures` and its image bind groups now know nothing about
|
||||
either buffer — `rebuild_image_bind_groups` is called only from
|
||||
`grow_array` (the atlas array texture growing, which genuinely does
|
||||
change what every image's own bind group must reference) — so a
|
||||
masks/move_offsets resize now touches exactly one bind group, ever,
|
||||
regardless of how many images are live. Numbers after the fix, same
|
||||
benchmark and command:
|
||||
|
||||
./run-bench.sh images
|
||||
frame=1 bind_group_creates=1000 (cold load, unchanged)
|
||||
frame=2 bind_group_creates=0 (was 1000 -- see the item below)
|
||||
frame=3 bind_group_creates=0
|
||||
frame=4 bind_group_creates=0
|
||||
(append one image here)
|
||||
frame=5 bind_group_creates=1 (was 1001)
|
||||
frame=6 bind_group_creates=0
|
||||
|
||||
`run-headless.sh tabs --shot` still 27266 bytes, byte-for-byte unchanged,
|
||||
confirming the bind-group restructuring changed nothing about what is
|
||||
drawn.
|
||||
- [x] **Bind-group creation takes two frames to reach the steady state, not
|
||||
one (2026-09-05, closed by the fix above, 2026-09-05).** Same benchmark:
|
||||
loading 1,000 images cold used to report 1,000 creates on frame 1
|
||||
(expected — `create_image`, one per new image) *and again* 1,000 on
|
||||
frame 2, before settling to 0 from frame 3. This was `rebuild_image_bind_groups`
|
||||
firing a second time for the same masks/move-offsets buffer-growth
|
||||
reason as the item above, confirming the guess recorded here — the two
|
||||
were exactly the same root cause measured two different ways. Frame 2
|
||||
now reports 0 (see the numbers above); not a separate fix.
|
||||
|
||||
## Build
|
||||
|
||||
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
||||
`benches/` or a script under `iris/`, never in `cargo test`). The
|
||||
scenario that matters most is a **message list** — chat apps and this
|
||||
app's transcript alike — stressed with many messages and many images.
|
||||
One case in particular: **resizing an input box** (typing enough text to
|
||||
grow it) that pushes a long list of messages above it must stay very
|
||||
fast and recalculate almost nothing — a move of everything above, not a
|
||||
re-layout. That is exactly the O(1) move chain in LAYOUT.md; the
|
||||
benchmark is what proves it. Done when the numbers are in this file with
|
||||
the command, and the input-box case reports draws re-run, not just frame
|
||||
time.
|
||||
|
||||
**Built as two rigs**, chosen per scenario by whether a real `wgpu`
|
||||
device is needed (`UiRenderState`/`Widgets` touch no GPU or window, so
|
||||
most of this runs as an ordinary binary — the same property
|
||||
`layout_tests.rs` relies on):
|
||||
|
||||
- `iris/benches/message_list.rs` — a plain `Instant`-timed binary
|
||||
(`[[bench]] harness = false` in `iris/Cargo.toml`), not criterion: see
|
||||
the file's own header for why (short version — every scenario here
|
||||
reduces to a *count* `UiRenderState::take_counters` already produces,
|
||||
which criterion's statistical machinery adds nothing to and which a
|
||||
new dependency is not worth pulling in for). Covers (a) first-frame
|
||||
cost of a message list of N wrapped-text rows (one in 20 also carrying
|
||||
a small in-memory image) for N = 100/1,000/10,000; (b) per-frame cost
|
||||
of scrolling that list, 200 ticks; (c) the input-box case — a
|
||||
fixed-height field at the bottom of the screen growing by a line 40
|
||||
times, with the message list above it filling the rest of the screen.
|
||||
Run: `cd iris && cargo bench --bench message_list` (always release —
|
||||
`cargo bench` builds the `bench` profile, which is optimized).
|
||||
- `iris/examples/bench_images.rs` — needs a real device, so it runs
|
||||
through `iris/run-headless.sh bench_images`, printing
|
||||
`UiRenderNode::take_image_bind_group_creates()` (a new counter, added
|
||||
in `core/src/render/texture.rs` and `core/src/render/mod.rs`,
|
||||
mirroring `UiRenderState::take_counters`) each frame. Covers (d): 1,000
|
||||
image rows, checked both cold (does bind-group creation reach zero
|
||||
once loaded) and after appending one more image once settled (does
|
||||
*that* stay cheap) — the second question is what actually matters for
|
||||
a live transcript and is what turned up the two Fix items above.
|
||||
- `iris/run-bench.sh [list|images]` runs either or both and is what to
|
||||
run before/after touching `Scroll`, `Span`, `Sized`, the move-offset
|
||||
chain, or `GpuTextures`.
|
||||
|
||||
**Numbers (2026-09-05, release, `cargo bench`/`run-headless.sh`, this
|
||||
VM: AMD Ryzen 7 3800X, 8 cores, rustc 1.98.0 nightly-2026-09-03):**
|
||||
|
||||
cd iris && cargo bench --bench message_list
|
||||
(a) first frame, N=100: 30.30ms draws=227 rewrites=15 moves=0
|
||||
(a) first frame, N=1000: 186.04ms draws=2252 rewrites=150 moves=0
|
||||
(a) first frame, N=10000:1770.36ms draws=22502 rewrites=1500 moves=0
|
||||
(b) scroll, N=100/1000/10000, 200 ticks each:
|
||||
draws=200 rewrites=0 moves=200 (identical at every N)
|
||||
per-tick average: 0.0002ms (identical at every N)
|
||||
(c) input grows 40 lines, N=100/1000/10000 rows above it:
|
||||
draws=320 rewrites=40 moves=160 (identical at every N)
|
||||
per-line average: 0.0012-0.0013ms (identical at every N)
|
||||
|
||||
cd iris && ./run-bench.sh images (2026-09-05, before the fix)
|
||||
frame=1 bind_group_creates=1000 (cold load)
|
||||
frame=2 bind_group_creates=1000 (see Fix item above)
|
||||
frame=3 bind_group_creates=0
|
||||
frame=4 bind_group_creates=0
|
||||
(append one image here)
|
||||
frame=5 bind_group_creates=1001 (see Fix item above)
|
||||
frame=6 bind_group_creates=0
|
||||
|
||||
cd iris && ./run-bench.sh images (2026-09-05, after the fix)
|
||||
frame=1 bind_group_creates=1000 (cold load, unchanged -- genuine work)
|
||||
frame=2 bind_group_creates=0
|
||||
frame=3 bind_group_creates=0
|
||||
frame=4 bind_group_creates=0
|
||||
(append one image here)
|
||||
frame=5 bind_group_creates=1 (one image's own create_image, O(1))
|
||||
frame=6 bind_group_creates=0
|
||||
|
||||
**Reading it**: (a) is real, necessary work — shaping and laying out N
|
||||
never-before-seen text rows — and scales with N as it must, ~10x cost
|
||||
per 10x N. (b) and (c) are the pass conditions that matter: both are
|
||||
**exactly flat across N = 100 to 10,000**, confirming LAYOUT.md's O(1)
|
||||
move chain holds for both scrolling and for a growing input box pushing
|
||||
the message list — draws/moves per tick or per line do not grow with
|
||||
list size, and the per-operation cost (a fraction of a microsecond) is
|
||||
nowhere near a frame budget. (d)'s cold-load and steady-state halves
|
||||
behave as designed; its *append* half did not, until the fix above moved
|
||||
masks/move_offsets out of the per-image bind group — now flat at O(1)
|
||||
the same way (b) and (c) are.
|
||||
|
||||
- **I5's transcript screen (`iris/transcript-ui/`, 2026-09-05) — what it
|
||||
left, each recorded at the point in the code it would go rather than
|
||||
silently dropped. See RUST.md's I5 box for the full account of what
|
||||
*was* built (the screen, `SpanStyle`, cross-row selection, the growing
|
||||
composer).**
|
||||
- [ ] **Android integration for this screen does not exist yet.** No
|
||||
cdylib/Gradle shell the way `iris-android-app` wraps `tabs-ui` (I2),
|
||||
so `transcript-bench.sh`'s render-number pass condition against the
|
||||
Compose baseline cannot be run. Needs: real `client-core::ApiClient`/
|
||||
`event_stream::follow_session_events` wiring against
|
||||
`app/ui-sandbox.sh --delay` (this crate deliberately fetches nothing
|
||||
itself, `transcript-ui/src/lib.rs`'s doc), a new cdylib + Gradle
|
||||
module, then the bench script pointed at it.
|
||||
- [x] **Touch-drag panning over a row's own rendered text — done,
|
||||
2026-09-05.** `row.rs` used to register `CursorSense::click_or_drag()`
|
||||
on each row's `TextEdit` for cross-row selection; `TextEdit::draw`'s
|
||||
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`) meant that
|
||||
registration won `core/src/sense.rs::run_sensors`'s per-layer
|
||||
arbitration on every frame it was pressed, not just the frame the
|
||||
press started, so a list pan gesture registered on `List` itself never
|
||||
got a turn while a row was under the finger. Fixed with
|
||||
`iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state
|
||||
machine per list deciding pan vs. select the way Android does (a
|
||||
vertical drag pans immediately; a stationary press held `LONG_PRESS`
|
||||
(500ms) starts a selection which further drag extends; a horizontal
|
||||
drag while something is already selected extends immediately) —
|
||||
`transcript-ui/src/selection.rs`'s `Selection::drag` is the one place
|
||||
every row's drag now routes through. 8 new unit tests
|
||||
(`iris/src/sense.rs`'s `drag_arbiter_tests`); `cargo fmt/clippy/test
|
||||
--workspace` and `cargo ndk` (both `iris` and `transcript-ui`) all
|
||||
clean; `run-headless.sh` screenshot byte-identical to before the
|
||||
change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05".
|
||||
- [ ] **Row-level accessibility names.** The composer carries
|
||||
`.label("Message")`; transcript rows do not carry a `.label()` of
|
||||
their own yet, so `Widgets::named()` (I4) does not include them —
|
||||
`row.rs`'s `build_text_row` is where one would go, keyed to something
|
||||
stable per row (its sender + a short excerpt, matching what a screen
|
||||
reader announcing a chat message would say).
|
||||
- [ ] **A tappable link and a background chip behind inline code.**
|
||||
Both need per-range glyph geometry that `TextEditCtx` does not expose
|
||||
outside `iris::widget::text` (`edit.rs`'s `layout()` helper is
|
||||
private) — see `markdown.rs`'s module doc for the exact shape the fix
|
||||
would take (the same primitive `TextEdit::draw`'s own selection
|
||||
highlight already uses internally,
|
||||
`iris/src/widget/text/edit.rs:99`).
|
||||
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
|
||||
is selected in full (`select_all`) the moment the drag leaves it,
|
||||
rather than "from the click point to whichever edge points away from
|
||||
the drag" — needs the same private `layout()` access as the item
|
||||
above. `selection.rs`'s module doc has the exact reasoning.
|
||||
- [ ] **No syntax highlighting inside a fenced code block.**
|
||||
`client_core::highlight` exists (built for the file explorer) and
|
||||
could feed per-token `SpanStyle`s into a code block's span; wiring it
|
||||
in was not attempted this pass.
|
||||
|
||||
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
||||
by something *and also* applies mask B — a mask can reference a parent
|
||||
mask, the way the move chain references a parent offset. Today masks
|
||||
are independent regions. Design it beside the move chain (same shape:
|
||||
a parent index and a bounded walk in the shader); do it when a real
|
||||
widget needs it, not before.
|
||||
- [ ] **Positions as a single float per scroll.** Iris raised, and half
|
||||
rejected, letting a scroll update one float rather than positions:
|
||||
input handling cares about most elements in a list, so absolute
|
||||
positions must be computed on the CPU anyway. LAYOUT.md's design
|
||||
already lands here (GPU walks the chain, CPU resolves on demand for
|
||||
hit tests). Keep the CPU resolution lazy and per query; do not
|
||||
materialise every row's absolute position per frame.
|
||||
- [ ] **Animations, last.** Cosmetic, so after everything above. Must be
|
||||
**modular — a piece of the library rather than a core part forced into
|
||||
everything, the same way input is**. Whatever the mechanism, a widget
|
||||
that does not animate must pay nothing and import nothing for it.
|
||||
|
||||
## Reconsider
|
||||
|
||||
- [ ] **`WidgetView`.** Iris is unsure of it: what she wants is an easy way
|
||||
to compose a widget from others (a button is the main case). With
|
||||
sizing folded into `draw`, composing may be easy enough that `View` is
|
||||
redundant. Decide after the layout change lands, by writing a button
|
||||
both ways and keeping the one that is shorter to explain; delete the
|
||||
other rather than keeping two ways.
|
||||
@@ -0,0 +1,897 @@
|
||||
# iris: one `draw` that reports a size
|
||||
|
||||
Preference stated by Iris, 2026-09-04, on the `rustify` branch. Recorded before
|
||||
any design or code so that it survives a cleared session. **Status: implemented
|
||||
2026-09-04, against every pass condition in §8** (measured, not assumed — see
|
||||
that section). Every widget listed in §7 was migrated in one change; none
|
||||
kept `desired_width`/`desired_height`. Five points needed correction or
|
||||
refinement beyond what this file originally specified — see "Deviations
|
||||
found during implementation" below, added right before "For IRIS.md" — read
|
||||
that section before touching `Aligned`, `Sized`, `MaxSize`, `Scroll`, or the
|
||||
move-slot lifecycle in `render_state.rs`, since each of those five is a real
|
||||
bug this file's first draft would have reproduced if implemented literally.
|
||||
|
||||
## What Iris asked for
|
||||
|
||||
> I don't like that widgets need both a draw and size functions. I'd much
|
||||
> rather them have a single draw that reports a size, and if it needs to be
|
||||
> moved then that can be done after the fact efficiently, or resized just
|
||||
> done after as well. This should be done efficiently like everything else
|
||||
> tries to do right now.
|
||||
|
||||
She added, a few minutes later: "single draw is not a requirement. It
|
||||
just seems more efficient from what I've heard. Feel free to override any
|
||||
decision I've made if you can find a genuinely better & still clean
|
||||
alternative." So the single-draw model is the default to design against,
|
||||
and the design below may reject it, but only with a written comparison
|
||||
showing the alternative does less work per frame and is no harder to use.
|
||||
|
||||
Standing constraints from RUST.md still apply: no DSL, plain Rust, do as
|
||||
little processing as possible per frame, but the model must cover every
|
||||
layout need a real app has (the transcript's virtualised list, wrapped
|
||||
text whose height depends on width, rows and columns that size to their
|
||||
children, overlays, masks).
|
||||
|
||||
## What exists today
|
||||
|
||||
`Widget` (`iris/core/src/widget/mod.rs`) has three methods: `draw(&mut
|
||||
self, &mut Painter)`, `desired_width(&mut self, &mut SizeCtx) -> Len` and
|
||||
`desired_height`. A parent asks `SizeCtx::width/height` for a child, which
|
||||
is memoised per widget id and axis in `Cache.size` keyed on the outer
|
||||
size, then places the child with `Painter::widget_within(region)`. So a
|
||||
child is visited twice (sized, then drawn), every widget implements sizing
|
||||
twice (one per axis), and a widget whose size depends on what it draws
|
||||
(wrapped text, a laid-out paragraph) does the layout in the size pass and
|
||||
again in the draw pass unless it caches by hand.
|
||||
|
||||
Primitives are already positioned by `UiRegion` values whose scalars have
|
||||
a `rel` and an `abs` part, resolved against the window in the vertex
|
||||
shader (`core/src/render/shader.wgsl`), and `Primitives::region_mut`
|
||||
exists to rewrite one instance's region in place. That is the mechanism a
|
||||
"move after the fact" can build on.
|
||||
|
||||
## What the design must answer
|
||||
|
||||
1. **Parent-before-child ordering.** A row has to know each child's width
|
||||
to place the next one, but under "one draw" the child's size only
|
||||
exists after it has drawn. The answer is meant to be: the child draws
|
||||
at a provisional origin, reports its size, and the parent *moves* it.
|
||||
The move must be O(1) per moved subtree, not O(primitives in the
|
||||
subtree). One way: every instance carries an index into a small
|
||||
per-widget offset buffer, so moving a widget writes one entry and the
|
||||
vertex shader adds it. Other ways may be better; the design should say
|
||||
what was considered.
|
||||
2. **Move vs resize are different costs and must be kept apart.** A move
|
||||
never re-runs `draw`. A resize re-runs `draw` for exactly the widgets
|
||||
whose size input changed, and a widget whose output does not depend on
|
||||
its size (an icon, a fixed rect) must be able to say so and be skipped.
|
||||
3. **Size-dependent content.** Wrapped text is the hard case: its height
|
||||
is a function of its width. A single `draw` receives the available
|
||||
size (what `SizeCtx.outer` is today) and reports what it used, so the
|
||||
two-pass "measure then draw" collapses into one for the common case.
|
||||
The design must say what happens when a parent wants the child's
|
||||
height *before* deciding the width it will offer (rare; say whether it
|
||||
is supported, or is done by drawing twice as an explicit, opt-in cost).
|
||||
4. **Caching.** Today's `Cache.size` memoises by (id, axis, outer). The
|
||||
replacement should memoise the whole draw result by (id, available
|
||||
size) so that an unchanged subtree costs nothing on the next frame,
|
||||
which is what makes a virtualised list cheap.
|
||||
5. **Everything currently written against `desired_width`/`desired_height`
|
||||
moves over in one change**, per the code rules: two names for one
|
||||
concept is not an intermediate state to leave behind. The widgets are
|
||||
in `iris/src/widget/` (`ptr`, `mask`, `image`, `rect`, `trait_fns`, and
|
||||
whatever else is there when the change is made).
|
||||
|
||||
## Order relative to the texture work
|
||||
|
||||
TEXTURES.md's redesign touches the render core (shader, `GpuTextures`,
|
||||
`Primitives`, `Painter`'s texture calls). This change touches the widget
|
||||
trait, `SizeCtx`, `Cache`, `Painter`'s widget calls, and any offset
|
||||
mechanism the vertex shader needs. They overlap in `Painter` and the
|
||||
shader, so they are done **in sequence, textures first**, and the layout
|
||||
design here is written (not implemented) while the texture work is in
|
||||
progress, then implemented on top of it.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. The new `Widget` trait
|
||||
|
||||
```rust
|
||||
pub trait Widget: Any {
|
||||
/// Draw within `painter.region()` (the space the parent offered) and
|
||||
/// report how much of it was actually used, per axis.
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size;
|
||||
|
||||
/// True if `draw`'s output (both the primitives it writes and the
|
||||
/// `Size` it returns) is the same for any `painter.region()` of the
|
||||
/// same *content* -- an icon, a fixed-size rect, an already-decoded
|
||||
/// image at its natural size. Default `false` (redraw on any change to
|
||||
/// the offered region) because assuming independence wrongly produces
|
||||
/// a stale draw; a widget must opt in.
|
||||
fn is_size_independent(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No `available` parameter: `Painter` already carries the region the parent
|
||||
handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already
|
||||
exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface
|
||||
size (`output_size()`, `:152`). Passing it again would be the same value
|
||||
under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`)
|
||||
and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not
|
||||
deprecated, not kept as a fallback — because a widget that implements both
|
||||
`draw` and `desired_*` for the same thing is exactly the "two names for one
|
||||
concept" the code rules call out, and it is what today's `Span::desired_ortho`
|
||||
(`iris/src/widget/position/span.rs:98-152`) already complains about in its
|
||||
own comment: "this literally copies draw so that the lengths are correctly
|
||||
set in the context, which makes this slow and not cool." Folding sizing into
|
||||
`draw` deletes that duplicate simulation, not just moves it.
|
||||
|
||||
**No single-draw alternative was found that does less work per frame.** The
|
||||
two-method trait was checked against three properties a real screen needs —
|
||||
a row placing children in sequence, a widget centering on its own content,
|
||||
and wrapped text — and in every one, `draw` already has to visit the child
|
||||
to get a size that is *this specific one's* answer, which today's
|
||||
`desired_width`/`desired_height` re-derive by re-running (a shrunk copy of)
|
||||
the same layout the draw pass will do again. So the two-method trait is not
|
||||
"measure once, draw once" in the general case; it is "measure once per axis,
|
||||
then draw once," i.e. up to three visits per widget per frame, against one
|
||||
under the design here. The single-draw model is therefore adopted as
|
||||
proposed, not merely accepted as a preference.
|
||||
|
||||
### 2. Move: O(1) per moved subtree, via a per-widget offset chain
|
||||
|
||||
**What exists today, and why it is not O(1).** `UiRenderState::mov`
|
||||
(`core/src/ui/render_state.rs:156-168`) fires when a widget's region keeps
|
||||
its *size* but changes *position* (`draw_inner`, `:85-100`:
|
||||
`active.region.size() == region.size()` after excluding the exact-match
|
||||
case). It rewrites every primitive's `region` field via
|
||||
`Primitives::region_mut` (`core/src/render/primitive.rs:176-179`) for the
|
||||
widget's own primitives, then recurses into every child — O(primitives in
|
||||
the subtree). Both call sites that trigger it today, `Scroll::draw`
|
||||
(`iris/src/widget/position/scroll.rs:29-31`) and `Offset::draw`
|
||||
(`iris/src/widget/position/offset.rs:9-11`), are "translate this subtree by
|
||||
an abs pixel amount, `rel` framing unchanged" — a transcript scroll
|
||||
re-touches every glyph in every visible row, every frame of the drag, and
|
||||
I3's target is 800 rows on screen.
|
||||
|
||||
**Recommendation: a per-widget offset slot forming a parent-linked chain,
|
||||
resolved in the vertex shader.**
|
||||
|
||||
- `UiData` (`core/src/ui/mod.rs:14-20`) gains
|
||||
`pub move_offsets: TrackedArena<MoveOffset, u32>`, the same arena shape
|
||||
already used for `masks: TrackedArena<Mask, u32>` on the line above it.
|
||||
- `render/data.rs` gains `pub struct MoveOffset { pub delta: [f32; 2], pub
|
||||
parent: u32 }` (`Pod`/`Zeroable`, `parent = u32::MAX` = "no ancestor,
|
||||
add nothing more"). A pure abs-pixel translation, not a general
|
||||
`UiRegion` remap — sufficient for every existing call site (above).
|
||||
- `PrimitiveInstance` (`render/data.rs:11-18`) gains `pub move_idx: u32`,
|
||||
a vertex attribute at `@location(7)` beside `mask_idx` at `6` — the same
|
||||
kind of per-instance handle.
|
||||
- `ActiveData` (`core/src/ui/active.rs`) gains `pub move_slot: MoveIdx`,
|
||||
assigned **when the widget is first drawn** (`draw_inner`, beside
|
||||
`active.insert`), with `parent` = the drawing widget's parent's slot.
|
||||
`Painter` threads a `move_slot` field down exactly as it already threads
|
||||
`mask` and `layer` (`painter.rs:9-20`), so a freshly-drawn descendant is
|
||||
correct from its first frame — nothing is ever retrofitted onto an
|
||||
already-active primitive. An unmoved widget's slot just stays `[0, 0]`.
|
||||
- `Painter::primitive_at` (`painter.rs:23-38`) writes `move_idx:
|
||||
self.move_slot`, matching how it already writes `mask_idx: self.mask`.
|
||||
- `mov(id, delta)` becomes: look up `id`'s slot, write
|
||||
`move_offsets[slot].delta += delta`. One write — no primitive touched, no
|
||||
recursion, since descendants already reference this slot transitively.
|
||||
- `shader.wgsl`'s vertex stage, after computing `top_left`/`bot_right` in
|
||||
pixels (after `:106`, before the clip-space divide at `:113`), walks
|
||||
`move_idx → move_offsets[i].parent` for a bounded number of steps (a
|
||||
small constant, e.g. 16, with a CPU-side debug assertion that no chain
|
||||
exceeds it), summing `delta` into both corners. Cost is O(chain depth),
|
||||
paid every frame regardless of whether anything moved — negligible next
|
||||
to the per-fragment texture sampling TEXTURES.md already measures this
|
||||
GPU as not bound by.
|
||||
|
||||
**Why the chain, not the flatter thing first proposed.** Iris's own
|
||||
phrasing — "every instance carries an index into a small per-widget offset
|
||||
buffer" — describes a flat table: one slot per subtree *declared* movable,
|
||||
no parent link. It breaks the moment two such subtrees nest — a row inside
|
||||
a scrolling list, itself later given its own animated offset (a
|
||||
swipe-to-delete mid-scroll) — because the row's primitives would have to
|
||||
pick one slot and lose the other's contribution. The chain costs one extra
|
||||
field and a bounded shader loop in exchange for no such gap, and since
|
||||
every `ActiveData` gets a slot unconditionally rather than lazily, it costs
|
||||
no more at the common depth of one than the flat version would.
|
||||
|
||||
**Against `region_mut` as the steady-state mechanism**: rejected for being
|
||||
O(primitives in the subtree) — the cost this section removes — but kept
|
||||
for a resize that changes a region's `rel` component (a genuine reflow,
|
||||
§3) and for a size-independent widget's resize (§3), where the content's
|
||||
shape doesn't change and one field write already suffices.
|
||||
|
||||
### 2b. Two more readers of "where is this widget," and masks
|
||||
|
||||
Moving the offset into the vertex shader means `ActiveData.region` is no
|
||||
longer the on-screen truth once a widget has been moved — it is where the
|
||||
widget was *drawn*, before any `move_offsets` delta. Two things read it as
|
||||
if it still were, and both must move to a resolved query or they silently
|
||||
answer with the pre-move position: a click landing on a scrolled row would
|
||||
be routed to whatever used to be there, with nothing on screen to say so —
|
||||
exactly the "wrong answer that looks like a right one" case the code rules
|
||||
single out.
|
||||
|
||||
**Hit-testing.** `SensorUi::run_sensors` (`src/default/sense.rs:154-200`)
|
||||
does the actual pointer routing, and line 170 is the read in question:
|
||||
`let shape = self.active.get(id).unwrap().region;` (`self: &UiRenderState`),
|
||||
immediately turned into pixels and tested against the cursor at `:171-172`.
|
||||
Under this design that region must be resolved through the same chain the
|
||||
GPU walks before it means anything. Add to `UiRenderState`:
|
||||
|
||||
```rust
|
||||
/// `active[id].region`, corrected by every `move_offsets` delta between
|
||||
/// `id` and the root — the CPU-side twin of the vertex shader's chain
|
||||
/// walk, over the same arena, so the two cannot disagree about where a
|
||||
/// widget is. O(chain depth), not O(primitives): a plain Rust loop over
|
||||
/// `move_offsets`, bounded by the same constant the shader loop uses
|
||||
/// (name it once, e.g. `render::MOVE_CHAIN_LIMIT`, and reference it from
|
||||
/// the WGSL loop bound in a comment, since WGSL cannot `include!` a Rust
|
||||
/// const across the language boundary).
|
||||
pub fn resolved_region(&self, id: WidgetId) -> UiRegion;
|
||||
```
|
||||
|
||||
`window_region` (`core/src/ui/render_state.rs:264-267`), the public
|
||||
coordinate query already used outside hit-testing
|
||||
(`src/default/attr.rs:15,17,70`, e.g. positioning one widget relative to
|
||||
another's on-screen box), is reimplemented to call `resolved_region(id)`
|
||||
before `.to_px(...)` instead of reading `.region` directly — one change
|
||||
covers both call sites listed there. `sense.rs:170` changes to
|
||||
`let shape = self.resolved_region(*id);`. Both are required the moment §2
|
||||
lands, not an optional follow-up: an unmoved widget's chain is empty and
|
||||
`resolved_region` costs one arena read to find that out, so there is no
|
||||
version of this design where skipping the fix is a legitimate
|
||||
optimization — it is a correctness gap, not a performance one.
|
||||
|
||||
**Masks.** `Painter::set_mask` (`core/src/ui/painter.rs:49-52`) bakes the
|
||||
painter's *current* region into a `Mask` pushed onto
|
||||
`masks: TrackedArena<Mask, u32>` (`core/src/ui/mod.rs:19`), and the
|
||||
fragment shader clips every primitive against `masks[in.mask_idx]`'s raw
|
||||
`rel`/`abs` fields, unaffected by any move (`shader.wgsl:147-157`). If the
|
||||
widget that called `set_mask` — `Masked::draw`,
|
||||
`iris/src/widget/mask.rs:7-11`, `painter.set_mask(painter.region()); ...` —
|
||||
is itself later moved, its clip rectangle stays where it was drawn while
|
||||
its content moves out from under it: a visibly wrong clip, immediately on
|
||||
screen, not a latency question.
|
||||
|
||||
Fix: `Mask` (`core/src/render/data.rs:46-49`) gains `pub move_idx: u32`,
|
||||
written from `Painter::set_mask` as `self.move_slot` — the identical slot
|
||||
the mask-owning widget's own primitives already get (§2), not a second
|
||||
mechanism. Resolution happens in the **fragment** shader, not the CPU, and
|
||||
not the vertex shader either: `shader.wgsl`'s mask check (`:147-157`)
|
||||
currently computes the mask's `top_left`/`bot_right` inline from
|
||||
`masks[in.mask_idx]`; that computation is extended to walk the same
|
||||
move-offset chain §2 added, via one shared function —
|
||||
|
||||
```wgsl
|
||||
fn resolve_move(idx: u32) -> vec2<f32> { /* the bounded parent walk, used by both stages */ }
|
||||
```
|
||||
|
||||
— called from `vs_main` for a primitive's own corners and from `fs_main`
|
||||
for its mask's corners, so the walk is written once and the two stages
|
||||
cannot drift apart (the sibling-rule from the code rules: one loop, not a
|
||||
hand-copied second one in the other shader stage).
|
||||
|
||||
**Why the fragment shader, not a CPU-side mask rewrite at move time.** A
|
||||
primitive's mask is frequently owned by a *different* widget than the
|
||||
primitive itself — often several levels up a subtree, with its own,
|
||||
independent move slot — so a primitive's resolved offset and its mask's
|
||||
resolved offset are two different chain sums, both needed, and only the
|
||||
fragment shader has both `in.move_idx` (this fragment's own chain) and
|
||||
`in.mask_idx` (indirecting to a second, possibly unrelated chain) already
|
||||
in hand per-fragment. Resolving mask regions on the CPU at move time would
|
||||
mean, for every `mov()` call, walking forward to every mask instance the
|
||||
moved widget's slot could affect and rewriting its raw region — exactly
|
||||
the O(subtree) cost §2 exists to remove, just moved from primitives to
|
||||
masks. The fragment shader already re-reads `masks[in.mask_idx]` every
|
||||
frame (`:148`); one more arena read to resolve its chain costs nothing
|
||||
extra in kind.
|
||||
|
||||
**The scroll-container case, checked rather than assumed.** A masked,
|
||||
scrollable region is built as a `Masked` wrapping a `Scroll`
|
||||
(`iris/src/widget/position/scroll.rs`, `iris/src/widget/mask.rs`) — the
|
||||
viewport border is drawn (and `set_mask` called) by `Masked`, which is
|
||||
never itself the target of `mov()`; only `Scroll`'s inner content is,
|
||||
every frame the user drags. Because each widget's move slot is its own
|
||||
(§2: assigned per `ActiveData`, not shared), `Masked`'s mask references
|
||||
its own, stationary slot, while the scrolled content underneath references
|
||||
a separate, deeper slot whose `parent` chain passes through — but does not
|
||||
write to — the viewport's slot. Moving the content therefore never touches
|
||||
the mask's resolved position, and the mask staying still while its content
|
||||
slides past it is what this design already produces with no special case,
|
||||
not an extra rule that had to be added for it.
|
||||
|
||||
### 3. Resize scope
|
||||
|
||||
A resize is "the region a widget's parent offers it changes such that the
|
||||
widget's draw might produce different output" — as opposed to a move, which
|
||||
by construction cannot (§2 is scoped to pure translation). Two independent
|
||||
narrowings apply, and both are real, measured properties of the code as it
|
||||
stands rather than new machinery:
|
||||
|
||||
**(a) A window resize does not, by itself, require touching most widgets.**
|
||||
`shader.wgsl:105-106` recomputes every primitive's pixel position from
|
||||
`window.dim` and the primitive's stored `rel`/`abs` pair *every frame,
|
||||
already, on the GPU*. A widget laid out purely in `rel`/`abs` terms (no
|
||||
call to `px_size()`, `output_size()`, or anything else that reads a
|
||||
concrete pixel count) is therefore already correct after a resize with zero
|
||||
CPU work — the shader did it. `UiRenderState::needs_redraw_all`
|
||||
(`render_state.rs:229-231`) currently ignores this and redraws the entire
|
||||
tree on every `resized`, which was the safe default while sizing and
|
||||
drawing were two passes; it should be narrowed to only the widgets that
|
||||
*do* read a concrete pixel value. Track this the same way `needs_redraw`
|
||||
already tracks per-widget dirtiness (`Widgets::needs_redraw`,
|
||||
`core/src/widget/widgets.rs:9`): a widget's `draw` call marks itself
|
||||
pixel-dependent by calling through `Painter` methods that read
|
||||
`output_size`/`px_size` (both already funnel through `Painter`, so the
|
||||
marking is one line at each), and `resize()` (`render_state.rs:32-35`)
|
||||
walks only that set instead of unconditionally setting `resized = true`
|
||||
for a full `redraw_all`. This turns "every resize redraws everything" into
|
||||
"every resize redraws what depends on pixels" — a real behavior change
|
||||
beyond what was asked, so verify it against the I0b `pre_present_notify`
|
||||
resize regression (that fix depended on `redraw_all`'s completeness)
|
||||
before narrowing this.
|
||||
|
||||
**(b) A widget's `available` (its parent's offered region) can change
|
||||
without the widget's *content* changing — this is what
|
||||
`is_size_independent` (§1) answers.** When a container's own layout shifts
|
||||
(a sibling grew or shrank, changing this widget's offered box), a widget
|
||||
that returns `true` from `is_size_independent` is not redrawn: its
|
||||
primitives are unaffected by size, only by placement, so the parent
|
||||
either (i) issues a move (§2) if only position changed, or (ii) rewrites
|
||||
the primitive's `region` fields directly via `region_mut` if the box
|
||||
changed shape too (still O(primitives owned directly by this widget, not
|
||||
its subtree, since a size-independent widget by definition has no
|
||||
size-dependent descendants worth distinguishing — in practice this is
|
||||
always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
|
||||
`false` (the default) is redrawn in full whenever `available` changes,
|
||||
which is correct always, just not free.
|
||||
|
||||
**Ancestor propagation** (a resized child changing its own reported size,
|
||||
requiring its parent to re-lay-out) is unchanged in spirit from today's
|
||||
`redraw` (`render_state.rs:270-305`), which already walks up exactly the
|
||||
ancestors whose cached size differs from the new one and stops as soon as
|
||||
a size is unchanged (`:274-286`). That loop moves from consulting
|
||||
`Cache.size` to consulting `ActiveData.size` (§5) but keeps its shape.
|
||||
|
||||
### 4. Wrapped text, and "needs child height before choosing width"
|
||||
|
||||
**Wrapped text is not a special case any more; it already reads as one
|
||||
draw.** `TextView::render` (`iris/src/widget/text/mod.rs:57-76`) already
|
||||
does exactly what single-draw asks for: it reads `ctx.px_size().x` as the
|
||||
wrap width, shapes once, and memoizes the shaped layout keyed on that width
|
||||
plus a changed-flag on the buffer and attrs (`:63-69`) — a second call with
|
||||
the same width is a hash-map-style cache hit, not a re-shape. Under the new
|
||||
trait this collapses `Text::draw`/`desired_width`/`desired_height`
|
||||
(`text/mod.rs:133-147`, three functions) into one `Text::draw` that calls
|
||||
`self.view.draw(painter)` once, which internally still calls `render`
|
||||
once, hits its own cache, and returns the size it already computed. No
|
||||
new caching is needed here; the two now-redundant call sites
|
||||
(`desired_width`/`desired_height` each separately calling `render`) simply
|
||||
disappear, which is a second `render` avoided per frame per text widget
|
||||
that is being measured by a parent.
|
||||
|
||||
**"Parent wants the child's height before deciding the width it will
|
||||
offer"** — the genuinely circular case named in the brief, e.g. a column
|
||||
that sizes its own width to its widest child, where that child is wrapped
|
||||
text whose height (which the column's *own* height depends on) depends on
|
||||
the width the column has not yet decided. This is not solvable in one pass
|
||||
for the same reason it is not solvable in CSS shrink-to-fit with wrapped
|
||||
content: the two axes' answers are mutually dependent. `Span::desired_ortho`
|
||||
(`span.rs:98-136`) already hits exactly this today and already resolves it
|
||||
by an explicit second, throwaway pass (its own comment: "this literally
|
||||
copies draw ... which makes this slow and not cool"). The design keeps that
|
||||
resolution, made explicit rather than accidental: `Painter` gets
|
||||
|
||||
```rust
|
||||
/// Draw `child` at a provisional region to learn its size under one
|
||||
/// axis's worth of assumption, discard everything it wrote, then draw it
|
||||
/// again at the region that assumption produced. For the rare parent that
|
||||
/// cannot pick an offered size without already knowing the answer.
|
||||
/// Twice the cost of one `draw`; every other case in this file avoids it.
|
||||
pub fn draw_twice(&mut self, child: &StrongWidget, first: UiRegion, second: impl FnOnce(Size) -> UiRegion) -> Size;
|
||||
```
|
||||
|
||||
implemented as: draw at `first`, record `Size`, remove the widget and its
|
||||
subtree the same way a resize-triggered redraw already does (`draw_inner`'s
|
||||
"if not \[same region\], maintain resize and track old children," `:97-100`,
|
||||
which already frees the old primitives before redrawing) — reusing that
|
||||
path rather than adding a second one — draw again at `second(size)`, return
|
||||
the final `Size`. It is opt-in and named for its cost, so a widget only
|
||||
pays it if it is the one that needs it; `Span`'s cross-axis case is the one
|
||||
call site converted to it, replacing the hand-rolled duplicate loop.
|
||||
|
||||
### 5. Caching and invalidation
|
||||
|
||||
`Cache.size` (`core/src/ui/cache.rs`) is **deleted, not replaced with an
|
||||
equivalent** — the thing it memoized (a `desired_width`/`desired_height`
|
||||
answer, independent of drawing) no longer exists as a separate query, so
|
||||
there is nothing left to cache at that layer. What already provides "an
|
||||
unchanged subtree costs nothing" is the check `draw_inner` performs before
|
||||
touching a widget at all (`render_state.rs:85-90`): if the widget is active,
|
||||
its region is unchanged, and it is not marked dirty, `draw_inner` returns
|
||||
immediately — no `Painter` constructed, no primitive touched, no shader
|
||||
work beyond what the GPU already redraws from the unchanged instance
|
||||
buffer. That check is kept exactly as it is; it is the caching mechanism,
|
||||
and it already operates at (id, region) granularity, which subsumes "(id,
|
||||
available size)" once size *is* what a region change means.
|
||||
|
||||
What is added: `ActiveData` gains `pub size: Size` — the value `draw`
|
||||
returned, stored the moment it is (`draw_inner`, alongside building the
|
||||
`ActiveData` struct at `:134-143`). This is what a parent placing this
|
||||
widget for a second frame without redrawing it (because nothing changed)
|
||||
reads instead of recomputing — it replaces `Cache.size`'s role of "answer a
|
||||
size question without a full draw" with "read the size of the last actual
|
||||
draw," which is always available because `draw_inner`'s skip path is only
|
||||
reachable once the widget has been drawn at least once. `Cache::remove`/
|
||||
`Cache::clear` (`cache.rs:9-17`) are deleted with the type; `ActiveData`
|
||||
already has an equivalent lifecycle (removed in `remove`/`remove_rec`,
|
||||
`render_state.rs:171-198`, freed with the widget).
|
||||
|
||||
### 6. Before / after
|
||||
|
||||
**A leaf, `iris/src/widget/rect.rs`** — the size-independent case:
|
||||
|
||||
```rust
|
||||
// before
|
||||
impl Widget for Rect {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
||||
thickness: self.thickness, inner_radius: self.inner_radius });
|
||||
}
|
||||
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
||||
fn desired_height(&mut self, _: &mut SizeCtx) -> Len { Len::rest(1) }
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// after
|
||||
impl Widget for Rect {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
painter.primitive(RectPrimitive { color: self.color, radius: self.radius,
|
||||
thickness: self.thickness, inner_radius: self.inner_radius });
|
||||
Size::REST // fills whatever it was given -- used == available
|
||||
}
|
||||
fn is_size_independent(&self) -> bool { true } // content never depends on region size
|
||||
}
|
||||
```
|
||||
|
||||
**A container that needs the child's size before placing it,
|
||||
`iris/src/widget/position/align.rs`**:
|
||||
|
||||
```rust
|
||||
// before
|
||||
impl Widget for Aligned {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
let region = match self.align.tuple() {
|
||||
(Some(x), Some(y)) => painter.size(&self.inner).to_uivec2().align(RegionAlign { x, y }),
|
||||
(Some(x), None) => { let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
|
||||
UiRegion::new(x, UiSpan::FULL) }
|
||||
(None, Some(y)) => { let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
|
||||
UiRegion::new(UiSpan::FULL, y) }
|
||||
(None, None) => UiRegion::FULL,
|
||||
};
|
||||
painter.widget_within(&self.inner, region);
|
||||
}
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { ctx.width(&self.inner) }
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { ctx.height(&self.inner) }
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// after
|
||||
impl Widget for Aligned {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let full = painter.region();
|
||||
// Draw once at the full region to learn the child's real size --
|
||||
// this placement is provisional and corrected below without a
|
||||
// second draw.
|
||||
let used = painter.widget_within(&self.inner, full);
|
||||
let region = match self.align.tuple() {
|
||||
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }).within(&full),
|
||||
(Some(x), None) => used.x.apply_rest().align(x).within(&full),
|
||||
(None, Some(y)) => used.y.apply_rest().align(y).within(&full),
|
||||
(None, None) => full,
|
||||
};
|
||||
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
|
||||
used
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
|
||||
return type from `()` to `Size`, carrying the child's `draw` result back —
|
||||
the only signature change needed to let a parent see what its child used.
|
||||
`Painter::reposition` is new, computing the delta between where a child
|
||||
was actually drawn and where it belongs and calling the O(1) `mov` from
|
||||
§2. `SizeCtx` and `Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,
|
||||
180-182`) are deleted — nothing calls `desired_len` any more, so there is
|
||||
nothing left for `SizeCtx` to answer; `draw_text`/`label`/`px_size`/
|
||||
`output_size` already exist redundantly on both `SizeCtx` and `Painter`
|
||||
today (compare `size.rs:71-90` against `painter.rs:152-174`) and this
|
||||
deletes the `SizeCtx` copies, keeping the `Painter` ones.
|
||||
|
||||
### 7. Migration — every file and widget that changes
|
||||
|
||||
One change, in dependency order (rename-and-move-together, per the code
|
||||
rules — no intermediate state with both trait shapes):
|
||||
|
||||
- `core/src/widget/mod.rs` — the `Widget` trait (§1), delete
|
||||
`WidgetAxisFns`, update `impl Widget for ()`.
|
||||
- `core/src/ui/size.rs` — delete `SizeCtx` (the type and all its methods).
|
||||
- `core/src/ui/cache.rs` — delete `Cache` (§5).
|
||||
- `core/src/ui/painter.rs` — `widget`/`widget_within`/`widget_at` return
|
||||
`Size`; add `reposition`, `draw_twice`; delete `size_ctx`, `size`,
|
||||
`len_axis`; `primitive_at` writes `move_idx`.
|
||||
- `core/src/ui/render_state.rs` — `draw_inner` captures and stores
|
||||
`ActiveData.size`; `mov` becomes the O(1) offset write (§2); resize
|
||||
narrowing (§3a); `redraw`'s per-axis loop reads `ActiveData.size`
|
||||
instead of `Cache.size`.
|
||||
- `core/src/ui/active.rs` — `ActiveData` gains `size: Size`,
|
||||
`move_slot: MoveIdx`.
|
||||
- `core/src/ui/mod.rs` — `UiData` gains `move_offsets`.
|
||||
- `core/src/render/data.rs` — `PrimitiveInstance` gains `move_idx`;
|
||||
new `MoveOffset` struct.
|
||||
- `core/src/render/primitive.rs` — thread `move_idx` through `PrimitiveInst`
|
||||
and `Primitives::write`, matching `mask_idx`.
|
||||
- `core/src/render/mod.rs` — bind the new `move_offsets` storage buffer
|
||||
(group 2, beside `masks`) and its update path.
|
||||
- `core/src/render/shader.wgsl` — `InstanceInput` gains `move_idx`;
|
||||
`MoveOffset`/`UiScalar`-shaped storage binding; a shared `resolve_move`
|
||||
function (§2b) called from both `vs_main` (a primitive's own corners)
|
||||
and `fs_main` (its mask's corners, once `Mask` carries `move_idx`).
|
||||
- `core/src/ui/render_state.rs` — additionally, `resolved_region` (§2b)
|
||||
and `window_region` (`:264-267`) reimplemented on top of it.
|
||||
- `src/default/sense.rs` — `run_sensors`'s hit-test read (`:170`) switches
|
||||
from `self.active.get(id).unwrap().region` to `self.resolved_region(*id)`
|
||||
(§2b) — the pointer-routing fix this design requires, not an optional
|
||||
follow-up.
|
||||
- `core/src/render/data.rs` — additionally, `Mask` (`:46-49`) gains
|
||||
`move_idx: u32` (§2b).
|
||||
- `core/src/ui/painter.rs` — additionally, `set_mask` (`:49-52`) writes
|
||||
`move_idx: self.move_slot` into the `Mask` it pushes (§2b).
|
||||
- Every widget with a two-method `impl Widget`, collapsed to one `draw`
|
||||
(§1, §6), `is_size_independent` added where true: `core/src/widget/mod.rs`
|
||||
(`impl Widget for ()`), `iris/src/widget/rect.rs` (`Rect`, → true),
|
||||
`iris/src/widget/image.rs` (`Image`, → true — a decoded image's primitive
|
||||
never depends on the region it is offered, same as `Rect`),
|
||||
`iris/src/widget/mask.rs` (`Masked`), `iris/src/widget/ptr.rs`
|
||||
(`WidgetPtr`), `iris/src/widget/text/mod.rs` (`Text`, §4),
|
||||
`iris/src/widget/text/edit.rs` (`TextEdit`),
|
||||
`iris/src/widget/position/scroll.rs` (`Scroll`, keeps its `mov`-shaped
|
||||
offset, now O(1) automatically via §2), `iris/src/widget/position/align.rs`
|
||||
(`Aligned`, §6), `iris/src/widget/position/max_size.rs` (`MaxSize`),
|
||||
`iris/src/widget/position/layer.rs` (`LayerOffset`),
|
||||
`iris/src/widget/position/pad.rs` (`Pad`),
|
||||
`iris/src/widget/position/stack.rs` (`Stack`),
|
||||
`iris/src/widget/position/offset.rs` (`Offset`),
|
||||
`iris/src/widget/position/span.rs` (`Span`, §4's `draw_twice` for the
|
||||
cross-axis case, deleting `desired_ortho`'s duplicate loop),
|
||||
`iris/src/widget/position/sized.rs` (`Sized`).
|
||||
This list was produced by `grep -rn "impl Widget for\|fn desired_width\|fn desired_height"`
|
||||
across `core/` and `src/`; re-run it before starting, since it is the
|
||||
authoritative check that nothing was missed, not this paragraph.
|
||||
- `iris/examples/{minimal.rs,task.rs,view.rs,tabs/main.rs}` — no direct
|
||||
`impl Widget` found in any example (verified by the same grep); they use
|
||||
the builder DSL in `core/src/widget/trait_fns.rs` and should need no
|
||||
source change, which is itself part of the pass condition below.
|
||||
|
||||
### 8. Pass conditions
|
||||
|
||||
1. **Every example under `iris/examples` renders identically.** Run
|
||||
`iris/run-headless.sh EXAMPLE --shot PNG` for each of `minimal`, `task`,
|
||||
`view`, `tabs` before and after, and diff the PNGs pixel-for-pixel — not
|
||||
"looks right," since a subtle wrap or alignment regression is exactly
|
||||
what a diff catches and a glance does not.
|
||||
|
||||
**Result (2026-09-04): pass, all four, 0 differing bytes.** No PNG
|
||||
library is installed in this VM (no PIL, no ImageMagick, no pip), so the
|
||||
diff is a from-scratch PNG decoder (`zlib` + the five filter types) at
|
||||
`/tmp/layout-shots/pngdiff.py`, comparing decoded pixel bytes rather than
|
||||
file bytes (`cmp` alone is not conclusive across two separately-encoded
|
||||
PNGs, though it happened to agree here for `minimal`). Before-shots were
|
||||
taken with `git stash` at the pre-change commit; `tabs` needed two real
|
||||
fixes (deviations 1 and 2 below) before it stopped differing — the other
|
||||
three matched on the first try.
|
||||
2. **Unchanged-frame cost, measured, not assumed.** Add a counter beside
|
||||
the existing `debug_layers`/`active_widgets` instrumentation
|
||||
(`render_state.rs:241-262`) for (a) `Widget::draw` invocations and (b)
|
||||
`Primitives::write`/`region_mut` calls, both per `update()` call. Drive
|
||||
one example (`tabs`, since it already has multiple widgets and an
|
||||
interactive element) through one frame with nothing changed and report
|
||||
both counts — the pass condition is **0 draws and 0 primitive rewrites**
|
||||
for a frame in which nothing was marked dirty, resized, or moved.
|
||||
|
||||
**Result (2026-09-04): pass, 0 and 0.** Implemented as
|
||||
`UiRenderState::take_counters() -> (u64, u64, u64)` (draws, `region_mut`
|
||||
rewrites, `move_offsets` writes — a third counter, for condition 3
|
||||
below), reset on read. Measured in
|
||||
`iris/src/layout_tests.rs::an_unchanged_frame_draws_and_rewrites_nothing`
|
||||
against a `Scroll` over 500 fixed-height rects (not the `tabs` example —
|
||||
see the note on condition 3 for why this runs as a plain unit test
|
||||
instead).
|
||||
3. **Single-moved-child cost, measured.** Same counters, one frame in
|
||||
which exactly one widget is moved (not resized) with N primitives in its
|
||||
subtree — the pass condition is **1 write to `move_offsets`, 0 calls to
|
||||
`Widget::draw`, 0 calls to `region_mut`**, independent of N. Construct
|
||||
the case with a `tabs`-style example holding a deliberately large text
|
||||
block (hundreds of glyphs) inside a `Scroll`, so N is large enough that
|
||||
an O(N) regression would show up as a non-trivial write count rather
|
||||
than being lost in noise.
|
||||
|
||||
**Result (2026-09-04): pass — 0 draws, 0 rewrites, 1 move_offsets
|
||||
write, N = 500.** Built with rects rather than glyphs
|
||||
(`iris/src/layout_tests.rs::scrolling_moves_in_o1_without_a_redraw`):
|
||||
`iris-core`/`iris` touch no GPU or window to lay out and move a tree, so
|
||||
this runs as a plain `cargo test`, not through `run-headless.sh` — a
|
||||
`Widgets`/`UiData` pair and a bare `UiRsc` impl are enough, and it is
|
||||
faster and more precise than reading counters out of a real example's
|
||||
stderr. Getting a clean single move took two follow-up fixes beyond the
|
||||
design as written (deviation 3, the `parent_move_slot` threading; and
|
||||
the `Scroll` design decision below about offering last frame's content
|
||||
length) — without either, the count was in the thousands (every rect in
|
||||
the subtree redrawing) rather than 1.
|
||||
4. **Hit-testing follows the move, not just the render.** In the same
|
||||
scrolled-`tabs` construction as condition 3, scroll the content, then
|
||||
send a synthetic cursor position over a widget that moved and assert
|
||||
`run_sensors` (`src/default/sense.rs:154-200`) routes to that widget's
|
||||
id, not to whatever is now at its pre-scroll coordinates or to nothing.
|
||||
This is a correctness check, not a timing one — §2b's fix is required
|
||||
before §2 can ship at all, and this is what would fail silently
|
||||
(nothing on screen indicates a missed or misrouted hit) if it were
|
||||
skipped.
|
||||
|
||||
**Result (2026-09-04): pass**, but checked one level below
|
||||
`run_sensors`: `iris/src/layout_tests.rs::hit_testing_follows_a_scrolled_widget`
|
||||
scrolls a widget and asserts `UiRenderState::resolved_region` (the
|
||||
query `run_sensors`'s hit-test and `window_region` both now go through,
|
||||
per §2b) reports the moved, not the pre-scroll, position — within
|
||||
0.01px of the exact expected delta. `run_sensors` itself needs a
|
||||
`HasEvents`/window/cursor-state harness this pass did not build; the
|
||||
coverage that matters (does the position query the router uses reflect
|
||||
the move) is exercised directly instead.
|
||||
5. **A mask moves with its subtree.** Render a `Masked`-wrapped `Scroll`
|
||||
both before and after scrolling it (`iris/run-headless.sh` against a
|
||||
small purpose-built example, or an addition to `tabs`), and diff the
|
||||
two frames: the clipped edge of the content must have moved with the
|
||||
scroll while the viewport's own border (drawn by `Masked`, not moved)
|
||||
stays put — the specific case worked through in §2b. A mask rectangle
|
||||
that stayed at its pre-scroll position while its content slid past it
|
||||
is the regression this checks for, and it is visible in a single
|
||||
screenshot, not just in a counter.
|
||||
|
||||
**Result (2026-09-04): pass, checked numerically rather than by
|
||||
screenshot.** No example in this repository builds a `Masked`-wrapped
|
||||
`Scroll` (`tabs`'s "text edit scroll" tab uses `TextEdit`'s own internal
|
||||
scrolling, not this widget), so there was nothing to screenshot without
|
||||
first authoring a new example. Checked instead in
|
||||
`iris/src/layout_tests.rs::a_mask_stays_put_while_its_scrolled_content_moves`,
|
||||
on the exact data the fragment shader's `resolve_move` reads: the
|
||||
masked widget's own `move_offsets` slot delta is `[0, 0]` both before
|
||||
and after scrolling its content, because `Masked` is never itself the
|
||||
target of a move — only its child is, on a separate, deeper slot in the
|
||||
chain (§2b's "scroll-container case, checked rather than assumed"). A
|
||||
pixel-level screenshot check of this remains open; see RUST.md's next
|
||||
step.
|
||||
6. **`cargo test --workspace`, `cargo clippy --all-targets`, `cargo fmt`**
|
||||
stay clean at the defaults (iris has no tests today per I0b, so this is
|
||||
presently only clippy/fmt; add the first real widget-layer tests here if
|
||||
the move-offset chain or `draw_twice` are non-trivial enough to want
|
||||
one, per "match the codebase's testing posture" — judge that once the
|
||||
code exists rather than pre-committing to a number of tests here).
|
||||
|
||||
**Result (2026-09-04): pass.** `cargo fmt --all -- --check`,
|
||||
`cargo build --workspace --all-targets`, and `cargo clippy --all-targets`
|
||||
are all clean (one pre-existing, unrelated warning about `naga`/`wgpu`/
|
||||
`winit` future-incompatibility, from dependencies, not this change).
|
||||
`cargo test --workspace`: the 14 pre-existing `TextEdit` tests plus 4 new
|
||||
ones in `iris/src/layout_tests.rs` (conditions 2–5 above), 18 passed, 0
|
||||
failed — the move-offset chain turned out non-trivial enough (three real
|
||||
bugs found only by writing it) to clearly clear the "match the testing
|
||||
posture" bar this section left open.
|
||||
|
||||
### 9. Rejected, and why
|
||||
|
||||
- **A flat (non-chained) per-subtree offset table**, Iris's literal
|
||||
phrasing — rejected in §2 for breaking under nested independent moves
|
||||
(a swiped row inside a scrolling list). Costs nothing extra to avoid: the
|
||||
chain is the same mechanism with one more field.
|
||||
- **Keeping `region_mut` recursion as the only move mechanism** — rejected
|
||||
as the steady-state path (O(primitives in subtree), exactly what a
|
||||
transcript scroll must not pay every frame) but kept for resize-shaped
|
||||
changes (§3) where the content's own region field, not an ancestor
|
||||
chain, is what has to change.
|
||||
- **A second, size-only trait method kept alongside `draw`** (e.g.
|
||||
`fn size_hint(&self) -> Option<Size>` as a fast path some widgets could
|
||||
implement to skip a draw when a cheap answer exists) — considered and
|
||||
rejected: it reintroduces exactly the "two names for one concept" split
|
||||
this change removes, for a saving `is_size_independent` (§1, §3b)
|
||||
already covers for the cases where it would actually help (fixed-size
|
||||
leaves). A widget whose size is cheap to compute but whose *drawing* is
|
||||
not (unlikely in this codebase's widget set, but conceivable) is better
|
||||
served by that widget caching its own draw output internally — exactly
|
||||
the pattern `TextView::render` already uses (§4) — than by a second
|
||||
trait method every implementor has to reason about.
|
||||
- **Passing `available` as an explicit parameter to `draw`** (mirroring
|
||||
Masonry's `layout(&mut self, ctx, bc: &BoxConstraints) -> Size`, the
|
||||
yardstick per AGENTS.md) — rejected as redundant with `Painter::region()`,
|
||||
which already carries the same information into every widget that needs
|
||||
it; adding a parameter would just be a second route to a value already
|
||||
reachable, and would invite the two drifting apart.
|
||||
- **Eagerly propagating a moved widget's delta into every descendant's own
|
||||
offset value** (rather than chaining and resolving in the shader) —
|
||||
rejected as O(descendant widgets), which is smaller than O(primitives)
|
||||
but still not O(1), and the shader-side chain costs nothing extra to get
|
||||
the better bound.
|
||||
|
||||
## Deviations found during implementation (2026-09-04)
|
||||
|
||||
Five corrections this file's first draft did not anticipate, each found by
|
||||
`iris/run-headless.sh tabs --shot` disagreeing with a pixel-identical
|
||||
pre-change screenshot (pass condition 1) and traced with `eprintln!` in
|
||||
`draw_inner`/`reposition` — not by reasoning about the design in the
|
||||
abstract. Recorded here rather than silently fixed in place, per the code
|
||||
rules' escape-hatch requirement.
|
||||
|
||||
1. **`Aligned`'s provisional draw must call `painter.widget`, not
|
||||
`widget_within(&self.inner, painter.region())`.** §6's original text drew
|
||||
the sample as the latter. `widget_within` composes its `region` argument
|
||||
as *local*, `UiRegion::FULL`-relative coordinates against
|
||||
`painter.region()` (exactly what `UiRegion::FULL.within(&self.region) ==
|
||||
self.region` relies on); handing it `painter.region()` itself —
|
||||
already-resolved, window-relative coordinates — composes that frame a
|
||||
second time. For the root widget this is silently the identity (its
|
||||
region already is `[0,1]`), which is why it can look correct in a
|
||||
trivial case and only breaks once something is nested — i.e. always, in
|
||||
practice. Symptom: a centered child rendered at a wildly wrong offset
|
||||
nested more than one level deep. Fixed by using `painter.widget`, which
|
||||
hands the child `self.region` unmodified, with no second composition.
|
||||
|
||||
2. **A widget that reports a size smaller than its offered region must
|
||||
actually paint at that size, anchored top-left of what it was given —
|
||||
not fill the full offered region while merely *reporting* a smaller
|
||||
number.** `Sized` and `MaxSize` both had exactly this bug: their
|
||||
`desired_width`/`desired_height` predecessors capped the *reported*
|
||||
value but their `draw` bodies called `painter.widget(&self.inner)`
|
||||
unconstrained, which was harmless under the old two-pass model (a parent
|
||||
always queried the size *before* drawing, so by the time `draw` ran the
|
||||
offered region already matched) but wrong under `Aligned`'s new
|
||||
provisional-draw-then-reposition pattern, which offers the *whole*
|
||||
region on the first, learning pass. Symptom: a `.sized((100, 100))` rect
|
||||
rendered stretched to fill its whole row instead of a 100×100 square.
|
||||
Fixed by having both widgets carve the declared sub-region (`UiSpan`
|
||||
sized to the axis's `Len`, anchored at `AxisAlign::Neg`) out of whatever
|
||||
they were offered before drawing the child in it. `Image` needed the
|
||||
same treatment from the start (`texture_within` at its own natural size,
|
||||
not `texture()` at the full offered region) and was written that way in
|
||||
the first pass, once this was understood; `Rect`'s "fill whatever I'm
|
||||
given" is the one case where painting the *whole* offered region really
|
||||
is the declared behavior, so it needed no change.
|
||||
|
||||
3. **The move-offset chain's `parent` link cannot be found by looking up
|
||||
the parent's `ActiveData` in `draw_inner`, because the parent's
|
||||
`ActiveData` does not exist yet while its own `Widget::draw` is still
|
||||
running.** `ActiveData` is inserted only after `draw` returns
|
||||
(`render_state.rs`, end of `draw_inner`), so a child drawn partway
|
||||
through its parent's `draw` body — the ordinary case, since every
|
||||
composite widget draws its children from inside its own `draw` — would
|
||||
always read "no parent" from `self.active`, silently orphaning it at the
|
||||
root of the chain. Fixed by threading the parent's `move_slot` down
|
||||
through `Painter` (it already carries `mask`/`layer` the same way) and
|
||||
passing it explicitly into `draw_inner` as `parent_move_slot`, rather
|
||||
than deriving it from `self.active.get(parent_id)`. `move_parent_of`
|
||||
(the `self.active`-based lookup) is kept, but only for `redraw()`, whose
|
||||
target's parent genuinely is already active at that call site — the
|
||||
doc comment on it says which is which. Symptom: `reposition` computed
|
||||
the right delta and wrote it to the right slot, but the shader never
|
||||
saw it, because the primitive doing the actual painting chained to
|
||||
`u32::MAX` one level too early.
|
||||
|
||||
4. **`Painter::reposition` cannot reuse `active.region` as "where the
|
||||
widget currently is," because for a widget offered more room than it
|
||||
used, `active.region` is the *offered* box, not the *painted* one.**
|
||||
This only matters for `reposition` (used by `Aligned`); `mov` (used by
|
||||
`draw_inner`'s own same-size-different-position dispatch, for `Scroll`
|
||||
and `Offset`) has no such gap, because there the offered region *is*
|
||||
the visual footprint — content is sized to fill exactly what it is
|
||||
given. `reposition` instead reconstructs "from" as `active.size`
|
||||
(already tracked, per §5) anchored at `AxisAlign::Neg` within
|
||||
`active.region` — i.e. it assumes the child painted itself top-left of
|
||||
whatever it was offered, per point 2's convention — and **overwrites**
|
||||
the slot's delta rather than accumulating it the way `mov` does, since
|
||||
"from" is recomputed fresh from stable inputs every call and repeating
|
||||
the same `reposition` (an unrelated redraw elsewhere re-running this
|
||||
widget's parent) must not drift further each time. The one shape this
|
||||
does not cover: `Aligned` wrapping `Aligned`, where the inner one's own
|
||||
`reposition` may have moved its content away from top-left already. No
|
||||
widget or example in this codebase builds that today; if one needs to,
|
||||
`reposition` would need the child to report *where* it painted, not
|
||||
just how big, which is a larger change than this pass's scope.
|
||||
|
||||
5. **A widget's `move_offsets` slot is allocated once, on its first-ever
|
||||
draw, and reused in place — never reallocated — for every later redraw
|
||||
of the same id, with its delta reset to `[0, 0]` on each reuse.** Not
|
||||
spelled out in §2's original text, which only said slots are assigned
|
||||
"when the widget is first drawn." Reallocating a fresh slot on every
|
||||
redraw would leave any *retained* (not-redrawn) descendant's `parent`
|
||||
link pointing at a now-orphaned old slot — a permanent leak, and worse,
|
||||
a descendant that silently stops tracking its ancestor's future moves.
|
||||
Resetting the delta on reuse (rather than carrying it forward) is
|
||||
required because a full redraw bakes the widget's correct absolute
|
||||
position into the fresh `region` argument directly; a stale delta left
|
||||
over from before the redraw would double-offset it.
|
||||
|
||||
Two further points worth recording because they were *design decisions*
|
||||
made while implementing, not bugs — `LAYOUT.md`'s own text left them
|
||||
unspecified rather than getting them wrong:
|
||||
|
||||
- **`Scroll` offers its content a region sized by the *previous* frame's
|
||||
measured content length, not a fresh one.** A fresh measurement would
|
||||
require drawing the content once to learn its size and — since that
|
||||
provisional size essentially never matches the previously active one —
|
||||
redrawing it a second time at the real size, on every single scroll
|
||||
tick, which is exactly the cost §2 exists to remove. Using the stale
|
||||
length means an ordinary scroll (position changes, content does not)
|
||||
offers the same *size* as last frame, only shifted, which is what makes
|
||||
`draw_inner` dispatch it as the O(1) move. The cost: a real content-size
|
||||
change lags one frame before the container's scroll range reflects it,
|
||||
self-correcting the frame after (the content length itself, read from
|
||||
what was actually drawn, is never stale — only the offered *region* used
|
||||
for placement is). No example in this repository builds a `Scroll` yet,
|
||||
so this could not be checked against a pixel diff; it is covered instead
|
||||
by `iris/src/layout_tests.rs`'s three `Scroll`-based unit tests, which
|
||||
build a tree and drive `UiRenderState` directly with no GPU or window
|
||||
needed.
|
||||
- **`redraw()`'s parent-relayout check draws the widget first, then
|
||||
compares the fresh `ActiveData.size` the draw produced against the size
|
||||
from before removal** — the mirror image of the old code's "query size,
|
||||
compare, decide whether to draw," which no longer has a size query to
|
||||
do the comparison with before drawing (§5 deleted `Cache`/`SizeCtx`
|
||||
along with `desired_width`/`desired_height`). This can occasionally draw
|
||||
a widget once more than the old code would have (if the parent it
|
||||
bubbles up to ends up redrawing the same widget again as part of its own
|
||||
relayout) — `draw_inner`'s own skip/move dispatch absorbs most of that
|
||||
redundancy for free, and this path is not one of §8's measured
|
||||
conditions, so the remaining slack was accepted rather than chased
|
||||
further.
|
||||
|
||||
## For IRIS.md
|
||||
|
||||
When this lands, copy this entry into `IRIS.md` (newest first):
|
||||
|
||||
> **2026-09-04 — `Widget::draw` reports the size it used; `desired_width`/
|
||||
> `desired_height` are gone.** A widget used to implement three methods
|
||||
> (`draw`, `desired_width`, `desired_height`); it now implements one,
|
||||
> `fn draw(&mut self, painter: &mut Painter) -> Size`, which draws into
|
||||
> `painter.region()` and returns how much of it was used. Why: the two
|
||||
> extra methods routinely re-simulated what `draw` was about to do anyway
|
||||
> (`Span::desired_ortho` copied its own draw loop to get cross-axis sizing
|
||||
> right) — one visit per widget per frame instead of up to three. A
|
||||
> container that needs a child's size before placing it (alignment,
|
||||
> centering) draws the child once at a provisional region, reads the
|
||||
> returned `Size`, and calls the new `Painter::reposition` to move it into
|
||||
> its final spot — an O(1) offset write, not a second draw. A widget whose
|
||||
> drawn output never depends on the size it's given (a fixed-size `Rect`,
|
||||
> a decoded `Image`) overrides the new `fn is_size_independent(&self) ->
|
||||
> bool { false }` to `true`, which skips redrawing it when only its
|
||||
> offered region changes shape.
|
||||
>
|
||||
> ```rust
|
||||
> // before
|
||||
> fn draw(&mut self, painter: &mut Painter) { /* ... */ }
|
||||
> fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||
> fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
|
||||
>
|
||||
> // after
|
||||
> fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
|
||||
> ```
|
||||
>
|
||||
> `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
|
||||
> design, the move-offset mechanism this shipped alongside, and the file
|
||||
> list.
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
# How iris should render an unbounded number of images
|
||||
|
||||
## Status (2026-09-04)
|
||||
|
||||
**Implemented**, on the `rustify` branch of `ai-app-2`, in `iris/core` and
|
||||
`iris/src/default/render.rs`. See "Implemented, 2026-09-04" at the bottom for
|
||||
what landed, what differs from the proposal below and why, and what was
|
||||
verified versus merely reasoned about. The short version: the binding array
|
||||
is gone, `request_device` asks for no features and no binding-array limits,
|
||||
and that is now proven on the emulator's software Vulkan
|
||||
(`rigs/gpu-probe`), not just read from the code. `RUST.md`'s blocking item
|
||||
is resolved.
|
||||
|
||||
Iris (the person) asked whether iris's (the library's) approach to
|
||||
"draw however many images happen to be on screen" — relevant here because a
|
||||
transcript can hold an unbounded number of attached screenshots — actually
|
||||
works on mobile, her recollection being that it does not. Checked rather
|
||||
than assumed, on 2026-09-04, on the `rustify` branch of `ai-app-2`. This
|
||||
file is that investigation and the resulting recommendation, written for a
|
||||
second agent to review before anything in iris's render core changes — no
|
||||
code has been written against this yet.
|
||||
|
||||
## The problem
|
||||
|
||||
Every texture iris ever creates — every `Image` widget
|
||||
(`iris/src/widget/image.rs`) and every glyph atlas page — gets a permanent
|
||||
slot in one array via `Textures::add` (`iris/core/src/primitive/texture.rs:65`).
|
||||
Both of iris's texture-sampling primitives (`TEXTURE` and `GLYPH`) read that
|
||||
array by index: `core/src/render/shader.wgsl:56` declares
|
||||
`var views: binding_array<texture_2d<f32>>`, sized by
|
||||
`UiLimits::default()` (`core/src/render/mod.rs:347`) at **100,000 textures,
|
||||
1,000 samplers**. Getting a device to accept that layout needs three wgpu
|
||||
features — `TEXTURE_BINDING_ARRAY`,
|
||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
|
||||
`PARTIALLY_BOUND_BINDING_ARRAY` — which correspond to Vulkan's
|
||||
`VK_EXT_descriptor_indexing` ("bindless"), promoted to Vulkan core at 1.2.
|
||||
|
||||
A transcript with an unbounded number of image attachments is exactly the
|
||||
case that grows this array without bound: each attachment becomes its own
|
||||
`Image` widget, which takes its own permanent array slot until dropped.
|
||||
|
||||
## What was measured
|
||||
|
||||
**A new rig, `rigs/gpu-probe`**, asks a device for exactly iris's features
|
||||
and limits with no window and no APK — a plain executable pushed with
|
||||
`adb push` and run from `/data/local/tmp`. It has two parts:
|
||||
`wgpu::Adapter::request_device` with iris's exact `Features`/`Limits`
|
||||
(`src/main.rs`), and a raw Vulkan query bypassing wgpu entirely via `ash`
|
||||
(`src/vk.rs`), to tell "the driver doesn't have it" apart from "wgpu didn't
|
||||
detect it."
|
||||
|
||||
- **On this VM's own GPU** (Vulkan via Venus onto an RX 7900 XT):
|
||||
`IRIS DEVICE: ok`. Not the case that matters — nobody's phone is a
|
||||
discrete desktop GPU — but it is why the design was never checked before
|
||||
now: it always worked in the one place it was tried.
|
||||
- **On the Android emulator's guest Vulkan**, both ICDs it ships
|
||||
(`vk_swiftshader_icd.json` and, cold-booted, `lvp_icd.json`/lavapipe):
|
||||
`request_device` **fails** —
|
||||
`Unsupported features were requested: TEXTURE_BINDING_ARRAY |
|
||||
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
|
||||
PARTIALLY_BOUND_BINDING_ARRAY`. The raw `ash` query on lavapipe shows the
|
||||
driver itself reporting all seven descriptor-indexing sub-features as
|
||||
`true` at device API version 1.3 — so wgpu-hal's own feature detection is
|
||||
being more conservative than the driver here, for a reason not chased
|
||||
further (a likely instance-version negotiation gap, since the extension
|
||||
only promoted to core at 1.2). That part is a wgpu-hal/emulator question,
|
||||
not the finding that matters, and is **not** why this design is rejected.
|
||||
|
||||
**The finding that matters is about real phones, sourced rather than
|
||||
recalled:**
|
||||
|
||||
- The **Android Vulkan Profile 2025** — Google and Khronos's current
|
||||
baseline, covering **80.1% of active Vulkan-capable Android devices** as
|
||||
of October 2025
|
||||
([developer.android.com/ndk/guides/graphics/android-vulkan-profile](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile)) —
|
||||
does **not** require `VK_EXT_descriptor_indexing` or any descriptor-
|
||||
indexing feature. It requires `shaderSampledImageArrayDynamicIndexing`
|
||||
(indexing by a value uniform across the invocation — Vulkan 1.0 baseline,
|
||||
unrelated to bindless) and stops there; true of the 2021 and 2022
|
||||
profiles as well.
|
||||
- Arm's own developer documentation states **"`VK_EXT_descriptor_indexing`
|
||||
is supported on all Valhall and 5th Gen GPUs"**
|
||||
([developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)) —
|
||||
Mali generations from roughly 2019 (Mali-G77) onward, with no claim made
|
||||
for Bifrost, Midgard or Utgard, which are still common in budget and
|
||||
older Android phones that are still in daily use.
|
||||
- A search engine's summarized claim of "1% support on Android" for this
|
||||
extension was checked against its cited source (an Arm blog post from
|
||||
2021) and **was not actually there** — that number does not appear in
|
||||
any primary source found and should not be repeated. The baseline-
|
||||
profile finding above is the one with an attributable source; use it
|
||||
instead.
|
||||
|
||||
So this is not a software-renderer artifact. A real, currently-shipping
|
||||
share of the Android fleet lacks the feature iris's texture pipeline asks
|
||||
for unconditionally, and neither the emulator's failure nor the current
|
||||
official hardware baseline gives any reason to expect that to change soon.
|
||||
|
||||
## What growth already costs today, before any redesign
|
||||
|
||||
Checked directly in `core/src/render/mod.rs` and `core/src/render/texture.rs`,
|
||||
because "does this redesign make things worse" needs the current baseline
|
||||
first:
|
||||
|
||||
- The `RenderPipeline` (`UiRenderNode::new`) is created **once** and never
|
||||
rebuilt for any reason related to texture count — its bind group
|
||||
*layouts* declare fixed slot counts (`limits.max_textures`,
|
||||
`limits.max_samplers`) up front and that never changes at runtime. Growth
|
||||
was never at risk of recreating the pipeline, in the current design or
|
||||
any redesign discussed below.
|
||||
- What **does** get rebuilt: `UiRenderNode::update` calls
|
||||
`self.textures.update(&mut ui.textures)`, and if that reports any change,
|
||||
rebuilds `self.rsc_group` — one `BindGroup` whose entries are
|
||||
`BindingResource::TextureViewArray(&tex_manager.views())`, collected
|
||||
fresh over **every currently-live texture**, plus the sampler array and
|
||||
the mask buffer. This happens on every texture `Push`, `Set`, or `Free`
|
||||
— an image added anywhere in the whole app rebuilds one shared structure
|
||||
referencing every other image too.
|
||||
- The one path already excluded from this, on purpose, is a `Patch` —
|
||||
writing into an existing texture's pixels without changing which
|
||||
textures exist. The code says why directly
|
||||
(`core/src/render/texture.rs`, in `GpuTextures::update`): *"A patch
|
||||
changes texture contents, not the binding array, so it must not report
|
||||
`changed` — rebuilding the bind group per glyph is the cost this exists
|
||||
to avoid."* This is exactly the mechanism I1 built for the glyph atlas:
|
||||
growing an existing atlas page costs a `write_texture` into a sub-rect,
|
||||
nothing else.
|
||||
|
||||
So today, growth that stays inside an existing texture (glyphs added to an
|
||||
atlas page) is already free. Growth that adds a *new* texture — a new atlas
|
||||
page, or any standalone image — already rebuilds the one shared array
|
||||
regardless of how the array is populated, before any change discussed
|
||||
below. That existing cost is O(live texture count) in CPU work to collect
|
||||
the view list and in however expensive the driver finds a
|
||||
descriptor-set-sized-for-N-descriptors to be.
|
||||
|
||||
## Prior art, checked rather than assumed
|
||||
|
||||
Two independent projects were checked to see whether "atlas for images"
|
||||
is actually how this is normally done, rather than a guess:
|
||||
|
||||
- **egui_wgpu** (`crates/egui-wgpu/src/renderer.rs` in emilk/egui), the
|
||||
closest prior art to iris — an immediate-mode wgpu-backed UI library that
|
||||
ships on Android. It keeps a `HashMap<TextureId, Texture>` and gives
|
||||
**each texture its own ordinary `BindGroup`** — one texture, one sampler,
|
||||
no array, no descriptor indexing of any kind. Draw calls are batched by
|
||||
texture id and the bind group is switched between batches within the
|
||||
render pass.
|
||||
- **Vello** — the renderer Masonry (E1/E2's Linebender stack) draws
|
||||
through — hit the identical problem and wrote down why in their own
|
||||
roadmap document
|
||||
([github.com/linebender/vello/blob/main/doc/roadmap_2023.md](https://github.com/linebender/vello/blob/main/doc/roadmap_2023.md)):
|
||||
*"The number of images that may appear in a scene is not bounded, which
|
||||
is not a good fit for the basic descriptor binding model... Until then,
|
||||
we'll do a workaround of having a single atlas image containing all the
|
||||
images in the scene."* Their reason is broader than Android — WebGPU 1.0
|
||||
has no descriptor indexing at all — but it reaches the same conclusion
|
||||
for the same shape of problem: atlas, not a bigger bindless array.
|
||||
|
||||
**This is also a live hazard, not a solved one.** Vello's own changelog
|
||||
(Sparse Strips v0.2.0) lists a fix titled *"WebGL image-atlas allocation
|
||||
and growth on Mali-G52 GPUs, avoiding application-not-responding errors"*
|
||||
— an actual ANR, from atlas growth, on an actual mid-range Android GPU,
|
||||
in the renderer Masonry is built on. The same release added
|
||||
`AtlasSpaceDiagnostics`/`AtlasLayerDiagnostics` (per-layer free-space,
|
||||
utilization, fragmentation) because growth needed instrumenting in
|
||||
production, not because it turned out to be free.
|
||||
|
||||
## Recommendation (not yet implemented)
|
||||
|
||||
1. **Small, plentiful textures** — glyphs (already done, I1), thumbnails,
|
||||
downscaled attachment previews, icons — go through a shared atlas, the
|
||||
same technique as `core/src/render/atlas.rs` generalized beyond glyphs.
|
||||
Adding one to an existing page is a `Patch`, already free per the
|
||||
section above.
|
||||
2. **Large or one-off images** — a photo attachment opened at full
|
||||
resolution, anything that would fragment a shared page — get their
|
||||
**own ordinary, non-array bind group**, the egui_wgpu way. Creating one
|
||||
is O(1): it references only itself, and does not touch any other
|
||||
texture's binding, unlike today's shared array where every push
|
||||
rebuilds a structure listing everything.
|
||||
3. **Opening a new atlas page** is the one case that still resembles
|
||||
today's rebuild — infrequent (bounded by how many *pages* are needed,
|
||||
not by how many images have ever been attached) but not free, and
|
||||
Vello's Mali-G52 fix says this specifically deserves care: it should
|
||||
never be allowed to block a frame, and it is worth having the
|
||||
equivalent of Vello's atlas diagnostics before trusting it under load.
|
||||
4. **Net effect**: dropping `TEXTURE_BINDING_ARRAY`,
|
||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`, and
|
||||
`PARTIALLY_BOUND_BINDING_ARRAY` from iris's device request entirely.
|
||||
Every path above is plain Vulkan 1.0 / GLES-level texture sampling.
|
||||
This is also what fixes the emulator failure measured above, regardless
|
||||
of the unresolved wgpu-hal question: a device that never asks for the
|
||||
feature cannot be refused for lacking it.
|
||||
|
||||
## What this touches, and what is still open
|
||||
|
||||
Implementing this reworks iris's rendering core: the shader's binding
|
||||
group layout (`shader.wgsl`), `Textures` and `GpuTextures`
|
||||
(`core/src/primitive/texture.rs`, `core/src/render/texture.rs`), both
|
||||
texture-sampling primitives, and `core/src/ui/painter.rs`'s draw-call
|
||||
batching (today one draw call can reference any texture by index; the
|
||||
per-texture-bind-group path needs draws grouped by which bind group they
|
||||
use). Nothing has been started.
|
||||
|
||||
Open questions a reviewer should weigh in on:
|
||||
|
||||
- **The size threshold** between "goes in an atlas page" and "gets its own
|
||||
bind group." Too low and ordinary attachment thumbnails end up as
|
||||
one-off bind groups, losing the batching benefit the atlas exists for;
|
||||
too high and a page fragments on a handful of medium images.
|
||||
- **Eviction policy** for atlas pages once the working set does not fit —
|
||||
today's `GlyphAtlas` never evicts, because a font's glyph set is small
|
||||
and bounded; images are not. An LRU at the page level, or at the
|
||||
individual-image level within a page, has not been designed.
|
||||
- **Whether iris should keep any binding array at all**, even a small
|
||||
fixed one (say, capped at a few dozen slots) for atlas pages themselves,
|
||||
or whether every atlas page should also be its own ordinary bind group
|
||||
like standalone images — the array's only remaining justification would
|
||||
be avoiding a bind-group-per-draw-call switch cost that has not been
|
||||
measured on this project's actual target hardware.
|
||||
- **How this interacts with I2/E2's virtualised list** (I3): a
|
||||
bottom-anchored transcript composes only visible rows, so the live
|
||||
texture set should already be bounded by what is on screen rather than
|
||||
by the whole conversation — worth confirming that invariant holds before
|
||||
relying on it to keep atlas/bind-group churn small.
|
||||
|
||||
## Review, 2026-09-04
|
||||
|
||||
A second pass over the file above against the code, done before anything
|
||||
is implemented. Iris's worry going in: a bind group per texture means a
|
||||
draw call per image, and she wants this as efficient as it can be.
|
||||
|
||||
### What checked out
|
||||
|
||||
Every code reference above is accurate as of this commit: the 100,000 /
|
||||
1,000 limits, the one-time pipeline, the `rsc_group` rebuild on every
|
||||
`Push`/`Set`/`Free`, and the `Patch` exclusion. The device request that
|
||||
asks for the three features is `iris/src/default/render.rs:96`, which the
|
||||
text above does not name. egui-wgpu and Vello are described correctly.
|
||||
|
||||
### The emulator refusal is a wgpu-hal gap, now located
|
||||
|
||||
The file guessed "a likely instance-version negotiation gap." It is
|
||||
narrower than that and it is in wgpu-hal, not the emulator. wgpu-hal
|
||||
28.0.0 (`src/vulkan/adapter.rs:1618`) only queries
|
||||
`PhysicalDeviceDescriptorIndexingFeaturesEXT` **when the device advertises
|
||||
the `VK_EXT_descriptor_indexing` extension string**. A Vulkan 1.2+ driver
|
||||
that has descriptor indexing as core need not list the extension, and
|
||||
lavapipe at 1.3 evidently does not, so wgpu never asks and reports the
|
||||
features absent, which is why `ash` sees seven `true`s and wgpu sees none.
|
||||
The properties query beside it (line 1486) correctly accepts
|
||||
`device_api_version >= 1.2 || extension`; the features query does not.
|
||||
wgpu-hal 30.0.1 in the local registry has the same asymmetry (lines
|
||||
1872 and 2036). Worth an upstream issue, but not a reason to keep the
|
||||
design: on real phones the gate that matters is stricter still.
|
||||
|
||||
**wgpu's `TEXTURE_BINDING_ARRAY` needs six sub-features, not one**
|
||||
(`adapter.rs:160-177`): non-uniform indexing *and* update-after-bind for
|
||||
sampled images, storage images and storage buffers, all together, because
|
||||
wgpu marks every array-bearing descriptor set update-after-bind. So Arm's
|
||||
"the extension is supported on Valhall" is necessary but not sufficient;
|
||||
a driver with sampled-image indexing and without storage-buffer
|
||||
update-after-bind is refused too. That widens the excluded set beyond
|
||||
what the Arm quote suggests and strengthens the conclusion.
|
||||
|
||||
### A live bug in the current code, found on the way
|
||||
|
||||
`GpuTextures::update` (`core/src/render/texture.rs:33`) implements
|
||||
"a patch must not report changed" as `changed = false`, unconditionally,
|
||||
which also **cancels a `Push` earlier in the same batch**. That ordering is
|
||||
exactly what opening a new atlas page produces: `GlyphAtlas::allocate`
|
||||
pushes the page and `insert` patches it in the same frame, so the bind
|
||||
group is not rebuilt and the new page's view is not bound until some
|
||||
unrelated texture change happens to rebuild it. It is hidden today only
|
||||
because the masks path also sets `changed`. The fix is one line
|
||||
(`changed |= !matches!(update, Patch)` in spirit); it should go in with
|
||||
the redesign since that code is being replaced, and it is recorded here
|
||||
so it is not rediscovered.
|
||||
|
||||
### In-layer draw order is already undefined
|
||||
|
||||
Relevant to any batching redesign: `Primitives::apply_free`
|
||||
(`core/src/render/primitive.rs:147`) uses `swap_remove`, so the instance
|
||||
order within a layer is permuted whenever anything is freed. Overlap order
|
||||
inside one layer is therefore not something the renderer promises today;
|
||||
ordering is done with layers. That means grouping a layer's draws by
|
||||
texture, or drawing a layer's images after its rects and glyphs, loses
|
||||
nothing that currently exists. It should be written down as an invariant
|
||||
when the redesign lands, because the new code will depend on it.
|
||||
|
||||
### On "a draw call per image"
|
||||
|
||||
Two corrections to the worry. First, it is a draw per *distinct texture per
|
||||
layer*, not per image primitive: every glyph quad in a layer shares the
|
||||
atlas and stays one instanced draw, and a thumbnail atlas would do the same
|
||||
for previews. Second, the count is bounded by what is on screen, which I3's
|
||||
virtualised transcript already bounds, and a mobile GPU is not draw-call
|
||||
bound at tens of draws per frame; egui ships exactly this on Android. What
|
||||
does cost is per-frame *bind group creation* and per-frame *sorting*, and
|
||||
the current code already creates a `primitive_group` bind group every time
|
||||
a layer updates (`render/mod.rs:103`), so one more per new image is not a
|
||||
regression in kind.
|
||||
|
||||
### Recommended shape (proposal, for Iris to accept or change)
|
||||
|
||||
Aimed at the fewest moving parts that need no feature beyond Vulkan 1.0:
|
||||
|
||||
1. **Atlas pages become layers of one `texture_2d_array`**, not separate
|
||||
textures. Every page is already `PAGE`x`PAGE` RGBA8, which is the one
|
||||
constraint an array texture imposes. A layer index is an ordinary
|
||||
sampling operand in WGSL and needs no indexing feature, so `GLYPH`
|
||||
(and any future atlased-image primitive) carries a layer instead of a
|
||||
`view_idx` and all of a layer's text stays **one draw**. This answers
|
||||
the open question above about keeping a small binding array: no. Cost
|
||||
of opening a page: recreate the array with one more layer and
|
||||
`copy_texture_to_texture` the old ones, GPU-side, no readback; grow
|
||||
with headroom (double) so it is rare. wgpu's default
|
||||
`max_texture_array_layers` is 256, at 4 MB each, so the cap is memory
|
||||
rather than the API.
|
||||
2. **Every standalone image is its own texture with its own bind group**,
|
||||
and its instances live in a **separate per-layer instance list**, not
|
||||
the main one. Then the main instance buffer never contains an image,
|
||||
there is nothing to sort, no handle remapping beyond what
|
||||
`apply_free` already does, and each image is `draw(0..4, k..k+1)` with
|
||||
its bind group set first. Group 2's layout becomes `{atlas array,
|
||||
one image texture, sampler, masks}`; the main draw binds a 1x1 null
|
||||
image in the image slot, each image draw binds its own. One pipeline,
|
||||
one shader, one layout.
|
||||
3. **No thumbnail atlas in the first version.** With images on their own
|
||||
textures, the threshold and eviction questions above disappear: an
|
||||
image is freed when the row that owns its `TextureHandle` scrolls out.
|
||||
Add an image atlas only if a measured screen shows enough small images
|
||||
to matter, which a transcript rarely does.
|
||||
4. **Drop the three features and the two `max_binding_array_*` limits from
|
||||
`src/default/render.rs`**, and the `UiLimits` counts with them.
|
||||
5. **Sampling is `NonFiltering` today** (`render/mod.rs:290,299`), so a
|
||||
downscaled attachment will alias. Either request a filtering sampler
|
||||
for the image slot or downscale on the CPU before upload; decide when
|
||||
the image widget is touched, not as part of this.
|
||||
|
||||
What this costs against the file's original recommendation: `Textures`
|
||||
needs to know an image from a page (two kinds of handle, or a kind on
|
||||
`TextureHandle`), and `Primitives` gets a second instance list per layer.
|
||||
What it saves: the sort, the size threshold, the eviction policy, and any
|
||||
per-page bind group switch.
|
||||
|
||||
## Implemented, 2026-09-04
|
||||
|
||||
The shape above, built as proposed with one structural addition the proposal
|
||||
didn't need to spell out and one bug it predicted made moot rather than
|
||||
literally fixed. Files: `core/src/primitive/texture.rs` (`Textures`,
|
||||
`TextureHandle`), `core/src/render/texture.rs` (`GpuTextures`),
|
||||
`core/src/render/primitive.rs` (`Primitives`, `GlyphPrimitive`),
|
||||
`core/src/render/atlas.rs`, `core/src/ui/painter.rs`,
|
||||
`core/src/render/mod.rs` (`UiRenderNode`, `UiLimits` removed),
|
||||
`core/src/render/shader.wgsl`, `src/default/render.rs`, and
|
||||
`rigs/gpu-probe/src/main.rs`.
|
||||
|
||||
**1. Atlas pages as array layers.** `GpuTextures` owns one
|
||||
`texture_2d_array` (`array_texture`/`array_view`), grown by doubling
|
||||
(`grow_array`): a new texture is created at twice the layer capacity, the
|
||||
old layers are copied across with `copy_texture_to_texture` (GPU-side, no
|
||||
readback), and every bind group that referenced the old view — the main
|
||||
one and every live standalone image's — is rebuilt, since the view's
|
||||
identity changed. `GlyphPrimitive` carries `layer: u32` instead of
|
||||
`view_idx`/`sampler_idx`; the layer number is assigned synchronously in
|
||||
`Textures::add_page` (a plain counter, `next_page_layer`), not by the
|
||||
renderer, because `GlyphAtlas::insert` needs it in the same call, before
|
||||
any GPU sync happens — the renderer only finds out later, when it
|
||||
processes the queued `Push`.
|
||||
|
||||
**2. Standalone images, one bind group each.** `TextureKind` on
|
||||
`TextureHandle`/`Textures` distinguishes `Image` (a plain bind-group index,
|
||||
`slot`) from `Page { layer }`. `Primitives` gained a second per-layer list
|
||||
— `images: Vec<PrimitiveInstance>`, tagged `IMAGE_BINDING` — separate from
|
||||
`instances` (rects and glyphs), written by `Painter::write_image` rather
|
||||
than through the generic `Primitive` trait, since an image has nowhere in
|
||||
`PrimitiveData` to put a per-instance entry once the bind group already
|
||||
picks the texture. `UiRenderNode::draw` draws a layer's `instance` buffer
|
||||
once as before, then walks `image_instance` one entry at a time, binding
|
||||
that texture's `BindGroup` (`GpuTextures::image_bind_group`) and issuing
|
||||
`draw(0..4, k..k+1)` per image. Group 2's layout is exactly the proposed
|
||||
`{atlas array, one image texture, sampler, masks}`; the main draw binds a
|
||||
1x1 null view in the image slot.
|
||||
|
||||
**The one addition beyond the proposal**: the masks storage buffer lives
|
||||
in every per-image bind group (group 2, binding 3), and `ArrBuf<Mask>`
|
||||
recreates its buffer whenever the mask count changes size
|
||||
(`render/util/mod.rs`'s `ArrBuf::update` now returns whether it resized).
|
||||
A resize invalidates every bind group holding the old buffer, not just the
|
||||
main one, so `GpuTextures::update` takes a `masks_resized: bool` and calls
|
||||
`rebuild_image_bind_groups` when it's set, alongside the same rebuild the
|
||||
array-growth path already needed. This wasn't a design question the
|
||||
proposal had to answer (it treated bind-group construction as a given),
|
||||
but it's exactly the shape of trap layer growth already had, so it uses
|
||||
the same fix.
|
||||
|
||||
**3. No thumbnail atlas.** Not built, as proposed.
|
||||
|
||||
**4. Removed**: `TEXTURE_BINDING_ARRAY`, `PARTIALLY_BOUND_BINDING_ARRAY`,
|
||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` from
|
||||
`src/default/render.rs`'s `request_device`, and `UiLimits` (the type
|
||||
itself, not just its binding-array methods — once its two fields were
|
||||
gone there was nothing left in it, and `UiRenderNode::new` no longer takes
|
||||
a limits parameter). `binding_array` no longer appears anywhere in
|
||||
`shader.wgsl`.
|
||||
|
||||
**5. Sampling** is still `NonFiltering`, unchanged, per the proposal's own
|
||||
note that this is a separate decision for whenever the image widget itself
|
||||
is touched.
|
||||
|
||||
**The `changed = false` bug is structurally gone, not patched.** The old
|
||||
`GpuTextures::update` held one `changed: bool` that a `Patch` reset
|
||||
unconditionally, which could erase an earlier `Push` in the same batch (a
|
||||
new atlas page's `Push` immediately followed by `GlyphAtlas::insert`'s
|
||||
`Patch`, both queued before the renderer ever runs). The new `update`
|
||||
computes the rebuild signal by OR-ing each event's own answer
|
||||
(`rebuild_main |= self.push(...)`), and `Patch`'s arm simply never
|
||||
contributes to it — there is no shared mutable flag left for a `Patch` to
|
||||
stomp on. Documented at the call site
|
||||
(`core/src/render/texture.rs`, `GpuTextures::update`'s doc comment and the
|
||||
`Patch` match arm's comment) rather than fixed as a one-line diff, since
|
||||
the mechanism that could go wrong no longer exists.
|
||||
|
||||
**In-layer draw order is an explicit invariant now, not just a fact about
|
||||
`swap_remove`.** `UiRenderNode::draw` draws every layer's images after its
|
||||
rects and glyphs, and `Primitives::apply_free`'s doc comment states
|
||||
directly that both of a layer's lists (`instances` and `images`) free with
|
||||
`swap_remove` and that nothing may assume adjacency survives a free —
|
||||
recorded there because `apply_free` is the one place a change to either
|
||||
list's ordering would have to be reconciled.
|
||||
|
||||
**Verified:**
|
||||
|
||||
- `cargo fmt --all -- --check`, `cargo build --workspace --all-targets`,
|
||||
`cargo clippy --all-targets`, `cargo test --workspace` all clean in
|
||||
`iris/`, on the pinned `nightly-2026-09-03` toolchain. 14 tests pass
|
||||
(unchanged from I1; nothing here is pure-logic enough to add a unit
|
||||
test to — it's all GPU resource wiring).
|
||||
- `iris/run-headless.sh minimal --shot /tmp/minimal.png` and
|
||||
`iris/run-headless.sh tabs --shot /tmp/tabs.png`: both render correctly
|
||||
on this VM's GPU (Venus) — `tabs`'s glyph-atlas text renders in every
|
||||
panel, confirming `GlyphPrimitive.layer` addresses the array correctly.
|
||||
- The standalone-image path specifically: a throwaway example (not
|
||||
committed) with an `image(...)` widget as part of the root, run the same
|
||||
way, rendered the image next to glyph-atlas text in one frame —
|
||||
confirming a live `BindGroup` built by `GpuTextures::create_image` and
|
||||
bound per-`draw()` call actually samples the right texture. `tabs`'s own
|
||||
"image span" tab exercises the same widget but needs a click to reach,
|
||||
which the headless compositor can't deliver (no seat devices, per I1's
|
||||
own note on this file) — the throwaway example is what stood in for it.
|
||||
- **Exercised, 2026-09-04: `grow_array` under real load, on `tabs`.**
|
||||
Rather than building a purpose-made glyph flood, `PAGE`
|
||||
(`core/src/render/atlas.rs`) was temporarily dropped from 1024 to 64 —
|
||||
small enough that `tabs`'s ordinary mix of sizes and families (nothing
|
||||
exotic: a handful of `Text` widgets at a few sizes, one at
|
||||
`Family::Monospace`) already exceeds one page's worth of distinct
|
||||
glyphs. A one-line `eprintln!` in `grow_array` confirmed two real grows
|
||||
in a single run (`GROW_ARRAY: 1 -> 2` then `GROW_ARRAY: 2 -> 4`, i.e.
|
||||
glyphs landed on at least a third layer), and
|
||||
`iris/run-headless.sh tabs --shot` showed every tab's text rendering
|
||||
correctly with no corruption or missing glyphs — confirming the
|
||||
`copy_texture_to_texture` grow-and-relocate path and cross-layer
|
||||
sampling (`GlyphPrimitive.layer` addressing a layer beyond the first)
|
||||
both work. Command:
|
||||
`sed -i 's/PAGE: u32 = 1024/PAGE: u32 = 64/' core/src/render/atlas.rs`,
|
||||
rebuild, `./run-headless.sh tabs --shot /tmp/x.png`, then
|
||||
`git checkout -- core/src/render/atlas.rs` to revert — this is a
|
||||
throwaway diagnostic value, never a committed change, since a real
|
||||
1024px page holding only a handful of glyphs at a time would be mostly
|
||||
wasted space in normal use. Confirmed the revert left `tabs` and
|
||||
`minimal` byte-identical to the pre-check screenshots afterward.
|
||||
- **The decisive check**, `rigs/gpu-probe` rewritten to request iris's new
|
||||
(empty) feature/limit set and run on this checkout's own emulator
|
||||
(`ai-app-2`, via `emu`), booted with `EMU_GPU=software` so the guest gets
|
||||
a real Vulkan device (SwiftShader) rather than the `-gpu host` default,
|
||||
which disables Vulkan in this VM entirely (`-feature -Vulkan`, because
|
||||
gfxstream can't pair Venus with the real GPU here — worth remembering,
|
||||
since the *default* `emu up` gives a device with **no** Vulkan adapter
|
||||
at all, which reads exactly like the old bindless failure if you don't
|
||||
know to ask for `EMU_GPU=software`):
|
||||
|
||||
cd rigs/gpu-probe
|
||||
ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/29.0.14206865 \
|
||||
cargo ndk -t arm64-v8a -P 26 build --release
|
||||
EMU_GPU=software emu up # from ~/repos/emulator-tools
|
||||
adb push target/aarch64-linux-android/release/gpu-probe /data/local/tmp/
|
||||
adb shell chmod 755 /data/local/tmp/gpu-probe
|
||||
adb shell /data/local/tmp/gpu-probe
|
||||
|
||||
Output: `adapters: 1 — Vulkan SwiftShader Device (Subzero) (Cpu)`,
|
||||
`features iris requires:` (none listed — the set is empty),
|
||||
`max_buffer_size … ok`, and **`IRIS DEVICE: ok`**. This is the fix
|
||||
measured working, on the exact rig that first measured it failing.
|
||||
Emulator stopped afterward (`emu down`); nothing was left running.
|
||||
@@ -3,56 +3,33 @@
|
||||
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 — composer and input
|
||||
|
||||
- [ ] Make the file-add button and the ones beside it "bubble" buttons with a
|
||||
visible outline. The popups they open should be round.
|
||||
- [ ] Returning to the app must not open the keyboard if it was closed when
|
||||
the app was left.
|
||||
- [ ] Tapping a slash-command suggestion should move the cursor to the end of
|
||||
the inserted suggestion.
|
||||
|
||||
## App — transcript
|
||||
|
||||
- [ ] Tapping an expanded agent message card should close it.
|
||||
- [ ] Tapping to deselect text should not toggle card expansion.
|
||||
- [ ] Text inside code blocks does not highlight when selected (selection
|
||||
itself works — only the highlight is missing).
|
||||
- [ ] Bash logs should apply colour and the other basic text escape sequences,
|
||||
and filter the rest.
|
||||
- [ ] An image should show a loading spinner in an area the size of the image.
|
||||
- [ ] Messages received from other agents are inconsistent — sometimes they
|
||||
appear, sometimes they don't.
|
||||
|
||||
## App — navigation
|
||||
|
||||
- [ ] Swiping right should open the session list, unless the gesture belongs to
|
||||
a component (e.g. scrolling left inside a long text block).
|
||||
|
||||
## App — AskUserQuestion card
|
||||
|
||||
- [ ] Selection should highlight instantly instead of waiting; a Submit button
|
||||
at the bottom sends, and becomes a spinner while sending.
|
||||
- [ ] Arrow buttons in the top right navigate between questions instead of
|
||||
stacking them all in a row.
|
||||
- [ ] Submit stays greyed out until every question is answered.
|
||||
|
||||
## Formatting
|
||||
|
||||
- [ ] Show a timeout in the largest possible unit: 480000ms = 8m. Under a
|
||||
minute show only the largest unit (2.5s, 30ms); at or over a minute show
|
||||
all units (5d 12h 4m).
|
||||
- [ ] Use tilde notation when moving the working directory.
|
||||
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.
|
||||
- [ ] Changing the model on a stopped provider should be possible, stored, and
|
||||
applied the next time it starts.
|
||||
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.
|
||||
|
||||
## Usage
|
||||
|
||||
- [ ] Add one minute to Claude's "time left".
|
||||
- [ ] The transcript header and the usage menu should share one usage value —
|
||||
they have been seen disagreeing.
|
||||
@@ -0,0 +1,427 @@
|
||||
# The transcript cache
|
||||
|
||||
Asked for by Iris on 2026-09-04 and built the same day: keep the transcripts
|
||||
of recently visited sessions on the phone, so reopening one does not download
|
||||
it again. It has to save data over the tunnel, must not disturb a reply that
|
||||
is streaming when the screen is reopened, must never skip an event, and needs
|
||||
a manual reload for when the file on the machine has changed under it.
|
||||
|
||||
Like EXPLORER.md this records each decision with its reason and what was
|
||||
rejected, so that when one changes it is changed here rather than re-argued.
|
||||
"What building it changed" at the foot says which of them moved while it was
|
||||
being built. How to exercise it, and what has bitten, are in AGENTS.md.
|
||||
|
||||
## What it is, in one paragraph
|
||||
|
||||
A per-session file on the phone holding the exact JSON lines the server has
|
||||
already sent, in transcript order, with a record of which sequence numbers
|
||||
each run of lines covers. Everything the session screen fetches — the opening
|
||||
window, the pages it scrolls back through, the span an anchor restore reaches
|
||||
for — is asked of the cache first and of the server only for what the cache
|
||||
does not hold, and everything that arrives from the server is written into
|
||||
it. The live stream then resumes from the newest cached event, exactly as it
|
||||
resumes from the newest event on screen, so the server sends only what
|
||||
happened since. One tiny request checks that the cached tail is still what
|
||||
the server has before the stream is opened from it, and a button in session
|
||||
settings throws the cache away and rebuilds the screen as a cold open for the
|
||||
cases that check cannot see.
|
||||
|
||||
## The invariants
|
||||
|
||||
When a decision below looks arbitrary, it is one of these forcing it.
|
||||
|
||||
1. **What is on screen is what the server's transcript says, in order, with
|
||||
nothing missing, for every sequence number the screen claims to show.**
|
||||
The cache is a copy of server output and is never inferred, folded, or
|
||||
edited on the phone. Where the copy cannot be shown to be current, it is
|
||||
thrown away, not patched.
|
||||
2. **A cached line is never ahead of the live cursor, and the live cursor is
|
||||
never ahead of the cache.** The stream resumes from the newest cached
|
||||
event, so a reply that was mid-stream when the screen closed picks up at
|
||||
its next delta and folds into the same row.
|
||||
3. **The cache is never load-bearing.** A missing, evicted, corrupt or
|
||||
unwritable cache degrades to a cold open, never to a blank or wrong
|
||||
screen. Every path that reads it has a network path beside it producing
|
||||
the same result.
|
||||
4. **Data crosses the tunnel once.** A line already on the phone is not
|
||||
fetched again unless the reader asks (the reload button) or the check in
|
||||
decision 3 says it must be.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Raw server lines, on the phone, keyed by server and session
|
||||
|
||||
The cache stores the server's own JSON, one event per line, byte-for-byte as
|
||||
it arrived: the elements of the `/transcript` array and the `data:` payload
|
||||
of each SSE frame. Reading the cache runs the same `parseSeqEvent` the
|
||||
network path runs, so a cached transcript and a fetched one cannot draw
|
||||
differently, and an event type this build does not know
|
||||
(`SessionEvent.Unknown`) survives on disk for the build that will.
|
||||
|
||||
It lives under `context.cacheDir`, which is exactly what that directory is
|
||||
for: bytes the phone can regenerate from the server, which Android may delete
|
||||
under storage pressure without asking. Keyed by the server's host and port,
|
||||
because two servers can hold a session with the same id (the sandbox and the
|
||||
real server, or a re-enrolment) and a line from one shown against the other
|
||||
is invariant 1 broken. The `v1` segment is the format version: any change to
|
||||
the layout below bumps it, and a directory of another version is deleted on
|
||||
first use.
|
||||
|
||||
Rejected: a database (Room, SQLite). The access pattern is "the newest N
|
||||
lines" and "the lines before seq X", on files of tens of megabytes at most,
|
||||
and a JSONL file per contiguous run answers both by reading from its end. A
|
||||
database would be a new dependency for an index the file layout provides.
|
||||
|
||||
Rejected: caching folded `TranscriptItem` rows instead of events. Rows are a
|
||||
*rendering* of events, and their shape changes when the fold changes; the
|
||||
cache would need invalidating on every app update that touched `foldEvent`,
|
||||
and would still have to keep raw seqs for the stream cursor. Events are the
|
||||
server's contract and the only thing that is stable.
|
||||
|
||||
### 2. Chunks with explicit coverage; one contiguous run behind the cursor
|
||||
|
||||
A page from the server is a set of lines *and a claim about what they cover*,
|
||||
and the two are not the same thing. A coalesced page joins each run of
|
||||
`assistantText` deltas into one event carrying the seq of its *oldest* delta,
|
||||
so a page whose newest event has seq 1,200 may in fact cover every line up to
|
||||
the `before` it was asked with, say 1,650. Nothing in the lines themselves
|
||||
says so. So each stored chunk records its coverage as a half-open range
|
||||
`[first, end)`, where `end` is the `before` the request was made with — or,
|
||||
for a raw chunk, its newest seq plus one.
|
||||
|
||||
Chunks are files named by their coverage:
|
||||
|
||||
<first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
|
||||
<first>-<end>.raw.jsonl an uncoalesced page or a closed live run
|
||||
<first>-open.raw.jsonl the live run: appended to by the stream
|
||||
|
||||
Two chunks are **adjacent** when one's `end` equals the other's `first`. The
|
||||
cache serves only the contiguous run of adjacent chunks that ends at the
|
||||
newest raw chunk (the **suffix**); chunks behind a gap are kept on disk,
|
||||
because the gap is usually filled (decision 4), but are never served across
|
||||
it.
|
||||
|
||||
**The newest chunk is always raw.** That is what makes the stream cursor and
|
||||
the probe well defined: a raw chunk's last line is a real event at a real
|
||||
seq, and the server never coalesces the newest window. It holds by
|
||||
construction — the opening window is fetched with no `before`, stream frames
|
||||
are raw, and a `reset` window is raw — and is *checked* on read: a `.rows`
|
||||
chunk found newest (which can only happen if the app died between closing one
|
||||
live run and appending to the next) purges the session's cache.
|
||||
|
||||
There is at most one open chunk. A stream event whose seq is not the open
|
||||
chunk's `end` — which is what a `reset` looks like from here — closes it by
|
||||
renaming it with its real end and starts a new one. An event whose seq is
|
||||
below the open chunk's `end` is already covered and is not written; the SSE
|
||||
contract is `seq > after`, so that is a guard rather than a path.
|
||||
|
||||
Rejected: one file per session, rewritten to prepend older pages. A 20 MB
|
||||
transcript would be rewritten on every page scrolled back to. The chunk
|
||||
directory costs a directory listing per open instead.
|
||||
|
||||
Rejected: trimming chunks to resolve overlaps. A coalesced event cannot be
|
||||
split at a seq inside its run, so an overlap between a coalesced page and an
|
||||
existing chunk has no clean cut. The cache therefore **never stores a page
|
||||
that overlaps an existing chunk**; decision 4 makes sure such a page is never
|
||||
fetched, and one that arrives anyway is used for display and not stored.
|
||||
|
||||
### 3. The cached tail is checked against the server before the stream opens from it
|
||||
|
||||
The transcript file is append-only in ordinary use, but it can be replaced or
|
||||
truncated — a sandbox re-seeded with the same ids, a backup restored, a
|
||||
session deleted and re-imported — and `catch_up` on such a file would hand
|
||||
the phone a continuation of a *different* conversation, spliced onto the
|
||||
cached one with no seam. That is the worst thing this feature can do, and it
|
||||
is caught with one request.
|
||||
|
||||
**The probe** is `GET /sessions/{id}/transcript?before=<cursor+1>&limit=1`,
|
||||
where `cursor` is the seq of the cache's newest line. `read_window` with that
|
||||
`before` returns the single newest event with seq ≤ cursor, which is the
|
||||
event *at* the cursor when it exists. It passes when that response, parsed
|
||||
with `parseSeqEvent`, is `==` to the cached line parsed the same way — over
|
||||
seq, ts, and the whole event. It fails when the response is empty, is a
|
||||
different seq, or differs in any field.
|
||||
|
||||
That equality rested on an assumption this plan stated and did not check:
|
||||
that the two ways the server hands out a line agree bit for bit. **They did
|
||||
not**, and the server was fixed — see AGENTS.md's entry on `float_roundtrip`.
|
||||
Comparing everything *except* `ts` was the other option and was rejected: a
|
||||
re-seeded fixture is identical in content and differs only in when it
|
||||
happened, which is exactly the case the probe exists for.
|
||||
|
||||
A failed probe **purges the session's cache and proceeds as a cold open**. A
|
||||
probe that cannot be made leaves the cached transcript on screen, shows the
|
||||
error on the stream banner where a connection failure shows today, and is
|
||||
retried on the stream loop's schedule; the stream is never opened until a
|
||||
probe has passed once for this screen instance.
|
||||
|
||||
What the probe does *not* catch: a line changed in the middle of the file
|
||||
with the tail intact, or a file rewritten so that the event at the cursor
|
||||
happens to be identical. Those are what the reload button is for, and the
|
||||
button's caption says so.
|
||||
|
||||
Cost: one request of a few hundred bytes, in the slot where the opening
|
||||
page's request would be — so the round trips before the stream is live are
|
||||
unchanged at two, and the bytes fall from a page to a line. The cached rows
|
||||
are drawn *before* the probe returns, which is the whole point; a failed
|
||||
probe replaces them, with the same appearance as a `reset`.
|
||||
|
||||
Rejected: a server-side check on the stream, answered with a distinct frame
|
||||
when the event at N is not what the phone thinks. Strictly better coverage —
|
||||
it would run on every reconnect — and no extra round trip. Not chosen because
|
||||
it puts a cache's validation into a protocol that otherwise knows nothing
|
||||
about caching, and because the reset frame already has to keep meaning "you
|
||||
are behind, your history is fine". Worth revisiting if the probe's round trip
|
||||
is ever measured as the thing making reopen slow.
|
||||
|
||||
Rejected: trusting the cache and relying on the reload button. Invariant 1 is
|
||||
not something a button restores after the fact.
|
||||
|
||||
Rejected: fetching the newest page as before and using it to validate the
|
||||
overlap. Zero saving on the opening page, which is the request paid on every
|
||||
open.
|
||||
|
||||
### 4. Pages ask the server only for the gap: `after` on `/transcript`
|
||||
|
||||
After a reader has been away, the cache holds `[a, b)` and the screen holds
|
||||
the newest window `[W, …)` with a gap between `b` and `W`. Paging back from
|
||||
`W` asks for a coalesced page before `W`, and that page may reach back past
|
||||
`b` — a single reply is hundreds of lines, so forty rows can be thousands of
|
||||
seqs — producing exactly the overlap decision 2 refuses to store. Left like
|
||||
that, every cached chunk would be dropped in turn as the reader paged back
|
||||
through the gap, and the cache would save nothing for the sessions it exists
|
||||
for.
|
||||
|
||||
So the transcript route takes a lower bound, `after`, named to match the SSE
|
||||
route's (exclusive, `seq > after`). `read_window` starts the walk at
|
||||
`first_at_or_after(after + 1)` instead of at `end - limit`. A delta run cut
|
||||
at the start is emitted as the partial it is, exactly as one cut by `limit`
|
||||
already is, and `healSplitMessage` welds it on the phone — no new mechanism.
|
||||
|
||||
The phone passes `after = b - 1` where `b` is the `end` of the nearest chunk
|
||||
whose `end ≤ before`, and nothing when there is none. A page that comes back
|
||||
with `first == b` is adjacent, and the suffix now runs through the old
|
||||
chunks: the gap is closed with exactly the bytes it was wide, and the history
|
||||
behind it is served locally from then on.
|
||||
|
||||
Rejected: fetching the gap raw in one request, which is what the anchor
|
||||
restore does. Exact, but a gap of ten thousand lines is several megabytes
|
||||
downloaded to save re-downloading history the reader may never scroll to.
|
||||
|
||||
Rejected: dropping the cached run whenever a gap opens. Being more than
|
||||
`CATCH_UP_LIMIT` (200) events behind is the *ordinary* state of an active
|
||||
session revisited — 200 raw events is one reply — so this would empty the
|
||||
cache for exactly the sessions that are opened most.
|
||||
|
||||
### 5. A page is served locally in rows, mirroring the server's count
|
||||
|
||||
`loadOlderPage` asks for `HISTORY_PAGE` (40) **rows** when coalescing and for
|
||||
a number of **events** otherwise (the anchor restore). Served from the cache,
|
||||
the events branch is the `limit` lines before `before`. The rows branch walks
|
||||
back counting rows the way `parse_coalesced` does — every event that is not
|
||||
an `assistantText` is a row, and each maximal run of `assistantText` lines is
|
||||
one row — stopping only between rows. It does not join the deltas; the fold
|
||||
does that, and the joined row keeps the seq of its first delta either way, so
|
||||
anchors and the next `before` land where they do on the network path.
|
||||
|
||||
A cached page is allowed to be **short**: a walk that reaches the suffix's
|
||||
oldest chunk returns what it found. The caller already treats a short page as
|
||||
a page; only an *empty* page means "start of the conversation", and the cache
|
||||
never returns one — it returns `null` (a miss) and the network is asked.
|
||||
|
||||
A miss is `before` **outside what the suffix covers continuously** — above
|
||||
its newest `end`, or at or below its oldest `first`. This plan first said a
|
||||
miss was "no chunk of the suffix ends at `before`", which is wrong in the
|
||||
commonest case there is: a warm open draws the newest eighty lines of the
|
||||
live run, so the cursor the reader then scrolls back from is in the *middle*
|
||||
of a chunk. Under the narrower rule every warm open sent its first backwards
|
||||
page to the server, and that page overlapped what the phone already held and
|
||||
could not be stored, so the same history was fetched again on every visit.
|
||||
The feature would have saved the opening window and nothing else.
|
||||
|
||||
The row rule is a copy of the server's, and copies drift. It is short, it is
|
||||
pure, and it is under a JVM unit test with the same fixture as the server's
|
||||
`coalescing_counts_rows_and_joins_delta_runs` — a run cut by the limit, a
|
||||
`usageDelta` inside a run (the server flushes the run there, so it is two
|
||||
rows), and a page that is all one run.
|
||||
|
||||
### 6. What a `reset` means for the cache: behind, not wrong
|
||||
|
||||
The server sends `reset` when the cursor is more than `CATCH_UP_LIMIT` events
|
||||
behind, then the newest 200 raw events. For the cache that means **the
|
||||
history is intact and there is a gap**: the probe passed, the file is
|
||||
append-only, and the window's first seq is above the open chunk's end. The
|
||||
store learns this from the first window event's seq and needs no signal from
|
||||
the screen; the gap is filled by paging.
|
||||
|
||||
The reset handler also clears `queued` and `waitingCommands`, which it did
|
||||
not originally. Both are folded from events, and a `messageQueued` whose
|
||||
resolving `userMessage` fell in the gap would otherwise draw a waiting bubble
|
||||
for a message the session has long since read. That was a latent bug made
|
||||
likely by the cache, because a cached tail is older than a fetched one.
|
||||
`contextTokens` needs no clearing: `UsageDelta.context` is absolute, so the
|
||||
window's first one corrects it.
|
||||
|
||||
### 7. Session state that is not the transcript comes from the list, not the cache
|
||||
|
||||
`apply` derives `status`, `model`, `permissionMode` and `compactingSince`
|
||||
from `Status` and `Settings` events. Replayed from a fetched page those are
|
||||
current; replayed from the cache they are as old as the last visit, while the
|
||||
list row the reader just tapped was fetched moments ago. So the cache replay
|
||||
runs through `apply` for the transcript's sake and then **reassigns those
|
||||
four from `summary`**, which is the newer of the two measurements; the
|
||||
stream's catch-up then makes them current. Without this a session that
|
||||
finished an hour ago would open saying "working" until the stream connected,
|
||||
which is a status row lying for a round trip.
|
||||
|
||||
### 8. Reload, in session settings
|
||||
|
||||
A row under the working directory showing what the button discards:
|
||||
|
||||
[ Transcript ] 2.3 MB cached [ Reload ]
|
||||
|
||||
The size is the unknown state made visible — `null` while the directory is
|
||||
being measured (spinner, as the notifications switch does), "nothing cached"
|
||||
when the directory is absent or empty, else the size. The caption is in the
|
||||
style of Move's, because the button costs something the reader cannot see:
|
||||
*"Reload throws away this phone's copy and fetches the transcript from the
|
||||
server again. Use it when what is shown here disagrees with the file on the
|
||||
machine."*
|
||||
|
||||
Pressing it purges the session's cache directory, closes the dialog, and
|
||||
rebuilds the screen as a cold open, with the reader put back where they were.
|
||||
The mechanism is an `epoch` counter in the key of the opening effect and the
|
||||
stream effect; incrementing it cancels both and relaunches them. `savedAnchor`
|
||||
is keyed on the epoch too, so the restore reads the anchor saved at the
|
||||
reader's *current* position. The button is enabled whether or not anything is
|
||||
cached: "what I see disagrees with the machine" is a state an empty cache can
|
||||
also be in, and a control that comes and goes makes its own presence the
|
||||
signal.
|
||||
|
||||
Nothing is announced on success — the transcript shows the opening spinner
|
||||
and then the rows, which is what the screen already says about a reload. A
|
||||
failure is the opening fetch's, and lands on the stream banner.
|
||||
|
||||
Rejected: a global "clear transcript cache" in the app's settings. Not asked
|
||||
for; eviction bounds the total, and the per-session button is where the
|
||||
reader is when they notice a problem. Easy to add as one more caller of
|
||||
`purgeAll`.
|
||||
|
||||
### 9. Budget, eviction, pruning
|
||||
|
||||
Bounded three ways, each with its path out written beside the path in:
|
||||
|
||||
- **Budget.** `CACHE_BUDGET_BYTES` is 256 MB across all sessions of one
|
||||
server. Each open touches the session directory's mtime; after the opening
|
||||
replay, on `Dispatchers.IO`, the store sums the server's directories and
|
||||
deletes least-recently-touched ones (never the one on screen) until under
|
||||
budget. 256 MB is a dozen of the largest transcripts seen in this VM
|
||||
(21 MB for 24,000 events) and a small fraction of a phone; it is a number
|
||||
to revisit against real use, not a measurement.
|
||||
- **Deleted sessions.** The list screen's delete purges after `deleteSession`
|
||||
succeeds, and every successful list fetch calls `retainOnly(ids)`, so a
|
||||
session deleted from another device is pruned on the next visit to the
|
||||
list. `Drafts.kt` chose not to prune because its residue is bytes; here it
|
||||
is megabytes.
|
||||
- **Android.** `cacheDir` may be emptied at any moment, including while a
|
||||
screen is open. Every read tolerates a missing directory and every write
|
||||
failure is swallowed once.
|
||||
|
||||
### 10. The cache never breaks the screen
|
||||
|
||||
Every store operation that touches the disk catches `IOException` and answers
|
||||
as if the cache were empty: `null` from a read, no-op from a write, logged
|
||||
once. After a write failure the instance stops writing, so a full disk costs
|
||||
one log line rather than one per delta. A line at the end of an open chunk
|
||||
that does not parse — the app died mid-write — is dropped and the file
|
||||
truncated to the last good line before anything is served from it; a line
|
||||
that does not parse anywhere else purges the session's cache, since that file
|
||||
was not written by this code. None of this is reported on screen: none of it
|
||||
changes what the screen shows, and the reader has nothing to do about it.
|
||||
|
||||
## Layout on disk
|
||||
|
||||
<cacheDir>/transcripts/
|
||||
v1/
|
||||
10.0.2.2_8443/ one directory per server (host_port)
|
||||
3f2c…/ one per session id
|
||||
1-1650.rows.jsonl coalesced page: covers seqs 1..1649
|
||||
1650-2001.rows.jsonl
|
||||
2001-2400.raw.jsonl a closed live run
|
||||
2600-open.raw.jsonl the live run
|
||||
|
||||
Here 2400..2599 is a gap: the reader was away for two hundred events and the
|
||||
stream reset. The suffix is the single chunk `2600-open`; the first backwards
|
||||
page asks the server for `before=2600&after=2399&coalesce=true`, and once a
|
||||
page comes back with `first == 2400` the suffix runs to seq 1.
|
||||
|
||||
Each `.jsonl` is one JSON object per line, oldest first, exactly as the
|
||||
server sent it. No header, no index: coverage is in the name, order is the
|
||||
file's, and the seq is in every line.
|
||||
|
||||
## What building it changed
|
||||
|
||||
Each of these contradicted the plan, and each was found by running it rather
|
||||
than by reading it. The decisions above are amended in place; this is what
|
||||
moved, so a reader who remembers the first version knows what to re-read.
|
||||
|
||||
- **The probe's equality had a false premise** (decision 3). The server did
|
||||
not hand out the same line twice the same way. Fixed on the server.
|
||||
- **A cached page starts anywhere inside the run** (decision 5). Requiring a
|
||||
chunk boundary would have made the cache save the opening window and
|
||||
nothing else.
|
||||
- **The opening window is stored by `append`, not by `storePage`.** The
|
||||
sketch had `storePage` grow a special case for "this page is the new open
|
||||
chunk", decided by an implicit condition a raw history page also satisfies.
|
||||
Appending each line instead is the mechanism that already exists, and the
|
||||
open chunk stays the one thing that grows.
|
||||
- **Chunks are read backwards, in blocks, and never whole.** Every question
|
||||
the cache is asked is about the newest end, and a live run reaches the size
|
||||
of the conversation — so reading a chunk to answer with eighty lines of it
|
||||
is the cost the server's own reader was rewritten to stop paying, arriving
|
||||
on the phone. Damage is therefore noticed when a read reaches it rather
|
||||
than up front, which is the better time: what is not read cannot be wrong.
|
||||
- **The stream waits for the opening effect's probe.** The screen lifts
|
||||
`ready` before the probe returns — that is the point of the cache — so
|
||||
`ready` stopped being the whole gate, and the stream loop asked the same
|
||||
question a second time and raced its own answer. Two probes per warm open,
|
||||
visible in the server's log.
|
||||
- **`SessionCache` is synchronized.** The stream appends live events from one
|
||||
IO thread while a reader scrolling back reads pages from another; the open
|
||||
chunk's name, its end and its writer must never be seen half-rotated.
|
||||
|
||||
## What it cost, measured
|
||||
|
||||
On the emulator against `app/ui-sandbox.sh`, 2026-09-04, on a session of 505
|
||||
events (three short exchanges and two 300-delta replies):
|
||||
|
||||
- **Reopening it: one request, for one event.** The probe, and nothing else —
|
||||
including scrolling the whole conversation back to its first line. A cold
|
||||
open of the same session is two requests and 100 events.
|
||||
- **A reset after falling 300 events behind costs the gap and no more.** The
|
||||
window arrived at seq 306, the phone held up to 202, and the first
|
||||
backwards page asked `before=306&after=201` and came back with **four
|
||||
coalesced rows** covering 202..305 — against the 104 raw events an
|
||||
unbounded page would have re-fetched and thrown away.
|
||||
- **Every chunk is exactly what the server says for the range its name
|
||||
claims**, checked line by line against `/transcript` for each chunk's own
|
||||
`before`/`after`/`coalesce`, across a reset and a gap-fill.
|
||||
- **Nothing about drawing changed**, which is what a cache must not do:
|
||||
`transcript-bench.sh` before and after, same viewport content and gestures,
|
||||
p50 16.9ms both times and the transcript's own draw accounting at 0.33ms
|
||||
against 0.32ms.
|
||||
|
||||
Still to measure, in real use rather than here: the size the cache reaches
|
||||
against `CACHE_BUDGET_BYTES`, and whether the probe's round trip is ever what
|
||||
a reader waits on.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **The probe on every reconnect, not only on open?** A file replaced *while*
|
||||
the screen is open is not made worse than it was, but the server-side check
|
||||
decision 3 rejects would close it. Decide after measuring how often the
|
||||
probe's round trip is what the reader waits on.
|
||||
- **Images.** `SessionImage` fetches bytes from the files route on draw; they
|
||||
are not part of this cache and are re-downloaded per view. A separate,
|
||||
simpler cache (a directory of refs, no ordering) if the measurement above
|
||||
says the images are where the data goes.
|
||||
@@ -1,272 +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.
|
||||
|
||||
**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.** `CodeFence.kt` holds `highlight` (shared with a
|
||||
tool call's input, so the same code is the same colours wherever it
|
||||
appears), 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 174ms to lex, 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`).
|
||||
- **highlights 1.1.0 finds comments before it knows the language, and pairs
|
||||
`/*` with `*/` by ordinal.** Measured against the library directly with
|
||||
`app/highlights-repro.sh`, which asks it for a piece of code outside the
|
||||
app and prints every span with the text under it -- the mistakes are
|
||||
invisible on a phone, where a line greyed out as a comment looks like a
|
||||
comment. In `MultilineCommentLocator` and `CommentLocator`: it collects every `/*` and
|
||||
every `*/` in the code, zips the two lists by position and never checks
|
||||
that the end follows the start, so `x '*/a/*'` yields `start=6, end=5` --
|
||||
a range `AnnotatedString` rejects, which crashed a card holding
|
||||
`-path '*/.git/*'` until `highlight` started dropping such spans. The same
|
||||
delimiters are used for every language, so a shell glob is read as a
|
||||
comment opener and, worse, `//` in any URL comments out the rest of its
|
||||
line: in `curl https://example.com/x && echo done` the comment runs to the
|
||||
end and takes `echo` with it, and in Kotlin `val url = "https://..."` the
|
||||
string span disappears inside it. Comments are located before strings and
|
||||
win over them. What we can reach is `getCodeStructure()`, which is public,
|
||||
plus a nine-line reconstruction of the library's private
|
||||
`constructHighlights`; the locators themselves are `internal`. 1.1.0 is
|
||||
the newest release, so there is nothing to upgrade to.
|
||||
|
||||
## 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. **Decide what to do about the highlighting bug above.** Two options, both
|
||||
ours: post-process `getCodeStructure()` -- pair `/*` with the next `*/`
|
||||
after it, and only for languages that have them, ignore `#` and `//`
|
||||
where the language does not use them -- then build the annotated string
|
||||
from the corrected structure, which costs no extra lexing but cannot
|
||||
recover keywords and strings the wrong comment range already suppressed;
|
||||
or fork the twenty-file library and fix the locators. Filing it upstream
|
||||
needs Bryan or a token, since there is no `gh` and no GitHub credential
|
||||
in this VM. One-line repro: lexing `x '*/a/*'` as `SyntaxLanguage.SHELL`
|
||||
in highlights 1.1.0 returns a highlight whose `location.end` precedes its
|
||||
`location.start`. Whichever is chosen, `app/highlights-repro.sh` is what
|
||||
checks it.
|
||||
3. **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.
|
||||
Generated
+1081
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "android-shell"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# The JNI bridge behind E3's two Java stub classes (`MainActivity`,
|
||||
# `NotificationService` -- see RUST.md's "How much Java is unavoidable" for
|
||||
# why those two classes cannot be anything but Java/Kotlin, registered from
|
||||
# the manifest by name). Everything they would otherwise have done in
|
||||
# Kotlin -- the SSE follow loop, deciding where a notification is shown,
|
||||
# picking a session for a share -- is here instead, built on `client-core`
|
||||
# so the networking and parsing are not duplicated a third time next to the
|
||||
# server and the Kotlin app.
|
||||
#
|
||||
# `cdylib` for `System.loadLibrary`; `lib` too so `cargo test`/`clippy` run
|
||||
# on a normal host target without an Android NDK toolchain, the same
|
||||
# posture `client-core` and `server` already have.
|
||||
|
||||
[lib]
|
||||
name = "android_shell"
|
||||
crate-type = ["cdylib", "lib"]
|
||||
|
||||
[dependencies]
|
||||
client-core = { path = "../client-core" }
|
||||
jni = "0.22"
|
||||
log = "0.4"
|
||||
|
||||
# `LogErrorAndDefault` (the `native_method!` error policy this crate uses
|
||||
# throughout, see lib.rs) logs through the `log` facade, which is a no-op
|
||||
# without a backend installed -- so without this, every recoverable error
|
||||
# at a native entry point would be silently dropped rather than reaching
|
||||
# logcat. Android-only: nothing else here needs it, and it does not build
|
||||
# off-device (see `notify::ensure_logger`'s call site, the only place this
|
||||
# is used).
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android_logger = "0.15"
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Thin wrappers around the five `Env` calls this crate makes constantly
|
||||
//! (a class name, a method name and a signature, all as plain `&str`).
|
||||
//!
|
||||
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
|
||||
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
|
||||
//! here uniformly rather than switching to the compile-time `jni_str!`
|
||||
//! literal macro call by call -- these are a handful of short, one-off
|
||||
//! lookups, not a hot loop, so the difference is not worth two code paths
|
||||
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
|
||||
//! `FieldSignature`, which is why those go through
|
||||
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
|
||||
//! parsed form is what lets these calls skip re-validating the signature
|
||||
//! against the arguments on every call, which is the whole reason `jni`
|
||||
//! moved to it.
|
||||
//!
|
||||
//! **The classloader gotcha, found by testing (2026-09-05).** A class
|
||||
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
|
||||
//! `get_static_field` -- anything that resolves a *class*, as opposed to
|
||||
//! `call_method` on an object it already has, which needs no such lookup)
|
||||
//! defaults to `FindClass`'s ordinary search when it cannot find the
|
||||
//! calling thread a classloader through `Thread.getContextClassLoader()`.
|
||||
//! That default is fine on a thread the JVM itself started -- an
|
||||
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
|
||||
//! from `android-shell`'s own background thread (the notification
|
||||
//! follow-loop, the share upload) is running on a thread *Rust* spawned
|
||||
//! and attached with `JavaVM::attach_current_thread`, which the platform
|
||||
//! never gave an app classloader. Framework classes
|
||||
//! (`android.app.Notification$Builder`, ...) still resolve, because they
|
||||
//! are reachable from the bootstrap loader `FindClass` falls back to --
|
||||
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
|
||||
//! packaged inside this app's own APK. The failure was
|
||||
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
|
||||
//! as "failed to resolve Java class ... (class not found or linkage
|
||||
//! error)" -- on a real device this reads as "the notification silently
|
||||
//! never arrives," since the whole call is inside the follow loop and the
|
||||
//! ongoing foreground notification (built on the main thread, in
|
||||
//! `try_start`, before the background thread exists) posts fine either
|
||||
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
|
||||
//! first time any entry point has a `Context` to ask, and every class
|
||||
//! lookup below goes through it explicitly via `LoaderContext::Loader`
|
||||
//! rather than the thread-dependent default -- so it is correct on the
|
||||
//! main thread and on this crate's own background threads alike.
|
||||
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
|
||||
use jni::refs::{Global, LoaderContext};
|
||||
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
|
||||
use jni::strings::JNIString;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
|
||||
|
||||
/// Caches `context`'s own `ClassLoader`, the first time this is called.
|
||||
/// Cheap to call from every entry point that has a `Context` on hand
|
||||
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
|
||||
/// a `OnceLock::get` and nothing else.
|
||||
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
if CLASS_LOADER.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// context.getClass().getClassLoader() -- resolved via `call_method` on
|
||||
// real objects throughout, so this needs no class-name lookup of its
|
||||
// own and has nothing to bootstrap.
|
||||
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
|
||||
let loader_obj = call_method(
|
||||
env,
|
||||
&class_obj,
|
||||
"getClassLoader",
|
||||
"()Ljava/lang/ClassLoader;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
|
||||
let global = env.new_global_ref(&loader)?;
|
||||
// Lost the race with another entry point calling this concurrently --
|
||||
// both loaders name the same app, so either one is fine and there is
|
||||
// nothing to reconcile.
|
||||
let _ = CLASS_LOADER.set(global);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
|
||||
/// through the cached app classloader when one has been remembered, and
|
||||
/// through the ordinary default otherwise -- which is every call made
|
||||
/// before any entry point has run, and is also correct for a main-thread
|
||||
/// caller, so there is no case this makes worse.
|
||||
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
||||
match CLASS_LOADER.get() {
|
||||
Some(loader) => {
|
||||
let binary_name = name.replace('/', ".");
|
||||
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
|
||||
}
|
||||
None => env.find_class(JNIString::new(name)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
||||
resolve_class(env, name)
|
||||
}
|
||||
|
||||
/// A new Java string as a plain `JObject` -- what every call site here
|
||||
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
|
||||
/// `JString: Into<JObject>` is the documented way across).
|
||||
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
|
||||
Ok(env.new_string(text)?.into())
|
||||
}
|
||||
|
||||
pub fn new_object<'local>(
|
||||
env: &mut Env<'local>,
|
||||
class: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<JObject<'local>> {
|
||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||
let class = resolve_class(env, class)?;
|
||||
env.new_object(class, sig.method_signature(), args)
|
||||
}
|
||||
|
||||
pub fn call_method<'local>(
|
||||
env: &mut Env<'local>,
|
||||
obj: &JObject,
|
||||
method: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<JValueOwned<'local>> {
|
||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
|
||||
}
|
||||
|
||||
pub fn call_static_method<'local>(
|
||||
env: &mut Env<'local>,
|
||||
class: &str,
|
||||
method: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<JValueOwned<'local>> {
|
||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||
let class = resolve_class(env, class)?;
|
||||
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
|
||||
}
|
||||
|
||||
pub fn get_static_field<'local>(
|
||||
env: &mut Env<'local>,
|
||||
class: &str,
|
||||
field: &str,
|
||||
sig: &str,
|
||||
) -> Result<JValueOwned<'local>> {
|
||||
let sig = RuntimeFieldSignature::from_str(sig)?;
|
||||
let class = resolve_class(env, class)?;
|
||||
env.get_static_field(class, JNIString::new(field), sig.field_signature())
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
|
||||
//! package comment for what this crate is and RUST.md's E3 entry for the
|
||||
//! design decisions.
|
||||
//!
|
||||
//! Each native method is declared with `jni`'s [`native_method!`] macro
|
||||
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
|
||||
//! the macro derives the mangled export name and the JNI signature from the
|
||||
//! Rust function itself, so the two cannot drift apart the way a
|
||||
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
|
||||
//! routinely do. `error_policy = LogErrorAndDefault` matches
|
||||
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
|
||||
//! JNI call that threw) is reported to logcat, not thrown back into Java
|
||||
//! as an exception that would crash the app over something recoverable.
|
||||
//!
|
||||
//! Each `const _: NativeMethod = native_method! { ... };` binding is
|
||||
//! otherwise unused by name -- `_` is the idiomatic way to keep a
|
||||
//! side-effecting const (here, generating the `#[export_name]`d function
|
||||
//! the JVM resolves by the JNI naming convention) without a `dead_code`
|
||||
//! warning for a binding nothing reads.
|
||||
|
||||
mod jcall;
|
||||
mod notify;
|
||||
mod settings;
|
||||
mod share;
|
||||
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JClass, JObject};
|
||||
use jni::sys::jint;
|
||||
use jni::{Env, NativeMethod, native_method};
|
||||
|
||||
/// Installs the `log` backend that routes to logcat, once per process.
|
||||
/// Without it, `LogErrorAndDefault` (every native method below) and any
|
||||
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
|
||||
/// call into the `log` facade's default no-op logger, and a real failure
|
||||
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
|
||||
/// crashing, since nothing on screen or in the log says a notification was
|
||||
/// dropped. Called from every entry point below rather than a Java-side
|
||||
/// `Application.onCreate`, since this crate deliberately has no such class
|
||||
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
|
||||
fn ensure_logger() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
#[cfg(target_os = "android")]
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Debug)
|
||||
.with_tag("android-shell"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// The parameters are spelled as their Java types, not as `JObject`: the
|
||||
// macro encodes each argument into the exported symbol's JNI signature
|
||||
// (and JNI resolves `Java_...` names *by* that signature), so a generic
|
||||
// `JObject` here would export `(Ljava/lang/Object;...)` against a Java
|
||||
// method actually declared `(Landroid/app/Activity;...)` -- two different
|
||||
// symbols that never resolve to each other, silently, with no compiler
|
||||
// error on either side. `android.app.Activity` etc. have no dedicated
|
||||
// Rust wrapper in this crate, so they fall back to plain `JObject` in the
|
||||
// implementation functions below (the "Built-in Types" note in
|
||||
// `native_method!`'s docs).
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.MainActivity",
|
||||
static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (),
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
|
||||
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
|
||||
fn native_handle_intent<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
activity: JObject<'local>,
|
||||
intent: JObject<'local>,
|
||||
) -> Result<(), jni::errors::Error> {
|
||||
ensure_logger();
|
||||
jcall::remember_class_loader(env, &activity)?;
|
||||
share::handle_intent(env, &activity, &intent)
|
||||
}
|
||||
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.NotificationService",
|
||||
static extern fn native_sync(context: android.content.Context) -> (),
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
|
||||
/// enrollment may have just landed) and from `NotificationService.sync`
|
||||
/// itself. See `notify::sync`.
|
||||
fn native_sync<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
context: JObject<'local>,
|
||||
) -> Result<(), jni::errors::Error> {
|
||||
ensure_logger();
|
||||
jcall::remember_class_loader(env, &context)?;
|
||||
notify::sync(env, &context)
|
||||
}
|
||||
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.NotificationService",
|
||||
static extern fn native_on_start_command(service: android.app.Service) -> jint,
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
|
||||
fn native_on_start_command<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
service: JObject<'local>,
|
||||
) -> Result<jint, jni::errors::Error> {
|
||||
ensure_logger();
|
||||
jcall::remember_class_loader(env, &service)?;
|
||||
Ok(notify::on_start_command(env, service))
|
||||
}
|
||||
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.NotificationService",
|
||||
static extern fn native_on_destroy() -> (),
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
|
||||
fn native_on_destroy<'local>(
|
||||
_env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
) -> Result<(), jni::errors::Error> {
|
||||
ensure_logger();
|
||||
notify::on_destroy();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
//! Where a notification is said, and the foreground service that keeps
|
||||
//! the connection open while the app is closed. Ported from
|
||||
//! `Notifications.kt`'s `NotificationService`, minus the "session on
|
||||
//! screen" / "hand to the app as a banner" branches: those read
|
||||
//! process-wide state that only exists because a screen is drawn to
|
||||
//! register against, and this experiment draws no screen yet (that is
|
||||
//! E4's job, on iris). So every notification here takes the third branch
|
||||
//! Kotlin's `show` already had -- the platform's own drawer -- which is
|
||||
//! also exactly the case E3's pass condition asks for: **a notification
|
||||
//! arrives with the app closed.**
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use client_core::api::UreqTransport;
|
||||
use client_core::notifications::{SessionNotification, follow_notifications};
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JValue};
|
||||
use jni::sys::{JNI_TRUE, jint};
|
||||
|
||||
use crate::settings::{self, ServerSettings};
|
||||
|
||||
const ALERT_CHANNEL: &str = "sessions";
|
||||
const ONGOING_CHANNEL: &str = "connection";
|
||||
const ONGOING_ID: i32 = 1;
|
||||
const ALERT_ID: i32 = 2;
|
||||
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
|
||||
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
||||
|
||||
/// Whether the follow-loop thread is already running. **A deviation from
|
||||
/// `Notifications.kt`, found by testing rather than planned**: the Kotlin
|
||||
/// `onStartCommand` spawns a fresh `thread(isDaemon = true) { follow(...) }`
|
||||
/// on *every* call, with nothing to notice a previous one is still going --
|
||||
/// and `sync()` calling `startForegroundService` when the service is
|
||||
/// already running is an ordinary Android start, not a restart, so
|
||||
/// `onStartCommand` runs again. Enrolling from `MainActivity` (which calls
|
||||
/// `sync` once itself, then again inside `handle_enrollment` after saving
|
||||
/// the token) hits exactly this path and was observed opening **two**
|
||||
/// concurrent connections to `/notifications` from one process -- caught
|
||||
/// on this build via `adb logcat` showing two `jni::vm::java_vm: Attached
|
||||
/// thread ai-app-notifications` lines for one enrollment. Guarded here
|
||||
/// rather than left to match Kotlin's behaviour exactly, since duplicating
|
||||
/// a live connection is a resource leak with no upside; worth carrying the
|
||||
/// same guard back to `Notifications.kt` separately.
|
||||
static RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Set by `nativeOnDestroy`, checked by the follow loop between
|
||||
/// reconnects. **Known gap, recorded rather than hidden**: unlike
|
||||
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
|
||||
/// can interrupt a `ureq` read already blocked inside one connection --
|
||||
/// `Transport::stream` hands back a plain `Read` with no cancellation
|
||||
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
|
||||
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
|
||||
/// practice this is a bounded wait rather than a hang; closing that gap
|
||||
/// for real means adding a cancellation point to `client_core::Transport`,
|
||||
/// which is a decision affecting every caller of that trait, not just this
|
||||
/// one -- left for whoever next depends on prompt shutdown.
|
||||
static STOPPING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
|
||||
crate::jcall::get_static_field(env, class, field, "I")?.i()
|
||||
}
|
||||
|
||||
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/app/NotificationManagerCompat",
|
||||
"from",
|
||||
"(Landroid/content/Context;)Landroidx/core/app/NotificationManagerCompat;",
|
||||
&[JValue::Object(context)],
|
||||
)?
|
||||
.l()
|
||||
}
|
||||
|
||||
fn create_channel(
|
||||
env: &mut Env,
|
||||
manager: &JObject,
|
||||
id: &str,
|
||||
name: &str,
|
||||
importance: i32,
|
||||
) -> Result<()> {
|
||||
let id_j = crate::jcall::jstr_obj(env, id)?;
|
||||
let builder = crate::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationChannelCompat$Builder",
|
||||
"(Ljava/lang/String;I)V",
|
||||
&[JValue::Object(&id_j), JValue::Int(importance)],
|
||||
)?;
|
||||
let name_j = crate::jcall::jstr_obj(env, name)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"setName",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
||||
&[JValue::Object(&name_j)],
|
||||
)?;
|
||||
let channel = crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"build",
|
||||
"()Landroidx/core/app/NotificationChannelCompat;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
manager,
|
||||
"createNotificationChannel",
|
||||
"(Landroidx/core/app/NotificationChannelCompat;)V",
|
||||
&[JValue::Object(&channel)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two channels, because they are two different things to be told -- see
|
||||
/// `Notifications.kt`'s `createChannels` for the reasoning; the names and
|
||||
/// importances here are copied from it exactly, since a phone that has
|
||||
/// seen both apps should not learn two different vocabularies for the
|
||||
/// same fact.
|
||||
fn create_channels(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
let manager = notification_manager(env, context)?;
|
||||
let default = static_int(
|
||||
env,
|
||||
"androidx/core/app/NotificationManagerCompat",
|
||||
"IMPORTANCE_DEFAULT",
|
||||
)?;
|
||||
let min = static_int(
|
||||
env,
|
||||
"androidx/core/app/NotificationManagerCompat",
|
||||
"IMPORTANCE_MIN",
|
||||
)?;
|
||||
create_channel(
|
||||
env,
|
||||
&manager,
|
||||
ALERT_CHANNEL,
|
||||
"Sessions needing attention",
|
||||
default,
|
||||
)?;
|
||||
create_channel(env, &manager, ONGOING_CHANNEL, "Staying connected", min)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn new_intent_for<'l>(
|
||||
env: &mut Env<'l>,
|
||||
context: &JObject,
|
||||
class_name: &str,
|
||||
) -> Result<JObject<'l>> {
|
||||
let target_class = crate::jcall::find_class(env, class_name)?;
|
||||
crate::jcall::new_object(
|
||||
env,
|
||||
"android/content/Intent",
|
||||
"(Landroid/content/Context;Ljava/lang/Class;)V",
|
||||
&[JValue::Object(context), JValue::Object(&target_class)],
|
||||
)
|
||||
}
|
||||
|
||||
/// The intent a tap on an alert opens -- mirrors `Notifications.kt`'s
|
||||
/// `sessionIntent`, including building the URI through `Uri.Builder`
|
||||
/// rather than string concatenation, for the same reason: an id needing
|
||||
/// escaping must survive the round trip.
|
||||
fn session_intent<'l>(
|
||||
env: &mut Env<'l>,
|
||||
context: &JObject,
|
||||
session_id: &str,
|
||||
) -> Result<JObject<'l>> {
|
||||
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
|
||||
let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&intent,
|
||||
"setAction",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||
&[JValue::Object(&action_view)],
|
||||
)?;
|
||||
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
||||
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"scheme",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&scheme)],
|
||||
)?;
|
||||
let authority = crate::jcall::jstr_obj(env, "session")?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"authority",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&authority)],
|
||||
)?;
|
||||
let path = crate::jcall::jstr_obj(env, session_id)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"appendPath",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&path)],
|
||||
)?;
|
||||
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&intent,
|
||||
"setData",
|
||||
"(Landroid/net/Uri;)Landroid/content/Intent;",
|
||||
&[JValue::Object(&uri)],
|
||||
)?;
|
||||
Ok(intent)
|
||||
}
|
||||
|
||||
fn pending_activity<'l>(
|
||||
env: &mut Env<'l>,
|
||||
context: &JObject,
|
||||
intent: &JObject,
|
||||
) -> Result<JObject<'l>> {
|
||||
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
|
||||
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"android/app/PendingIntent",
|
||||
"getActivity",
|
||||
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;",
|
||||
&[
|
||||
JValue::Object(context),
|
||||
JValue::Int(0),
|
||||
JValue::Object(intent),
|
||||
JValue::Int(update_current | immutable),
|
||||
],
|
||||
)?
|
||||
.l()
|
||||
}
|
||||
|
||||
fn builder_call<'l>(
|
||||
env: &mut Env<'l>,
|
||||
builder: &JObject<'l>,
|
||||
method: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<()> {
|
||||
crate::jcall::call_method(env, builder, method, sig, args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The type Android 14+ requires a foreground service to declare, and
|
||||
/// nothing before it -- mirrors `Notifications.kt`'s `foregroundType`.
|
||||
fn foreground_type(env: &mut Env) -> Result<i32> {
|
||||
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
||||
let upside_down_cake = static_int(env, "android/os/Build$VERSION_CODES", "UPSIDE_DOWN_CAKE")?;
|
||||
if sdk >= upside_down_cake {
|
||||
static_int(
|
||||
env,
|
||||
"android/content/pm/ServiceInfo",
|
||||
"FOREGROUND_SERVICE_TYPE_SPECIAL_USE",
|
||||
)
|
||||
} else {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||
let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
||||
let builder = crate::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationCompat$Builder",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[JValue::Object(context), JValue::Object(&channel)],
|
||||
)?;
|
||||
let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentTitle",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&title)],
|
||||
)?;
|
||||
let icon = static_int(env, "android/R$drawable", "stat_notify_sync")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setSmallIcon",
|
||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Int(icon)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setOngoing",
|
||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Bool(JNI_TRUE)],
|
||||
)?;
|
||||
let priority_min = static_int(env, "androidx/core/app/NotificationCompat", "PRIORITY_MIN")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setPriority",
|
||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Int(priority_min)],
|
||||
)?;
|
||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?.l()
|
||||
}
|
||||
|
||||
/// Starts the service if there is a server to connect to, and stops it
|
||||
/// otherwise -- mirrors `Notifications.kt`'s `NotificationService.sync`.
|
||||
pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
let service_intent =
|
||||
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
|
||||
if settings::load(env, context)?.is_none() {
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
context,
|
||||
"stopService",
|
||||
"(Landroid/content/Intent;)Z",
|
||||
&[JValue::Object(&service_intent)],
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
create_channels(env, context)?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/content/ContextCompat",
|
||||
"startForegroundService",
|
||||
"(Landroid/content/Context;Landroid/content/Intent;)V",
|
||||
&[JValue::Object(context), JValue::Object(&service_intent)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `Service.onStartCommand` body -- loads settings, starts the
|
||||
/// foreground notification, and spawns the follow-loop thread. Answers the
|
||||
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
|
||||
/// framework rather than hardcoded so a wrong guess at their values cannot
|
||||
/// silently pick the other behaviour.
|
||||
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
|
||||
match try_start(env, &service) {
|
||||
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
|
||||
Ok(false) => {
|
||||
let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
|
||||
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
||||
}
|
||||
Err(e) => {
|
||||
log_error(env, "onStartCommand", &e);
|
||||
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
||||
let Some(settings) = settings::load(env, service)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let ca = settings::load_pinned_ca(env)?;
|
||||
let notification = ongoing_notification(env, service)?;
|
||||
let fg_type = foreground_type(env)?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/app/ServiceCompat",
|
||||
"startForeground",
|
||||
"(Landroid/app/Service;ILandroid/app/Notification;I)V",
|
||||
&[
|
||||
JValue::Object(service),
|
||||
JValue::Int(ONGOING_ID),
|
||||
JValue::Object(¬ification),
|
||||
JValue::Int(fg_type),
|
||||
],
|
||||
)?;
|
||||
|
||||
// See `RUNNING`'s doc: a second `onStartCommand` while the loop from
|
||||
// the first is still going -- the ordinary case for this service,
|
||||
// since `sync()` is called from more than one place -- must not open
|
||||
// a second connection.
|
||||
if RUNNING.swap(true, Ordering::SeqCst) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let vm = env.get_java_vm()?;
|
||||
let context = env.new_global_ref(service)?;
|
||||
STOPPING.store(false, Ordering::SeqCst);
|
||||
std::thread::Builder::new()
|
||||
.name("ai-app-notifications".to_string())
|
||||
.spawn(move || {
|
||||
// Requests a *permanent* attachment (detached only when this thread
|
||||
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
|
||||
// this is the long-lived follow loop, not a one-shot callback.
|
||||
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||
follow_loop(env, &context, settings, &ca);
|
||||
Ok(())
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Follows the backend's notification stream, reconnecting until stopped
|
||||
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
|
||||
/// ordinary case, so it retries quietly and forever; nothing is shown when
|
||||
/// it cannot connect, for the same reason as the Kotlin original: a
|
||||
/// notification saying "I could not tell you whether anything happened" is
|
||||
/// noise about a condition nobody can act on.
|
||||
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
|
||||
while !STOPPING.load(Ordering::SeqCst) {
|
||||
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
|
||||
let _ = follow_notifications(&transport, |notification| {
|
||||
if let Err(e) = show(env, context, ¬ification) {
|
||||
log_error(env, "show", &e);
|
||||
}
|
||||
!STOPPING.load(Ordering::SeqCst)
|
||||
});
|
||||
}
|
||||
if STOPPING.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(RECONNECT_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
/// One notification per session, replacing that session's previous one --
|
||||
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
|
||||
/// branches this module's doc comment explains.
|
||||
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
|
||||
let manager = notification_manager(env, context)?;
|
||||
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
||||
let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?;
|
||||
let allowed = if sdk < tiramisu {
|
||||
true
|
||||
} else {
|
||||
let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
|
||||
let granted = static_int(
|
||||
env,
|
||||
"android/content/pm/PackageManager",
|
||||
"PERMISSION_GRANTED",
|
||||
)?;
|
||||
let result = crate::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/content/ContextCompat",
|
||||
"checkSelfPermission",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)I",
|
||||
&[JValue::Object(context), JValue::Object(&permission)],
|
||||
)?
|
||||
.i()?;
|
||||
result == granted
|
||||
};
|
||||
let enabled =
|
||||
crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
|
||||
if !allowed || !enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let intent = session_intent(env, context, ¬ification.session_id)?;
|
||||
let pending = pending_activity(env, context, &intent)?;
|
||||
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
||||
let builder = crate::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationCompat$Builder",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[JValue::Object(context), JValue::Object(&channel)],
|
||||
)?;
|
||||
let title = crate::jcall::jstr_obj(env, ¬ification.title)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentTitle",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&title)],
|
||||
)?;
|
||||
let text = crate::jcall::jstr_obj(env, notification.kind.attention_line())?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentText",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&text)],
|
||||
)?;
|
||||
let icon = static_int(env, "android/R$drawable", "stat_notify_chat")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setSmallIcon",
|
||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Int(icon)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentIntent",
|
||||
"(Landroid/app/PendingIntent;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&pending)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setAutoCancel",
|
||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Bool(JNI_TRUE)],
|
||||
)?;
|
||||
let when = (notification.at * 1000.0) as i64;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setWhen",
|
||||
"(J)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Long(when)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setShowWhen",
|
||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Bool(JNI_TRUE)],
|
||||
)?;
|
||||
let built =
|
||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
|
||||
.l()?;
|
||||
let tag = crate::jcall::jstr_obj(env, ¬ification.session_id)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&manager,
|
||||
"notify",
|
||||
"(Ljava/lang/String;ILandroid/app/Notification;)V",
|
||||
&[
|
||||
JValue::Object(&tag),
|
||||
JValue::Int(ALERT_ID),
|
||||
JValue::Object(&built),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
|
||||
/// the gap this module's `STOPPING` doc explains.
|
||||
pub fn on_destroy() {
|
||||
STOPPING.store(true, Ordering::SeqCst);
|
||||
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
|
||||
// comment): the old thread may still be inside a blocked read when a
|
||||
// new `onStartCommand` follows immediately, which would spawn a
|
||||
// second one before the first has actually stopped. Narrower than not
|
||||
// resetting at all -- a service destroyed and never restarted would
|
||||
// otherwise wedge `RUNNING` true forever -- and no worse than the
|
||||
// known gap already accepted above.
|
||||
RUNNING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
|
||||
let message = format!("android-shell: {where_}: {error}");
|
||||
let _ = (|| -> Result<()> {
|
||||
let tag = crate::jcall::jstr_obj(env, "android-shell")?;
|
||||
let msg = crate::jcall::jstr_obj(env, &message)?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"android/util/Log",
|
||||
"e",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)I",
|
||||
&[JValue::Object(&tag), JValue::Object(&msg)],
|
||||
)?;
|
||||
Ok(())
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Enrollment: where the backend is, and the Keystore-sealed token to
|
||||
//! reach it. This crate does not reimplement the Android Keystore AES-GCM
|
||||
//! sealing in Rust -- it calls the same `wg-app-link` `ServerStore` Kotlin
|
||||
//! class the production app already uses (see `ServerConfig.kt`), through
|
||||
//! JNI, for two reasons: that code is shared with Dev Updater and already
|
||||
//! tested, and the sealed value on a real phone is keyed to the exact
|
||||
//! Keystore alias that class already uses -- reimplementing the crypto
|
||||
//! here would either duplicate it or invalidate an existing enrollment.
|
||||
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
|
||||
/// Where the backend is and how to authenticate to it -- the Rust twin of
|
||||
/// `wg-app-link`'s `ServerSettings` data class, read back field by field
|
||||
/// rather than kept as a live JNI reference, so it can cross a thread
|
||||
/// boundary (a `JObject` is tied to one `Env`/thread).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerSettings {
|
||||
pub host: String,
|
||||
pub port: i32,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl ServerSettings {
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("https://{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
/// This experiment's own scheme and Keystore alias -- distinct from the
|
||||
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
|
||||
/// installed side by side on the same development device without
|
||||
/// colliding over which one a scanned QR or a deep link resolves to. See
|
||||
/// RUST.md's E3 entry for why they are not the same value.
|
||||
pub(crate) const SCHEME: &str = "aiappshell";
|
||||
const KEY_ALIAS: &str = "aiapp-shell-token-key";
|
||||
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
|
||||
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
|
||||
|
||||
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
||||
let scheme = crate::jcall::jstr_obj(env, SCHEME)?;
|
||||
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
|
||||
crate::jcall::new_object(
|
||||
env,
|
||||
STORE_CLASS,
|
||||
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||
&[JValue::Object(&scheme), JValue::Object(&alias)],
|
||||
)
|
||||
}
|
||||
|
||||
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
|
||||
let host = get_string(env, settings_obj, "getHost")?;
|
||||
let port = crate::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
|
||||
let token = get_string(env, settings_obj, "getToken")?;
|
||||
Ok(ServerSettings { host, port, token })
|
||||
}
|
||||
|
||||
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
||||
let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
|
||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||
jstr.try_to_string(env)
|
||||
}
|
||||
|
||||
/// The stored enrollment, or `None` when there is not one -- mirrors
|
||||
/// `ServerConfig.kt`'s `loadServerSettings`.
|
||||
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
|
||||
let store = new_store(env)?;
|
||||
let settings_obj = crate::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"load",
|
||||
"(Landroid/content/Context;)Lcom/example/wgapplink/ServerSettings;",
|
||||
&[JValue::Object(context)],
|
||||
)?
|
||||
.l()?;
|
||||
if settings_obj.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(read_settings(env, &settings_obj)?))
|
||||
}
|
||||
|
||||
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
|
||||
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
|
||||
let store = new_store(env)?;
|
||||
let host = crate::jcall::jstr_obj(env, &settings.host)?;
|
||||
let token = crate::jcall::jstr_obj(env, &settings.token)?;
|
||||
let settings_obj = crate::jcall::new_object(
|
||||
env,
|
||||
SETTINGS_CLASS,
|
||||
"(Ljava/lang/String;ILjava/lang/String;)V",
|
||||
&[
|
||||
JValue::Object(&host),
|
||||
JValue::Int(settings.port),
|
||||
JValue::Object(&token),
|
||||
],
|
||||
)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"save",
|
||||
"(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V",
|
||||
&[JValue::Object(context), JValue::Object(&settings_obj)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
|
||||
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
|
||||
/// query-parameter rules rather than re-deriving them here.
|
||||
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
|
||||
let store = new_store(env)?;
|
||||
let settings_obj = crate::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"parseEnrollmentUri",
|
||||
"(Landroid/net/Uri;)Lcom/example/wgapplink/ServerSettings;",
|
||||
&[JValue::Object(uri)],
|
||||
)?
|
||||
.l()?;
|
||||
if settings_obj.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(read_settings(env, &settings_obj)?))
|
||||
}
|
||||
|
||||
/// The CA this build pins, generated at build time the same way
|
||||
/// `androidApp`'s `generatePinnedCert` task does (see `build.gradle.kts`)
|
||||
/// but into a plain Java constant, since this module has no Kotlin of its
|
||||
/// own to generate into.
|
||||
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
|
||||
let value = crate::jcall::get_static_field(
|
||||
env,
|
||||
"com/example/aiapp/shell/PinnedCa",
|
||||
"PINNED_CA_PEM",
|
||||
"Ljava/lang/String;",
|
||||
)?
|
||||
.l()?;
|
||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||
Ok(jstr.try_to_string(env)?.into_bytes())
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
|
||||
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
|
||||
//!
|
||||
//! **Scope cut, recorded rather than silent**: only shared *text*
|
||||
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
|
||||
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
|
||||
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
|
||||
//! ported here, because `client-core`'s `ApiClient` does not have the
|
||||
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
|
||||
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
|
||||
//! `content://` stream and no text falls through to a toast saying so,
|
||||
//! rather than silently doing nothing. Closing this gap is the same
|
||||
//! `client-core` work whichever caller needs it next.
|
||||
//!
|
||||
//! **Which session a share lands in** is also a placeholder: with no
|
||||
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
|
||||
//! to whichever session has the latest `last_activity` -- the one most
|
||||
//! likely to be what somebody meant. Worth revisiting once a real screen
|
||||
//! exists to ask instead of guessing.
|
||||
|
||||
use client_core::api::{ApiClient, UreqTransport};
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
|
||||
use crate::notify;
|
||||
use crate::settings;
|
||||
|
||||
const ACTION_SEND: &str = "android.intent.action.SEND";
|
||||
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
|
||||
const ACTION_VIEW: &str = "android.intent.action.VIEW";
|
||||
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
|
||||
|
||||
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
|
||||
let value = crate::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||
Ok(Some(jstr.try_to_string(env)?))
|
||||
}
|
||||
|
||||
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
||||
let message = crate::jcall::jstr_obj(env, message)?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"com/example/aiapp/shell/MainActivity",
|
||||
"toast",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[JValue::Object(context), JValue::Object(&message)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The one place an incoming intent is sorted into what it means -- mirrors
|
||||
/// `MainActivity.kt`'s `handleIntent`.
|
||||
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||
let action = get_string_method(env, intent, "getAction")?;
|
||||
if matches!(
|
||||
action.as_deref(),
|
||||
Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE)
|
||||
) {
|
||||
return handle_share(env, activity, intent);
|
||||
}
|
||||
if action.as_deref() != Some(ACTION_VIEW) {
|
||||
return Ok(());
|
||||
}
|
||||
let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?;
|
||||
if uri.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
let scheme = get_string_method(env, &uri, "getScheme")?;
|
||||
if scheme.as_deref() != Some(settings::SCHEME) {
|
||||
return Ok(());
|
||||
}
|
||||
match get_string_method(env, &uri, "getHost")?.as_deref() {
|
||||
Some("session") => handle_session_open(env, activity, &uri),
|
||||
Some("enroll") => handle_enrollment(env, activity, &uri),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
||||
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
|
||||
return Ok(());
|
||||
};
|
||||
// There is no session screen yet (E4's job); the toast is this
|
||||
// experiment's stand-in proof that the tap was routed to the right
|
||||
// session id.
|
||||
toast(env, activity, &format!("Opened session {session_id}"))
|
||||
}
|
||||
|
||||
fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
||||
match settings::parse_enrollment_uri(env, uri)? {
|
||||
Some(parsed) => {
|
||||
settings::save(env, activity, &parsed)?;
|
||||
notify::sync(env, activity)?;
|
||||
toast(
|
||||
env,
|
||||
activity,
|
||||
&format!("Enrolled with {}", parsed.base_url()),
|
||||
)
|
||||
}
|
||||
None => toast(env, activity, "Not a valid enrollment code"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
|
||||
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
|
||||
/// what happens to it, minus attachments per this module's doc comment.
|
||||
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
||||
let text = crate::jcall::call_method(
|
||||
env,
|
||||
intent,
|
||||
"getStringExtra",
|
||||
"(Ljava/lang/String;)Ljava/lang/String;",
|
||||
&[JValue::Object(&extra_text)],
|
||||
)?
|
||||
.l()?;
|
||||
let text = if text.is_null() {
|
||||
None
|
||||
} else {
|
||||
let jstr: JString = env.cast_local::<JString>(text)?;
|
||||
Some(jstr.try_to_string(env)?)
|
||||
};
|
||||
let Some(text) = text.filter(|t| !t.trim().is_empty()) else {
|
||||
return toast(
|
||||
env,
|
||||
activity,
|
||||
"Nothing to share -- only shared text is supported so far",
|
||||
);
|
||||
};
|
||||
|
||||
// Network I/O must not run on the calling thread: `handle_intent` is
|
||||
// called from `onCreate`/`onNewIntent`, both on the main thread, and a
|
||||
// blocking socket read there is a `NetworkOnMainThreadException`. So
|
||||
// the actual send happens on a JNI-attached background thread, the
|
||||
// same shape `notify::try_start`'s follow loop uses; `toast` from that
|
||||
// thread is safe because `MainActivity.toast` itself hops back to the
|
||||
// main looper (see that method).
|
||||
let vm = env.get_java_vm()?;
|
||||
let activity_ref = env.new_global_ref(activity)?;
|
||||
std::thread::spawn(move || {
|
||||
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||
share_in_background(env, &activity_ref, text);
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn share_in_background(env: &mut Env, activity: &JObject, text: String) {
|
||||
let outcome = attach_to_a_session(env, activity, &text);
|
||||
let message = match outcome {
|
||||
Ok(title) => format!("Shared into \"{title}\""),
|
||||
Err(message) => message,
|
||||
};
|
||||
let _ = toast(env, activity, &message);
|
||||
}
|
||||
|
||||
fn attach_to_a_session(
|
||||
env: &mut Env,
|
||||
activity: &JObject,
|
||||
text: &str,
|
||||
) -> std::result::Result<String, String> {
|
||||
let settings = settings::load(env, activity)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "Not enrolled yet".to_string())?;
|
||||
let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?;
|
||||
let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let client = ApiClient::new(transport);
|
||||
let sessions = client.fetch_sessions().map_err(|e| e.to_string())?;
|
||||
let target = sessions
|
||||
.into_iter()
|
||||
.max_by(|a, b| a.last_activity.total_cmp(&b.last_activity))
|
||||
.ok_or_else(|| "No session to share into".to_string())?;
|
||||
client
|
||||
.send_message(&target.id, text, &[])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(target.title)
|
||||
}
|
||||
@@ -173,6 +173,15 @@ dependencies {
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.zxing.embedded)
|
||||
implementation(libs.markdown.renderer)
|
||||
implementation(libs.highlights)
|
||||
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() }
|
||||
@@ -37,12 +37,20 @@
|
||||
<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. -->
|
||||
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"
|
||||
android:windowSoftInputMode="adjustResize|stateUnchanged"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
/**
|
||||
* The sixteen colours a terminal program names, and the two it assumes.
|
||||
*
|
||||
* Its own palette rather than the syntax one: a program that prints in red has chosen red, where a
|
||||
* highlighter's colours are this app's reading of somebody else's code. They come out of the same
|
||||
* Catppuccin values so nothing on screen is a colour from somewhere else, but the two are not one
|
||||
* table -- adding a syntax role to this list would silently move `ls`'s directory blue.
|
||||
*/
|
||||
data class AnsiPalette(
|
||||
/** Indexes 0-7, then 8-15 bright, in the terminal's own order. */
|
||||
val colours: List<Color>,
|
||||
/** What uncoloured text is, needed only where a style has to state a colour. */
|
||||
val foreground: Color,
|
||||
/** What the text sits on, needed for reverse video. */
|
||||
val background: Color,
|
||||
)
|
||||
|
||||
/**
|
||||
* What a tool printed, with its terminal styling applied and everything else taken out.
|
||||
*
|
||||
* Bash output arrives exactly as the program wrote it, escape sequences included, and drawn
|
||||
* verbatim those are line noise in the middle of the thing being read. Stripping them all would be
|
||||
* the other half-answer -- colour is often the whole of what a diff or a test run is saying.
|
||||
*
|
||||
* So the sequences that decide how text *looks* become spans, and every other one is dropped rather
|
||||
* than shown: the rest move a cursor around a grid this is not, and "go to column 40" has no
|
||||
* meaning in a scrolling document.
|
||||
*
|
||||
* A carriage return is honoured the way a terminal honours it: what was written since the last line
|
||||
* break is thrown away and the line starts again. That is what makes a progress bar show its final
|
||||
* state rather than every state it passed through.
|
||||
*
|
||||
* Not a composable, and the palette is a parameter, so this can be remembered against the text it
|
||||
* parsed rather than re-run on every recomposition of the card holding it.
|
||||
*/
|
||||
fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
|
||||
// The common case by a long way -- nothing to do, and nothing allocated to find that out.
|
||||
if (text.indexOf(ESC) < 0 && text.indexOf('\r') < 0) return AnnotatedString(text)
|
||||
|
||||
val runs = mutableListOf<Run>()
|
||||
var sgr = Sgr.PLAIN
|
||||
var at = 0
|
||||
val plain = StringBuilder()
|
||||
|
||||
fun flush() {
|
||||
if (plain.isNotEmpty()) {
|
||||
runs.add(Run(plain.toString(), sgr.span(palette)))
|
||||
plain.clear()
|
||||
}
|
||||
}
|
||||
|
||||
while (at < text.length) {
|
||||
val c = text[at]
|
||||
when {
|
||||
c == ESC -> {
|
||||
flush()
|
||||
at =
|
||||
skipEscape(text, at) { params, final ->
|
||||
if (final == 'm') sgr = sgr.apply(params, palette)
|
||||
}
|
||||
}
|
||||
// A bare carriage return rewrites the line. One before a newline is the other half of a
|
||||
// Windows line ending: it rewrites nothing, and it is dropped rather than kept, since
|
||||
// that pair is one line break.
|
||||
c == '\r' && text.getOrNull(at + 1) != '\n' -> {
|
||||
flush()
|
||||
dropLine(runs)
|
||||
at++
|
||||
}
|
||||
c == '\r' -> at++
|
||||
// Everything printable, plus the two control characters that are layout rather than
|
||||
// terminal commands. A stray bell or backspace goes for the same reason a cursor move
|
||||
// does.
|
||||
c >= ' ' || c == '\n' || c == '\t' -> {
|
||||
plain.append(c)
|
||||
at++
|
||||
}
|
||||
else -> at++
|
||||
}
|
||||
}
|
||||
flush()
|
||||
|
||||
return buildAnnotatedString {
|
||||
runs.forEach { run ->
|
||||
if (run.style == null) {
|
||||
append(run.text)
|
||||
} else {
|
||||
val pushed = pushStyle(run.style)
|
||||
append(run.text)
|
||||
pop(pushed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One stretch of text that shares a style. */
|
||||
private class Run(val text: String, val style: SpanStyle?)
|
||||
|
||||
/** Throws away everything written since the last line break, as a carriage return does. */
|
||||
private fun dropLine(runs: MutableList<Run>) {
|
||||
while (runs.isNotEmpty()) {
|
||||
val last = runs.removeAt(runs.size - 1)
|
||||
val breakAt = last.text.lastIndexOf('\n')
|
||||
if (breakAt >= 0) {
|
||||
runs.add(Run(last.text.substring(0, breakAt + 1), last.style))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ESC = '\u001B'
|
||||
|
||||
private const val BELL = '\u0007'
|
||||
|
||||
/**
|
||||
* Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte.
|
||||
*
|
||||
* One reader for every kind, because the point is to *leave* them all behind: a sequence this did
|
||||
* not recognise would otherwise have its body printed as ordinary text. Three shapes -- the CSI
|
||||
* (`ESC [ … letter`), the string escapes which run to a terminator, and the two-character ones.
|
||||
*/
|
||||
private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int {
|
||||
val next = text.getOrNull(at + 1) ?: return at + 1
|
||||
return when (next) {
|
||||
'[' -> {
|
||||
var end = at + 2
|
||||
while (end < text.length && text[end] !in CSI_FINAL) end++
|
||||
if (end >= text.length) {
|
||||
// Cut off mid-sequence, which is what a stream that has not finished arriving looks
|
||||
// like: drop the fragment rather than printing it, and the whole sequence arrives
|
||||
// with the next delta.
|
||||
text.length
|
||||
} else {
|
||||
onCsi(text.substring(at + 2, end), text[end])
|
||||
end + 1
|
||||
}
|
||||
}
|
||||
']',
|
||||
'P',
|
||||
'X',
|
||||
'^',
|
||||
'_' -> {
|
||||
// Runs to a string terminator: `ESC \`, or the bell that xterm allows after an OSC.
|
||||
var end = at + 2
|
||||
while (end < text.length) {
|
||||
if (text[end] == BELL) return end + 1
|
||||
if (text[end] == ESC && text.getOrNull(end + 1) == '\\') return end + 2
|
||||
end++
|
||||
}
|
||||
text.length
|
||||
}
|
||||
else -> at + 2
|
||||
}
|
||||
}
|
||||
|
||||
/** The bytes that end a CSI sequence. */
|
||||
private val CSI_FINAL = '@'..'~'
|
||||
|
||||
/** Everything an SGR sequence can turn on, as the terminal tracks it. */
|
||||
private data class Sgr(
|
||||
val fg: Color?,
|
||||
val bg: Color?,
|
||||
val bold: Boolean,
|
||||
val dim: Boolean,
|
||||
val italic: Boolean,
|
||||
val underline: Boolean,
|
||||
val strike: Boolean,
|
||||
val reverse: Boolean,
|
||||
) {
|
||||
/** Null while nothing is set, so unstyled output costs no spans at all. */
|
||||
fun span(palette: AnsiPalette): SpanStyle? {
|
||||
if (this == PLAIN) return null
|
||||
val front = if (reverse) bg ?: palette.background else fg
|
||||
val back = if (reverse) fg ?: palette.foreground else bg
|
||||
// Dim has to have a colour to dim, so where none was named it dims the ordinary one.
|
||||
val stated = front ?: palette.foreground.takeIf { dim }
|
||||
return SpanStyle(
|
||||
color =
|
||||
stated?.let { if (dim) it.copy(alpha = DIM_ALPHA) else it } ?: Color.Unspecified,
|
||||
background = back ?: Color.Unspecified,
|
||||
fontWeight = if (bold) FontWeight.Bold else null,
|
||||
fontStyle = if (italic) FontStyle.Italic else null,
|
||||
textDecoration =
|
||||
when {
|
||||
underline && strike ->
|
||||
TextDecoration.combine(
|
||||
listOf(TextDecoration.Underline, TextDecoration.LineThrough)
|
||||
)
|
||||
underline -> TextDecoration.Underline
|
||||
strike -> TextDecoration.LineThrough
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This state with [params] applied -- one `ESC[…m`, which carries any number of them.
|
||||
*
|
||||
* A code this does not model is ignored rather than reset from: the program meant something by
|
||||
* it, and starting again would also drop the codes beside it that are understood.
|
||||
*/
|
||||
fun apply(params: String, palette: AnsiPalette): Sgr {
|
||||
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a zero too.
|
||||
val codes = params.split(';').map { it.trim().toIntOrNull() ?: 0 }
|
||||
var state = this
|
||||
var at = 0
|
||||
while (at < codes.size) {
|
||||
val code = codes[at]
|
||||
state =
|
||||
when (code) {
|
||||
0 -> PLAIN
|
||||
1 -> state.copy(bold = true)
|
||||
2 -> state.copy(dim = true)
|
||||
3 -> state.copy(italic = true)
|
||||
4 -> state.copy(underline = true)
|
||||
7 -> state.copy(reverse = true)
|
||||
9 -> state.copy(strike = true)
|
||||
21,
|
||||
22 -> state.copy(bold = false, dim = false)
|
||||
23 -> state.copy(italic = false)
|
||||
24 -> state.copy(underline = false)
|
||||
27 -> state.copy(reverse = false)
|
||||
29 -> state.copy(strike = false)
|
||||
in 30..37 -> state.copy(fg = palette.colours[code - 30])
|
||||
in 90..97 -> state.copy(fg = palette.colours[code - 90 + 8])
|
||||
in 40..47 -> state.copy(bg = palette.colours[code - 40])
|
||||
in 100..107 -> state.copy(bg = palette.colours[code - 100 + 8])
|
||||
39 -> state.copy(fg = null)
|
||||
49 -> state.copy(bg = null)
|
||||
38,
|
||||
48 -> {
|
||||
val (colour, last) = extendedColour(codes, at, palette)
|
||||
at = last
|
||||
if (code == 38) state.copy(fg = colour) else state.copy(bg = colour)
|
||||
}
|
||||
else -> state
|
||||
}
|
||||
at++
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
companion object {
|
||||
val PLAIN =
|
||||
Sgr(
|
||||
fg = null,
|
||||
bg = null,
|
||||
bold = false,
|
||||
dim = false,
|
||||
italic = false,
|
||||
underline = false,
|
||||
strike = false,
|
||||
reverse = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** How much of its colour dim text keeps: enough to read, little enough to recede. */
|
||||
private const val DIM_ALPHA = 0.65f
|
||||
|
||||
/**
|
||||
* The colour named by a `38`/`48` at [at], and the index of that colour's last parameter.
|
||||
*
|
||||
* Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal one. The first sixteen of
|
||||
* that table are the palette's own, so a program asking for "colour 1" through either spelling gets
|
||||
* the same red.
|
||||
*/
|
||||
private fun extendedColour(codes: List<Int>, at: Int, palette: AnsiPalette): Pair<Color?, Int> =
|
||||
when (codes.getOrNull(at + 1)) {
|
||||
5 -> {
|
||||
val n = codes.getOrNull(at + 2)
|
||||
if (n == null) null to at + 1 else indexedColour(n, palette) to at + 2
|
||||
}
|
||||
2 -> {
|
||||
val r = codes.getOrNull(at + 2)
|
||||
val g = codes.getOrNull(at + 3)
|
||||
val b = codes.getOrNull(at + 4)
|
||||
if (r == null || g == null || b == null) null to at + 1
|
||||
else Color(r.coerceIn(0, 255), g.coerceIn(0, 255), b.coerceIn(0, 255)) to at + 4
|
||||
}
|
||||
else -> null to at + 1
|
||||
}
|
||||
|
||||
/** One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a grey ramp. */
|
||||
private fun indexedColour(n: Int, palette: AnsiPalette): Color =
|
||||
when {
|
||||
n < 0 -> palette.foreground
|
||||
n < 16 -> palette.colours[n]
|
||||
n < 232 -> {
|
||||
val i = n - 16
|
||||
Color(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
|
||||
}
|
||||
n < 256 -> {
|
||||
val grey = 8 + (n - 232) * 10
|
||||
Color(grey, grey, grey)
|
||||
}
|
||||
else -> palette.foreground
|
||||
}
|
||||
|
||||
/** The six levels of each channel in the 256-colour cube, as xterm defines them. */
|
||||
private val CUBE = intArrayOf(0, 95, 135, 175, 215, 255)
|
||||
@@ -6,26 +6,34 @@ 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.
|
||||
// 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 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.
|
||||
// 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)
|
||||
/**
|
||||
* A request that did not produce what it asked for, carrying the server's own wording where it sent
|
||||
* some.
|
||||
*
|
||||
* [status] is the HTTP status where there was a response at all, and null where the server was
|
||||
* never reached. Callers that need it need it because the *same* failure is two different things to
|
||||
* do: a 409 from a write is "somebody else changed this, here are three ways out". Nothing should
|
||||
* branch on it to decide what to *say* -- the message is what says that.
|
||||
*/
|
||||
class ApiException(message: String, val status: Int? = null, 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.
|
||||
* connection.
|
||||
*
|
||||
* @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).
|
||||
* here -- an event stream has no bounded read time.
|
||||
*/
|
||||
fun <T> requestFromServer(
|
||||
settings: ServerSettings,
|
||||
@@ -33,9 +41,9 @@ fun <T> requestFromServer(
|
||||
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.
|
||||
* A request body written as it is produced -- the upload path. 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.
|
||||
*/
|
||||
streamBody: Pair<String, (java.io.OutputStream) -> Unit>? = null,
|
||||
readTimeoutMs: Int = READ_TIMEOUT_MS,
|
||||
@@ -68,38 +76,36 @@ fun <T> requestFromServer(
|
||||
detail.isNullOrEmpty() ->
|
||||
"Server returned HTTP ${connection.responseCode} for $path"
|
||||
else -> detail
|
||||
}
|
||||
},
|
||||
status = connection.responseCode,
|
||||
)
|
||||
}
|
||||
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.
|
||||
// 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,
|
||||
cause = e,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw ApiException(
|
||||
"Reached ${settings.baseUrl}$path but couldn't read its response " +
|
||||
"(${e::class.simpleName}: ${e.message})",
|
||||
e,
|
||||
cause = 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)
|
||||
|
||||
@@ -108,21 +114,16 @@ private fun <T> JSONArray.mapObjects(parse: (JSONObject) -> T): List<T> =
|
||||
|
||||
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.
|
||||
// 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.
|
||||
* this session's row out of the per-machine usage snapshots. Never shown; [setupName] is what a
|
||||
* reader sees, and holding both invites showing the wrong one.
|
||||
*/
|
||||
val setup: String,
|
||||
/** The machine's current label. This is the one to display; [setup] is never shown. */
|
||||
@@ -135,9 +136,8 @@ data class SessionSummary(
|
||||
* 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.
|
||||
* own record 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.
|
||||
*/
|
||||
val keepsOwnTranscript: Boolean,
|
||||
/** How much the session asks before acting; null when it was never set. */
|
||||
@@ -150,35 +150,32 @@ data class SessionSummary(
|
||||
* 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.
|
||||
* itself from a default is one you can turn off while believing you are reading it.
|
||||
*/
|
||||
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.
|
||||
* Null is not "the home directory": it is the session never having been told. 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`.
|
||||
* How much context this session is holding, as the server last measured it.
|
||||
*
|
||||
* 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.
|
||||
* 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`.
|
||||
* Null and "a big number" are different answers, and only the first stays true. Decided by the
|
||||
* server because that is where a provider's kind is known.
|
||||
*/
|
||||
val maxImageEdge: Int?,
|
||||
val status: String,
|
||||
@@ -213,18 +210,16 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||
*
|
||||
* 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.
|
||||
* wrong for a switch, since a stale row shows a position that may have been changed since.
|
||||
*/
|
||||
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.
|
||||
// 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.
|
||||
// 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>)
|
||||
|
||||
/**
|
||||
@@ -263,7 +258,7 @@ fun fetchSetups(settings: ServerSettings): List<Setup> =
|
||||
* 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.
|
||||
* no way to ask it to read one.
|
||||
*/
|
||||
data class Importable(
|
||||
val id: String,
|
||||
@@ -272,19 +267,15 @@ data class Importable(
|
||||
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.
|
||||
* 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.
|
||||
* Tokens the model was holding at the last turn, or null if no turn has recorded any. 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. */
|
||||
@@ -293,17 +284,15 @@ data class Importable(
|
||||
* 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.
|
||||
* what is running. 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.
|
||||
* What this server is doing to the session right now -- "importing" or "deleting" -- or null.
|
||||
*
|
||||
* 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.
|
||||
* for it: a phone that was asleep never saw the events that said so.
|
||||
*/
|
||||
val pending: String?,
|
||||
/**
|
||||
@@ -316,7 +305,7 @@ data class Importable(
|
||||
/**
|
||||
* 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
|
||||
* [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(
|
||||
@@ -336,20 +325,18 @@ fun parseImportableChange(payload: String): ImportableChange? =
|
||||
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.
|
||||
// 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.
|
||||
* 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 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. A timeout is for a server that has stopped answering.
|
||||
*/
|
||||
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
|
||||
requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) {
|
||||
@@ -361,13 +348,11 @@ fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
|
||||
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.
|
||||
// Absent means nothing has been measured, which is not a context of zero.
|
||||
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".
|
||||
// Absent means an older backend that cannot answer, which is what "unknown" says.
|
||||
inUse = session.optString("inUse", "unknown"),
|
||||
named = session.optBoolean("named", false),
|
||||
pending = session.optString("pending").takeIf { it.isNotEmpty() },
|
||||
@@ -516,8 +501,7 @@ fun sendMessage(
|
||||
*
|
||||
* 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.
|
||||
* that id. The bubble disappearing arrives on the event stream, so every device drops it.
|
||||
*/
|
||||
fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) {
|
||||
requestFromServer(
|
||||
@@ -531,13 +515,11 @@ fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: Strin
|
||||
/**
|
||||
* 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.
|
||||
* The server checks the directory is there and refuses if it is not -- a mistyped path accepted
|
||||
* here would surface much later, as a session that would not start.
|
||||
*
|
||||
* 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.
|
||||
* The next thing said to the session starts it again in the new one.
|
||||
*/
|
||||
fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) {
|
||||
requestFromServer(
|
||||
@@ -581,15 +563,179 @@ fun uploadAttachment(
|
||||
write(out)
|
||||
out.write(tail)
|
||||
},
|
||||
// Long: a trace is hundreds of megabytes, and the server copies it on to a remote
|
||||
// machine before answering.
|
||||
// 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). */
|
||||
/**
|
||||
* One entry of a directory on the machine a setup names.
|
||||
*
|
||||
* [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link]
|
||||
* still says it is one. Neither is worked out here -- the machine answers both.
|
||||
*/
|
||||
data class DirEntry(
|
||||
val name: String,
|
||||
val kind: String,
|
||||
val size: Long,
|
||||
val modified: Long,
|
||||
val link: Boolean,
|
||||
) {
|
||||
val isDirectory: Boolean
|
||||
get() = kind == "directory"
|
||||
}
|
||||
|
||||
/** A directory's entries, and the path the machine resolved the request to. */
|
||||
data class Listing(val path: String, val entries: List<DirEntry>)
|
||||
|
||||
/**
|
||||
* What reading a file produced.
|
||||
*
|
||||
* Four cases, because they are four different things to draw and none is an error the screen can
|
||||
* shrug off: content, something that is not text, something too big to have sent, and (as
|
||||
* [ApiException]) the machine's own refusal. A file with nothing in it is [FileContent.Text] with
|
||||
* an empty string, which is what it is.
|
||||
*/
|
||||
sealed class FileContent {
|
||||
abstract val path: String
|
||||
abstract val size: Long
|
||||
abstract val modified: Long
|
||||
|
||||
data class Text(
|
||||
override val path: String,
|
||||
override val size: Long,
|
||||
override val modified: Long,
|
||||
/** What a write is given back, to prove the file is still the one that was read. */
|
||||
val sha256: String,
|
||||
val content: String,
|
||||
) : FileContent()
|
||||
|
||||
data class Binary(
|
||||
override val path: String,
|
||||
override val size: Long,
|
||||
override val modified: Long,
|
||||
) : FileContent()
|
||||
|
||||
data class TooBig(
|
||||
override val path: String,
|
||||
override val size: Long,
|
||||
override val modified: Long,
|
||||
) : FileContent()
|
||||
}
|
||||
|
||||
/** What a file is after a write, so the editor's precondition is fresh without a second read. */
|
||||
data class Written(val size: Long, val modified: Long, val sha256: String)
|
||||
|
||||
/** Everything in [path] on the machine [setup] names, and what [path] resolved to. */
|
||||
fun fetchDir(settings: ServerSettings, setup: String, path: String): Listing =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${setup.urlEncoded()}/dir?path=${path.urlEncoded()}",
|
||||
readTimeoutMs = 30000,
|
||||
) { connection ->
|
||||
val body = connection.jsonObject()
|
||||
Listing(
|
||||
path = body.getString("path"),
|
||||
entries =
|
||||
body.getJSONArray("entries").mapObjects { entry ->
|
||||
DirEntry(
|
||||
name = entry.getString("name"),
|
||||
kind = entry.getString("kind"),
|
||||
size = entry.optLong("size"),
|
||||
modified = entry.optLong("modified"),
|
||||
link = entry.optBoolean("link", false),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** One file's content, or which of the reasons there is none to show. */
|
||||
fun fetchFile(settings: ServerSettings, setup: String, path: String): FileContent =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}",
|
||||
// A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before any
|
||||
// of it moves. Well clear of that rather than just above it.
|
||||
readTimeoutMs = 60000,
|
||||
) { connection ->
|
||||
val body = connection.jsonObject()
|
||||
val at = body.getString("path")
|
||||
val size = body.optLong("size")
|
||||
val modified = body.optLong("modified")
|
||||
when (val kind = body.getString("kind")) {
|
||||
"text" ->
|
||||
FileContent.Text(
|
||||
at,
|
||||
size,
|
||||
modified,
|
||||
body.getString("sha256"),
|
||||
body.getString("content"),
|
||||
)
|
||||
"binary" -> FileContent.Binary(at, size, modified)
|
||||
"tooBig" -> FileContent.TooBig(at, size, modified)
|
||||
// A backend that has learned a fifth answer. Reported rather than guessed at: picking
|
||||
// the nearest of the four would draw something confident about a state never seen.
|
||||
else ->
|
||||
throw ApiException(
|
||||
"The server described this file as \"$kind\", which this app does not know how to show."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a file's contents, but only while it still hashes to [ifSha256].
|
||||
*
|
||||
* The refusal is a 409 carrying the server's wording, which is what the conflict dialog shows -- an
|
||||
* agent editing the same file while somebody reads it is the ordinary case here.
|
||||
*/
|
||||
fun writeFile(
|
||||
settings: ServerSettings,
|
||||
setup: String,
|
||||
path: String,
|
||||
content: String,
|
||||
ifSha256: String,
|
||||
): Written =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${setup.urlEncoded()}/file",
|
||||
method = "PUT",
|
||||
jsonBody =
|
||||
JSONObject()
|
||||
.put("path", path)
|
||||
.put("content", content)
|
||||
.put("ifSha256", ifSha256)
|
||||
.toString(),
|
||||
readTimeoutMs = 60000,
|
||||
) { connection ->
|
||||
val body = connection.jsonObject()
|
||||
Written(body.optLong("size"), body.optLong("modified"), body.getString("sha256"))
|
||||
}
|
||||
|
||||
/** Creates an empty file. Refused, with the machine's own words, if the name is already taken. */
|
||||
fun createFile(settings: ServerSettings, setup: String, path: String) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${setup.urlEncoded()}/file",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("path", path).toString(),
|
||||
readTimeoutMs = 30000,
|
||||
) {}
|
||||
}
|
||||
|
||||
/** Creates a directory, with the same refusal as [createFile]. */
|
||||
fun createDir(settings: ServerSettings, setup: String, path: String) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${setup.urlEncoded()}/dir",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("path", path).toString(),
|
||||
readTimeoutMs = 30000,
|
||||
) {}
|
||||
}
|
||||
|
||||
fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray =
|
||||
requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) {
|
||||
it.inputStream.readBytes()
|
||||
@@ -598,10 +744,9 @@ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String):
|
||||
// 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.
|
||||
* The API's own word for which window this is -- "session" for the five-hour one. 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,
|
||||
@@ -620,8 +765,8 @@ data class UsageSnapshot(
|
||||
* 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.
|
||||
* machine somebody chose not to put an account on -- a fact, not a fault. Collapsing them made
|
||||
* a healthy setup read as broken.
|
||||
*/
|
||||
val state: String,
|
||||
/** Why, for the two states that are faults. Absent otherwise. */
|
||||
@@ -637,8 +782,8 @@ fun fetchUsage(settings: ServerSettings): List<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.
|
||||
// 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 =
|
||||
@@ -659,8 +804,7 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
|
||||
* 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.
|
||||
* special case of it. What a provider makes of several answers is decided on the server.
|
||||
*/
|
||||
fun answerQuestion(
|
||||
settings: ServerSettings,
|
||||
@@ -687,9 +831,8 @@ fun interruptSession(settings: ServerSettings, sessionId: String) {
|
||||
/**
|
||||
* 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.
|
||||
* Not a delete and not an interrupt: the conversation stays where it is and [startSession] picks it
|
||||
* back up. The server reports what it could not do rather than answering the same way either way.
|
||||
*/
|
||||
fun stopSession(settings: ServerSettings, sessionId: String) {
|
||||
requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {}
|
||||
@@ -707,12 +850,10 @@ fun startSession(settings: ServerSettings, sessionId: String) {
|
||||
* 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.
|
||||
* is each row's own state. That is the point: leaving the screen used to cancel the delete.
|
||||
*
|
||||
* 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.
|
||||
* One request for the whole batch, which is what makes a handover all-or-nothing. One per row meant
|
||||
* a batch could half-arrive, and the rows that were missed looked exactly like rows not picked.
|
||||
*/
|
||||
fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) {
|
||||
requestFromServer(
|
||||
@@ -729,8 +870,7 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<S
|
||||
* 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.
|
||||
* changing -- which is what lets the screen be left.
|
||||
*/
|
||||
fun startImport(
|
||||
settings: ServerSettings,
|
||||
@@ -760,7 +900,7 @@ fun startImport(
|
||||
*
|
||||
* 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 anything was readable, which looked exactly like the app loading top-down.
|
||||
*
|
||||
* [before] pages backwards for history somebody scrolls to; absent means the newest page.
|
||||
*/
|
||||
@@ -769,21 +909,30 @@ fun fetchTranscript(
|
||||
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`.
|
||||
// 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. Ignored by the server for the
|
||||
// newest window, where the live cursor needs real seqs.
|
||||
coalesce: Boolean = false,
|
||||
): List<SeqEvent> {
|
||||
// Return nothing at or below this seq, stopping the page here instead of at [limit]. The phone
|
||||
// passes the end of the run it already holds cached, so a page never overlaps that copy -- an
|
||||
// overlap it cannot store, since a coalesced event cannot be cut inside its own delta run.
|
||||
after: Long? = null,
|
||||
): List<Pair<String, SeqEvent>> {
|
||||
val query = buildString {
|
||||
append("?limit=").append(limit)
|
||||
if (before != null) append("&before=").append(before)
|
||||
if (coalesce) append("&coalesce=true")
|
||||
if (after != null) append("&after=").append(after)
|
||||
}
|
||||
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()) }
|
||||
// The text as well as the event: the transcript cache stores the one and the fold needs the
|
||||
// other, and they have to be the same line.
|
||||
(0 until body.length()).map {
|
||||
val line = body.getJSONObject(it).toString()
|
||||
line to parseSeqEvent(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,7 +942,7 @@ fun fetchTranscript(
|
||||
* 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.
|
||||
* picker.
|
||||
*/
|
||||
fun renameSession(settings: ServerSettings, sessionId: String, title: String) {
|
||||
requestFromServer(
|
||||
@@ -885,10 +1034,9 @@ fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Bo
|
||||
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.
|
||||
// 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)
|
||||
|
||||
|
||||
@@ -29,16 +29,22 @@ 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.
|
||||
* Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
|
||||
* them a step down from another -- and what is left here is only what genuinely is a step down: one
|
||||
* session, spawning one, and settings.
|
||||
*/
|
||||
private sealed class Screen {
|
||||
data object Main : Screen()
|
||||
|
||||
data class Session(val summary: SessionSummary) : Screen()
|
||||
/**
|
||||
* One session, with the file explorer over it when [files] is set.
|
||||
*
|
||||
* The explorer is a layer on this screen rather than a screen of its own, so the session under
|
||||
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
|
||||
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re-
|
||||
* created on every return, refetching the transcript over the tunnel.
|
||||
*/
|
||||
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
|
||||
|
||||
data object Spawn : Screen()
|
||||
|
||||
@@ -50,7 +56,7 @@ private sealed class 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.
|
||||
* would otherwise compare equal.
|
||||
*/
|
||||
data class SessionOpenRequest(val sessionId: String, val serial: Int)
|
||||
|
||||
@@ -58,8 +64,8 @@ data class SessionOpenRequest(val sessionId: String, val serial: Int)
|
||||
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.
|
||||
* [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.
|
||||
*
|
||||
@@ -79,8 +85,8 @@ fun AppRoot(
|
||||
// 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.
|
||||
// Bumped whenever another screen changes something the list shows, so returning to it
|
||||
// refetches.
|
||||
var reloadToken by remember { mutableIntStateOf(0) }
|
||||
// Cleared by the session screen that attached it, not when a newer request arrives: a share
|
||||
// must be attached exactly once, and only the screen that did it knows that it has.
|
||||
@@ -94,9 +100,8 @@ fun AppRoot(
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// A standing condition rather than a per-request failure, so it is stated once here instead of
|
||||
// appended to every error it might cause. Without this the app is simply unreachable and every
|
||||
// screen blames the server or the tunnel for it.
|
||||
if (!localNetworkAllowed(context)) {
|
||||
Text(
|
||||
@@ -111,8 +116,8 @@ fun AppRoot(
|
||||
|
||||
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.
|
||||
// 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,
|
||||
@@ -126,10 +131,9 @@ fun AppRoot(
|
||||
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.
|
||||
// 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
|
||||
@@ -139,8 +143,8 @@ fun AppRoot(
|
||||
}
|
||||
|
||||
// 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.
|
||||
// first, because that is what SessionScreen is given -- and unlike a list row, there is nothing
|
||||
// here to seed it from.
|
||||
//
|
||||
// A failure is reported rather than swallowed: somebody deliberately tapped a notification, so
|
||||
// an app that opens to the session list with no explanation looks like the tap missed.
|
||||
@@ -170,10 +174,9 @@ fun AppRoot(
|
||||
)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Every screen but the session takes the keyboard as bottom padding here. The session screen
|
||||
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is
|
||||
// the cost that made it lag, so it moves only its composer and transcript.
|
||||
when (val here = screen) {
|
||||
is Screen.Main ->
|
||||
Box(Modifier.imePadding()) {
|
||||
@@ -192,19 +195,33 @@ fun AppRoot(
|
||||
}
|
||||
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.
|
||||
// one showing other rows. SessionScreen remembers a transcript, an open stream, a draft
|
||||
// and a scroll position, and without the key Compose keeps all of it across the change
|
||||
// and merges two conversations -- which crashes the list on the first duplicate row
|
||||
// key. Only reachable since a notification can move straight from one session to
|
||||
// another.
|
||||
key(here.summary.id) {
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
onBack = goToMain,
|
||||
share = share,
|
||||
onShareTaken = { share = null },
|
||||
)
|
||||
// A Box so the explorer can be drawn *over* the session rather than instead of it.
|
||||
// No imePadding here, for the reason above -- the explorer adds its own.
|
||||
Box {
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
onBack = goToMain,
|
||||
onFiles = { screen = here.copy(files = it) },
|
||||
share = share,
|
||||
onShareTaken = { share = null },
|
||||
)
|
||||
// Its own back handler is registered after this screen's, so it is the one the
|
||||
// platform asks first, and it steps back inside itself before closing.
|
||||
here.files?.let { target ->
|
||||
FilesScreen(
|
||||
settings = current,
|
||||
target = target,
|
||||
onClose = { screen = here.copy(files = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is Screen.Spawn ->
|
||||
Box(Modifier.imePadding()) {
|
||||
@@ -232,8 +249,7 @@ fun AppRoot(
|
||||
|
||||
// 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.
|
||||
// wanting attention is not a fact about the page somebody happens to be on. Tapping one is the
|
||||
// same act as tapping a notification, so it goes through the same `open`.
|
||||
SessionAlerts(onOpen = { request -> scope.launch { open(request) } })
|
||||
}
|
||||
@@ -10,42 +10,170 @@ 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.material3.TextButton
|
||||
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>)
|
||||
|
||||
/**
|
||||
* Every question one tool call is waiting on.
|
||||
* What the reader has settled on for one question, before any of it is sent.
|
||||
*
|
||||
* 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
|
||||
* Held here rather than inferred from the transcript, which is what made picking an option feel
|
||||
* broken: the mark used to appear only when the answer had crossed the tunnel and come back as an
|
||||
* event, so the card sat unchanged for most of a second after a tap.
|
||||
*
|
||||
* Picked options and typed words are one field each because they are alternatives rather than
|
||||
* parts: typing puts the picks away and picking puts the words away, so there is never a draft that
|
||||
* means two things.
|
||||
*/
|
||||
data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
|
||||
val settled: Boolean
|
||||
get() = picked.isNotEmpty() || other.isNotBlank()
|
||||
|
||||
/**
|
||||
* What goes back, in the order the options were offered rather than the order they were tapped:
|
||||
* the reader is answering a list, and it should read back as that list.
|
||||
*/
|
||||
fun answers(options: List<QuestionOption>): List<String> =
|
||||
if (other.isNotBlank()) listOf(other.trim())
|
||||
else options.map { it.label }.filter { it in picked }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every question one tool call is waiting on, one at a time.
|
||||
*
|
||||
* All of it comes from the question events themselves. None of it is read out of the call's own
|
||||
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
|
||||
* app, where no other provider can reach it and where it drifts the first time the schema moves.
|
||||
*
|
||||
* One question on screen with arrows to the others, rather than all of them stacked. A card asking
|
||||
* three questions with four options and a description each is several screens tall, so the reader
|
||||
* scrolls past the question they are answering to reach the button that sends it. Paged, each
|
||||
* question is a screen and the count says how many are left.
|
||||
*
|
||||
* Nothing is sent until Submit. Answering is one act even when it is several questions: the tool
|
||||
* asked them together, and sending each as it was tapped meant the reader could not change their
|
||||
* mind about the first after reading the third.
|
||||
*/
|
||||
@Composable
|
||||
fun AskUserQuestionBody(
|
||||
asks: List<TranscriptItem.QuestionCard>,
|
||||
onAnswer: (questionId: String, answers: List<String>) -> Unit,
|
||||
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()) {
|
||||
asks.forEach { ask ->
|
||||
if (asks.size > 1) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
"Question ${at + 1} of ${asks.size}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Disabled at the ends rather than absent, so the pair keeps its place and the
|
||||
// reader can see there is nothing further that way.
|
||||
MarkButton("Previous question", { at-- }, enabled = at > 0) {
|
||||
Chevron(Pointing.Left, colour = LocalContentColor.current)
|
||||
}
|
||||
MarkButton("Next question", { at++ }, enabled = at < asks.size - 1) {
|
||||
Chevron(Pointing.Right, colour = LocalContentColor.current)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
AskedQuestion(
|
||||
showing,
|
||||
draft = drafts[showing.id] ?: Draft(),
|
||||
onDraft = { drafts = drafts + (showing.id to it) },
|
||||
)
|
||||
if (outstanding.isNotEmpty()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
AskedQuestion(ask) { answers -> onAnswer(ask.id, answers) }
|
||||
// Greyed until every question has an answer, because the tool is waiting on all of
|
||||
// them: a submit that sent two of three would leave the third asked and the card
|
||||
// looking dealt with.
|
||||
val ready = outstanding.all { drafts[it.id]?.settled == true }
|
||||
Button(
|
||||
onClick = {
|
||||
sending = true
|
||||
onAnswer(
|
||||
outstanding.map { ask ->
|
||||
QuestionAnswer(ask.id, (drafts[ask.id] ?: Draft()).answers(ask.options))
|
||||
}
|
||||
) {
|
||||
// Back to a button whatever happened. A refusal is reported by the screen
|
||||
// around this, and the draft is still here to send again -- a spinner that
|
||||
// never stops would be the only sign of a failure this card cannot
|
||||
// describe.
|
||||
sending = false
|
||||
}
|
||||
},
|
||||
enabled = ready && !sending,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (sending) {
|
||||
// In the button rather than beside it, so the row does not change height at the
|
||||
// moment it is pressed.
|
||||
CircularProgressIndicator(
|
||||
Modifier.height(18.dp).width(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = LocalContentColor.current,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
if (outstanding.size > 1) "Submit ${outstanding.size} answers" else "Submit"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,11 +182,17 @@ fun AskUserQuestionBody(
|
||||
* 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.
|
||||
* when nothing did. Two renderings of it would be two places for an answer to go missing.
|
||||
*
|
||||
* [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here
|
||||
* sends anything. An answered question ignores both and draws what was answered.
|
||||
*/
|
||||
@Composable
|
||||
fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) -> Unit) {
|
||||
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
|
||||
@@ -75,24 +209,28 @@ fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) ->
|
||||
// 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.
|
||||
// "picked" while the question is open, so it is one appearance learned once.
|
||||
val answered = ask.answers.isNotEmpty()
|
||||
if (ask.multiSelect && !answered) {
|
||||
MultipleChoice(ask.options, onAnswer)
|
||||
} else if (ask.options.all { it.description == null && it.preview == null }) {
|
||||
// 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, ask.answers, onAnswer.takeUnless { answered })
|
||||
AnswerOptions(ask.options, marked.toList(), onPick)
|
||||
} else {
|
||||
ask.options.forEach { option ->
|
||||
OptionCard(option, selected = option.label in ask.answers) {
|
||||
if (!answered) onAnswer(listOf(option.label))
|
||||
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.
|
||||
// What was answered in the reader's own words, which no option can mark. Only ever the
|
||||
// answers that match nothing offered, so a question answered by picking says it by the
|
||||
// mark.
|
||||
val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } }
|
||||
if (inWords.isNotEmpty()) {
|
||||
Text(
|
||||
@@ -102,43 +240,29 @@ fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) ->
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
if (!answered) OtherAnswer(onAnswer)
|
||||
if (!answered) {
|
||||
OtherAnswer(draft.other) { onDraft(Draft(other = it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options that can be chosen together, with one button to send them.
|
||||
*
|
||||
* The answer goes back as the list it is. What a provider makes of several answers is decided where
|
||||
* that provider is spoken to -- Claude Code's answers map holds a string, so they are joined there
|
||||
* -- and nothing on this side has to know that.
|
||||
* [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].
|
||||
*/
|
||||
@Composable
|
||||
private fun MultipleChoice(options: List<QuestionOption>, onAnswer: (List<String>) -> Unit) {
|
||||
var chosen by remember { mutableStateOf(setOf<String>()) }
|
||||
options.forEach { option ->
|
||||
OptionCard(option, selected = option.label in chosen) {
|
||||
chosen = if (option.label in chosen) chosen - option.label else chosen + option.label
|
||||
}
|
||||
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)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
// In the order they were offered rather than the order they were tapped: the reader is
|
||||
// answering a list, and it should read back as that list.
|
||||
onClick = { onAnswer(options.map { it.label }.filter { it in chosen }) },
|
||||
enabled = chosen.isNotEmpty(),
|
||||
) {
|
||||
Text(if (chosen.size <= 1) "Send answer" else "Send ${chosen.size} answers")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* should have been. A border is one cue and it is unambiguous.
|
||||
*/
|
||||
@Composable
|
||||
private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) {
|
||||
@@ -192,8 +316,8 @@ private fun Preview(preview: String) {
|
||||
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.
|
||||
// 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()),
|
||||
)
|
||||
@@ -204,24 +328,20 @@ private fun Preview(preview: String) {
|
||||
* 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.
|
||||
* rather than pick. Leaving it out narrows a question that was never that narrow.
|
||||
*/
|
||||
@Composable
|
||||
private fun OtherAnswer(onAnswer: (List<String>) -> Unit) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text("Other") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { onAnswer(listOf(text.trim())) }, enabled = text.isNotBlank()) {
|
||||
Text("Send")
|
||||
}
|
||||
}
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,16 +349,15 @@ private fun OtherAnswer(onAnswer: (List<String>) -> Unit) {
|
||||
*
|
||||
* 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.
|
||||
* That reads as those having been the only choices.
|
||||
*/
|
||||
@Composable
|
||||
fun AnswerOptions(
|
||||
options: List<QuestionOption>,
|
||||
/** What was chosen, marked rather than restated; empty while the question is open. */
|
||||
/** 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. */
|
||||
onAnswer: ((List<String>) -> Unit)?,
|
||||
onPick: ((String) -> Unit)?,
|
||||
) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -248,11 +367,11 @@ fun AnswerOptions(
|
||||
options.forEach { option ->
|
||||
val taken = option.label in answers
|
||||
OutlinedButton(
|
||||
onClick = { onAnswer?.invoke(listOf(option.label)) },
|
||||
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 = onAnswer != null,
|
||||
// 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,
|
||||
|
||||
@@ -28,8 +28,8 @@ 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.
|
||||
* file is not fetched -- there is nothing on this phone to open a trace with -- so the name is all
|
||||
* of it.
|
||||
*/
|
||||
@Composable
|
||||
fun Attachment(
|
||||
@@ -50,8 +50,7 @@ fun Attachment(
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* is identified by both ends -- what it is at the front, what kind at the back.
|
||||
*/
|
||||
@Composable
|
||||
fun FileName(name: String, modifier: Modifier = Modifier) {
|
||||
|
||||
@@ -20,10 +20,8 @@ import kotlin.math.max
|
||||
* 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.
|
||||
* expensive part on a phone is the upload, not the decode. What the limit *is* comes from the
|
||||
* server, per session, because that is where a provider's requirements are known.
|
||||
*/
|
||||
suspend fun uploadPickedImage(
|
||||
context: Context,
|
||||
@@ -53,18 +51,17 @@ suspend fun uploadPicked(
|
||||
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.
|
||||
// Opened before the request starts, so a provider that refuses says so here and not from inside
|
||||
// the connection; then streamed, since a trace is bigger than this process should hold at once.
|
||||
val source = openSource(resolver, uri)
|
||||
val name = displayName(resolver, uri)
|
||||
return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out ->
|
||||
try {
|
||||
source.use { it.copyTo(out, COPY_BUFFER) }
|
||||
} catch (e: java.io.IOException) {
|
||||
// Either side of the copy can fail; the message names the file, which is the
|
||||
// part the reader can do something about.
|
||||
throw ApiException("couldn't send $name: ${e.message}", e)
|
||||
// Either side of the copy can fail; the message names the file, which is the part the
|
||||
// reader can do something about.
|
||||
throw ApiException("couldn't send $name: ${e.message}", cause = e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,7 +73,7 @@ private const val COPY_BUFFER = 64 * 1024
|
||||
*
|
||||
* 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.
|
||||
* are things the reader can act on.
|
||||
*/
|
||||
private fun openSource(resolver: ContentResolver, uri: Uri): java.io.InputStream =
|
||||
try {
|
||||
@@ -110,9 +107,9 @@ private fun displayName(resolver: ContentResolver, uri: Uri): String {
|
||||
/**
|
||||
* 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.
|
||||
* An image already inside the limit is uploaded exactly as it came, rather than decoded and re-
|
||||
* encoded to the same size: a round trip through JPEG loses a little every time. This is also the
|
||||
* path a provider with no limit always takes.
|
||||
*/
|
||||
private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteArray, String> {
|
||||
val resolver = context.contentResolver
|
||||
@@ -128,9 +125,9 @@ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteA
|
||||
// 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.
|
||||
// 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))
|
||||
@@ -141,9 +138,8 @@ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteA
|
||||
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.
|
||||
// re-encoding drops the tag -- so a portrait photo would arrive at the model on its side.
|
||||
// Applied to the same matrix as the scale, so it costs no second copy of the bitmap.
|
||||
matrix.postRotate(exifRotation(original))
|
||||
val scaled = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
|
||||
val out = ByteArrayOutputStream()
|
||||
@@ -166,8 +162,8 @@ private fun exifRotation(bytes: ByteArray): Float =
|
||||
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.
|
||||
// No EXIF, or none this can read. Upright is the assumption every image without the tag is
|
||||
// displayed under anyway.
|
||||
0f
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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.
|
||||
|
||||
/**
|
||||
* A bordered pill: a control that can be seen without being pressed.
|
||||
*
|
||||
* The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at
|
||||
* all until they are touched. Three bare words under the message field read as a caption about the
|
||||
* field rather than as three things to press. The outline says "control" without the weight of a
|
||||
* filled button, which is reserved for the two that act on the session.
|
||||
*/
|
||||
@Composable
|
||||
fun BubbleButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
shape = BubbleShape,
|
||||
// A text button's padding rather than a filled button's 24dp: these sit three across under
|
||||
// the message field, and the wider padding is what decides whether the row fits.
|
||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
||||
modifier = modifier,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** Fully round ends, so the control reads as a bubble rather than as a box. */
|
||||
val BubbleShape: Shape = RoundedCornerShape(percent = 50)
|
||||
|
||||
/**
|
||||
* The corner on a menu one of these opens.
|
||||
*
|
||||
* A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding
|
||||
* ends that tall would bow its sides.
|
||||
*/
|
||||
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
|
||||
@@ -26,20 +26,16 @@ import androidx.compose.ui.unit.dp
|
||||
* 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.
|
||||
* the same in the import list and the session list or the appearance becomes a per-screen dialect.
|
||||
*
|
||||
* [label] names the operation and `null` means none is running. One parameter rather than a boolean
|
||||
* beside a string, which can disagree: 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.
|
||||
* beside a string, which can disagree. It is a *word* because a spinner alone cannot say which
|
||||
* operation this is -- deleting and importing are different in kind.
|
||||
*
|
||||
* It does **not** make the row inert; the caller disables its own click handling while it passes a
|
||||
* label. That was the other way round at first — an overlay consuming pointer events, 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.
|
||||
* label. That was the other way round at first -- an overlay consuming pointer events -- and it
|
||||
* swallowed the drag along with the tap, so a list could not be scrolled while anything in it was
|
||||
* busy.
|
||||
*/
|
||||
@Composable
|
||||
fun BusyItem(label: String?, content: @Composable () -> Unit) {
|
||||
@@ -71,14 +67,12 @@ fun BusyItem(label: String?, content: @Composable () -> Unit) {
|
||||
/**
|
||||
* 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.
|
||||
* Both, rather than either alone. Dimming by itself is the same cue as a disabled control, so a
|
||||
* busy row read as one more thing that could not be tapped. Draining the colour is what says the
|
||||
* row is *suspended* -- the status word and everything else that means something by its colour stop
|
||||
* meaning it for as long as the operation runs, which is exactly true.
|
||||
*
|
||||
* Not all the way to grey. A row with no colour left is hard to find again in a list, and the
|
||||
* reader is watching this one.
|
||||
* Not all the way to grey: a row with no colour left is hard to find again in a list.
|
||||
*/
|
||||
private fun Modifier.busy(busy: Boolean): Modifier =
|
||||
if (!busy) this
|
||||
|
||||
@@ -11,43 +11,59 @@ 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 up or down.
|
||||
* 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 both directions rather than two that differ by a minus sign -- the pair would
|
||||
* drift, and the drift would be a bug in exactly one direction.
|
||||
* One composable for all four directions rather than one per axis that differ by which coordinate
|
||||
* gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one
|
||||
* direction. The shape is written once in its own coordinates, and [Pointing] is only a table of
|
||||
* how those map onto the box.
|
||||
*
|
||||
* It draws no label of its own, so every caller owes it a `contentDescription`: 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.
|
||||
* It draws no label of its own, so every caller owes it a `contentDescription`.
|
||||
*/
|
||||
@Composable
|
||||
fun Chevron(
|
||||
pointingUp: Boolean,
|
||||
pointing: Pointing,
|
||||
modifier: Modifier = Modifier,
|
||||
colour: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Canvas(modifier.width(20.dp).height(10.dp)) {
|
||||
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 point = if (pointingUp) inset else size.height - inset
|
||||
val ends = if (pointingUp) size.height - inset else inset
|
||||
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,
|
||||
Offset(inset, ends),
|
||||
Offset(size.width / 2, point),
|
||||
strokeWidth = stroke,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
drawLine(
|
||||
colour,
|
||||
Offset(size.width / 2, point),
|
||||
Offset(size.width - inset, ends),
|
||||
strokeWidth = stroke,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
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
|
||||
@@ -11,23 +11,14 @@ 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.graphics.Color
|
||||
import androidx.compose.ui.semantics.isTraversalGroup
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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 dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.BoldHighlight
|
||||
import dev.snipme.highlights.model.ColorHighlight
|
||||
import dev.snipme.highlights.model.SyntaxLanguage
|
||||
import org.intellij.markdown.MarkdownElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
@@ -38,15 +29,13 @@ 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 lexer that colours a tool call's
|
||||
* command colours a reply's code the same way, through [highlighted] and one theme, so a `kotlin`
|
||||
* fence and the Kotlin a tool wrote are the same colours. A fence in a language the lexer 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.
|
||||
* The renderer's own fence drew the same block in plain text. The scanner that colours a tool
|
||||
* call's command colours a reply's code the same way, so a `kotlin` fence and the Kotlin a tool
|
||||
* wrote are the same colours. A fence in a language [scan] has no rules for is plain rather than
|
||||
* wrongly coloured.
|
||||
*
|
||||
* Finding the code is still the library's: which children of the node are the fence markers, the
|
||||
* language word and the code between them is its knowledge of the parser, and [MarkdownCodeFence]
|
||||
* hands out the code and the language and leaves the drawing to the block it is given.
|
||||
* language word and the code between them is its knowledge of the parser.
|
||||
*/
|
||||
@Composable
|
||||
fun CodeFence(
|
||||
@@ -74,18 +63,16 @@ fun CodeBlock(
|
||||
}
|
||||
|
||||
/**
|
||||
* The code inside a fence or indented block, and the lexer's language for its info word.
|
||||
* 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.
|
||||
* Copied from the library's `MarkdownCodeFence` rather than called: that one is a composable, and
|
||||
* the whole point here is that [warm] can run this on a background thread and highlight the same
|
||||
* string the drawing will ask for. Two extractions would be two keys, and the warmed answer would
|
||||
* be silently missed at every fence.
|
||||
*
|
||||
* Null for a fence too short to hold anything -- an unterminated one still arriving, which the
|
||||
* library skips as invalid.
|
||||
* Null for a fence too short to hold anything -- an unterminated one still arriving.
|
||||
*/
|
||||
fun fenceContent(content: String, node: ASTNode): Pair<String, SyntaxLanguage?>? {
|
||||
fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
|
||||
val word =
|
||||
node.findChildOfType(MarkdownTokenTypes.FENCE_LANG)?.getTextInNode(content)?.toString()
|
||||
val language = fenceLanguage(word)
|
||||
@@ -106,12 +93,11 @@ fun fenceContent(content: String, node: ASTNode): Pair<String, SyntaxLanguage?>?
|
||||
*
|
||||
* 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: SyntaxLanguage?,
|
||||
language: Language?,
|
||||
style: TextStyle,
|
||||
replies: ParsedReplies,
|
||||
streaming: Boolean,
|
||||
@@ -126,8 +112,7 @@ private fun CodeBlockText(
|
||||
.semantics { isTraversalGroup = true }
|
||||
) {
|
||||
BasicText(
|
||||
// No language while the block is still being written, which is what draws it plain;
|
||||
// see [MarkdownRoot]'s `streaming`.
|
||||
// No language while the block is still being written, which is what draws it plain.
|
||||
replies.highlighted(code, language.takeUnless { streaming }),
|
||||
style = style,
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
|
||||
@@ -136,122 +121,90 @@ private fun CodeBlockText(
|
||||
}
|
||||
|
||||
/**
|
||||
* The lexer's language for a fence's info word, or null for one it has no lexer for.
|
||||
* 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 lexer's, because a fence
|
||||
* 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?): SyntaxLanguage? =
|
||||
fun fenceLanguage(name: String?): Language? =
|
||||
FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null]
|
||||
|
||||
private val FENCE_LANGUAGES: Map<String, SyntaxLanguage> =
|
||||
/**
|
||||
* The highlighter's language for a *file*, from its name.
|
||||
*
|
||||
* The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people
|
||||
* write after the backticks. One table rather than two, so a language added for fences is a
|
||||
* language added for files and neither can be the one somebody forgot.
|
||||
*
|
||||
* The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin. A
|
||||
* leading dot is not one: `.bashrc` has no extension, it has a name that starts with a dot. A name
|
||||
* with no dot at all -- `Makefile` -- is likewise null, and null is drawn plain.
|
||||
*/
|
||||
fun fileLanguage(name: String): Language? {
|
||||
val dot = name.lastIndexOf('.')
|
||||
if (dot < 1) return null
|
||||
return fenceLanguage(name.substring(dot + 1))
|
||||
}
|
||||
|
||||
private val FENCE_LANGUAGES: Map<String, Language> =
|
||||
mapOf(
|
||||
"kotlin" to SyntaxLanguage.KOTLIN,
|
||||
"kt" to SyntaxLanguage.KOTLIN,
|
||||
"kts" to SyntaxLanguage.KOTLIN,
|
||||
"rust" to SyntaxLanguage.RUST,
|
||||
"rs" to SyntaxLanguage.RUST,
|
||||
"sh" to SyntaxLanguage.SHELL,
|
||||
"bash" to SyntaxLanguage.SHELL,
|
||||
"shell" to SyntaxLanguage.SHELL,
|
||||
"zsh" to SyntaxLanguage.SHELL,
|
||||
"console" to SyntaxLanguage.SHELL,
|
||||
"python" to SyntaxLanguage.PYTHON,
|
||||
"py" to SyntaxLanguage.PYTHON,
|
||||
"javascript" to SyntaxLanguage.JAVASCRIPT,
|
||||
"js" to SyntaxLanguage.JAVASCRIPT,
|
||||
"jsx" to SyntaxLanguage.JAVASCRIPT,
|
||||
"typescript" to SyntaxLanguage.TYPESCRIPT,
|
||||
"ts" to SyntaxLanguage.TYPESCRIPT,
|
||||
"tsx" to SyntaxLanguage.TYPESCRIPT,
|
||||
"java" to SyntaxLanguage.JAVA,
|
||||
"c" to SyntaxLanguage.C,
|
||||
"h" to SyntaxLanguage.C,
|
||||
"cpp" to SyntaxLanguage.CPP,
|
||||
"c++" to SyntaxLanguage.CPP,
|
||||
"cc" to SyntaxLanguage.CPP,
|
||||
"hpp" to SyntaxLanguage.CPP,
|
||||
"csharp" to SyntaxLanguage.CSHARP,
|
||||
"cs" to SyntaxLanguage.CSHARP,
|
||||
"c#" to SyntaxLanguage.CSHARP,
|
||||
"go" to SyntaxLanguage.GO,
|
||||
"golang" to SyntaxLanguage.GO,
|
||||
"swift" to SyntaxLanguage.SWIFT,
|
||||
"dart" to SyntaxLanguage.DART,
|
||||
"ruby" to SyntaxLanguage.RUBY,
|
||||
"rb" to SyntaxLanguage.RUBY,
|
||||
"php" to SyntaxLanguage.PHP,
|
||||
"perl" to SyntaxLanguage.PERL,
|
||||
"pl" to SyntaxLanguage.PERL,
|
||||
"coffeescript" to SyntaxLanguage.COFFEESCRIPT,
|
||||
"coffee" to SyntaxLanguage.COFFEESCRIPT,
|
||||
"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,
|
||||
"markdown" to Language.MARKDOWN,
|
||||
"md" to Language.MARKDOWN,
|
||||
)
|
||||
|
||||
/**
|
||||
* [code] with its keywords and strings coloured, or plain if there is no language for it.
|
||||
*
|
||||
* The lexing is dev.snipme:highlights. The colours are this app's, mapped in [catppuccinSyntax] --
|
||||
* a library's default theme would be the one place in the app whose palette came from somewhere
|
||||
* else. 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.
|
||||
*
|
||||
* Measured on the emulator before it was cached: a two-hundred-line Kotlin fence costs **174ms** to
|
||||
* lex, and the lazy list charged that again every time the block scrolled back into composition.
|
||||
* That is the whole reason [ParsedReplies.highlighted] exists rather than a `remember`.
|
||||
* 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 highlight(code: String, language: SyntaxLanguage?): AnnotatedString {
|
||||
if (language == null) return AnnotatedString(code)
|
||||
val marks =
|
||||
DebugStats.timed("code highlighted") {
|
||||
Highlights.Builder(code = code, language = language, theme = catppuccinSyntax())
|
||||
.build()
|
||||
.getHighlights()
|
||||
// highlights 1.1.0's shell lexer answers a quoted glob that looks like a comment
|
||||
// -- `x '*/a/*'` is the smallest input -- with a span whose end is before its
|
||||
// start, and AnnotatedString refuses such a range. That crashed the app the
|
||||
// moment a card holding `-path '*/.git/*'` was opened. Dropped rather than
|
||||
// clamped: a span the lexer got backwards is not one it knows the colour of.
|
||||
// Delete when snipme/highlights fixes it.
|
||||
.filter {
|
||||
it.location.start in 0..it.location.end && it.location.end <= code.length
|
||||
}
|
||||
}
|
||||
return buildAnnotatedString {
|
||||
append(code)
|
||||
marks.forEach { mark ->
|
||||
when (mark) {
|
||||
is ColorHighlight ->
|
||||
addStyle(
|
||||
SpanStyle(color = Color(mark.rgb or 0xFF000000.toInt())),
|
||||
mark.location.start,
|
||||
mark.location.end,
|
||||
)
|
||||
is BoldHighlight ->
|
||||
addStyle(
|
||||
SpanStyle(fontWeight = FontWeight.Bold),
|
||||
mark.location.start,
|
||||
mark.location.end,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, SyntaxLanguage?>> {
|
||||
fun fences(parse: State): List<Pair<String, Language?>> {
|
||||
val success = parse as? State.Success ?: return emptyList()
|
||||
val out = ArrayList<Pair<String, SyntaxLanguage?>>()
|
||||
val out = ArrayList<Pair<String, Language?>>()
|
||||
fun walk(node: ASTNode) {
|
||||
if (
|
||||
node.type == MarkdownElementTypes.CODE_FENCE ||
|
||||
|
||||
@@ -25,8 +25,7 @@ import androidx.compose.ui.unit.dp
|
||||
* 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.
|
||||
* through, because a dialect's own vocabulary grows without this list.
|
||||
*/
|
||||
data class SessionCommand(
|
||||
/** With the slash, as it is typed and as it is sent. */
|
||||
@@ -90,8 +89,8 @@ fun CommandSuggestions(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
// The command in the colour commands are, so the suggestion and the
|
||||
// bubble it becomes are visibly the same thing.
|
||||
// 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,
|
||||
@@ -117,8 +116,7 @@ fun CommandSuggestions(
|
||||
* 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.
|
||||
* reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes.
|
||||
*/
|
||||
@Composable
|
||||
fun CommandBubble(text: String, waiting: Boolean = false) {
|
||||
@@ -128,8 +126,8 @@ fun CommandBubble(text: String, waiting: Boolean = false) {
|
||||
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.
|
||||
// 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))
|
||||
|
||||
@@ -7,12 +7,10 @@ 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.
|
||||
* now, and that is a fact about the conversation, not a turn in it. Drawn by [TranscriptDivider],
|
||||
* which a clear also uses, so the two marks cannot drift apart.
|
||||
*
|
||||
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it,
|
||||
* which is the same thing the status line says while the compaction runs.
|
||||
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it.
|
||||
*/
|
||||
@Composable
|
||||
fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) {
|
||||
@@ -23,9 +21,8 @@ fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifi
|
||||
* 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.
|
||||
* to why the wait was worth it. When they were not reported this says only that a compaction
|
||||
* happened, rather than filling in a plausible number.
|
||||
*/
|
||||
fun compactionSummary(item: TranscriptItem.CompactedNote): String {
|
||||
val pre = item.preTokens
|
||||
@@ -41,8 +38,8 @@ fun compactionSummary(item: TranscriptItem.CompactedNote): String {
|
||||
* 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.
|
||||
* quantity about the same moment, and one grouping its thousands while the other did not read as
|
||||
* two different measurements.
|
||||
*/
|
||||
fun tokens(count: Long): String = "%,d".format(count)
|
||||
|
||||
@@ -50,15 +47,12 @@ 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.
|
||||
* compaction has begun and then says nothing until it has finished, so any bar or estimate here
|
||||
* would be this screen's guess wearing a measurement's clothes.
|
||||
*
|
||||
* [seconds] is null when this device did not see the compaction start, which is what opening a
|
||||
* session that is already compacting looks like. That case says only "compacting": 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.
|
||||
* session that is already compacting looks like. That case says only "compacting": a number counted
|
||||
* from the moment the screen opened would be wrong in the direction that matters.
|
||||
*/
|
||||
fun compactingLabel(seconds: Long?): String =
|
||||
when {
|
||||
|
||||
@@ -12,10 +12,9 @@ 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.
|
||||
* and `logcat` running, which is the one thing nobody has set up at the moment it happens. This
|
||||
* costs one file write on a process that is already dying, and it turns "it crashes when I open
|
||||
* that chat" into the frame it crashed in.
|
||||
*
|
||||
* Kept until it is read rather than cleared on the next launch: the app restarts before anybody can
|
||||
* ask about it, so a log that lives for one session is a log that is never read.
|
||||
@@ -26,8 +25,7 @@ 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.
|
||||
* reader. The top of a stack is what identifies a crash and the bottom is framework plumbing.
|
||||
*/
|
||||
private const val CRASH_LIMIT = 4000
|
||||
|
||||
@@ -35,8 +33,7 @@ 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.
|
||||
* ends the process, and an app that swallows that instead sits there in an unknown state.
|
||||
*/
|
||||
fun installCrashLog(context: Context) {
|
||||
val app = context.applicationContext
|
||||
|
||||
@@ -12,10 +12,9 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
*
|
||||
* 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.
|
||||
* is under the floor of what it can measure. Counts do not have that problem: how many times a row
|
||||
* was composed, or a reply parsed, is the same number on any machine, and it is the number that
|
||||
* says whether the work is proportional to what is on screen or to everything ever loaded.
|
||||
*
|
||||
* Always on rather than behind a build flag. What is measured is an atomic increment on paths that
|
||||
* already allocate lists and parse markdown, and a counter that is only compiled into the build
|
||||
@@ -90,14 +89,12 @@ object DebugStats {
|
||||
*
|
||||
* 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.
|
||||
* different things is high. The transcript times its own measure, placement and recording, and this
|
||||
* is the subtraction. What is left over is the framework's per-frame bookkeeping after a layout,
|
||||
* which grows with how many nodes are alive rather than how many are on screen.
|
||||
*
|
||||
* Per frame rather than in total, because the budget it has to fit in is per frame. The recordings
|
||||
* are not themselves per-frame -- a measurement happens on the frames that need one -- so these are
|
||||
* shares of an average frame, not a claim about any particular one.
|
||||
* are not themselves per-frame, so these are shares of an average frame.
|
||||
*/
|
||||
fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
|
||||
if (frames == 0 || drawNanos == 0L) return emptyList()
|
||||
@@ -122,8 +119,7 @@ fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
|
||||
* 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.
|
||||
* number -- a report whose reader has to ask what the columns mean costs another round trip.
|
||||
*/
|
||||
fun debugReport(
|
||||
device: String,
|
||||
|
||||
@@ -21,11 +21,7 @@ import androidx.compose.ui.unit.dp
|
||||
* 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.
|
||||
* The rules take [color] too, so the whole divider reads as one mark of one kind.
|
||||
*/
|
||||
@Composable
|
||||
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
|
||||
@@ -44,9 +40,8 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
|
||||
* 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.
|
||||
* compaction it summarises nothing and measures nothing. Everything above stays on screen and stays
|
||||
* scrollable -- the reader can see that, which is why this does not say it.
|
||||
*/
|
||||
@Composable
|
||||
fun ClearedRow(modifier: Modifier = Modifier) {
|
||||
|
||||
@@ -10,12 +10,11 @@ private const val DRAFTS = "session-drafts"
|
||||
*
|
||||
* 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.
|
||||
* box on the phone somebody is holding, and half a sentence surfacing on another device would be a
|
||||
* surprise. What has been *sent* is the server's.
|
||||
*
|
||||
* Kept per session id, 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.
|
||||
* Kept per session id: one shared box would hand a message meant for one session to whichever was
|
||||
* opened next.
|
||||
*/
|
||||
fun loadDraft(context: Context, sessionId: String): String =
|
||||
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).getString(sessionId, "").orEmpty()
|
||||
@@ -23,11 +22,9 @@ fun loadDraft(context: Context, sessionId: String): String =
|
||||
/**
|
||||
* 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.
|
||||
* The path out is emptying the box, which is what sending does. A session *deleted* while it held a
|
||||
* draft does leave its key behind: pruning those means a pass over the live session list, and the
|
||||
* residue is a few bytes per session ever abandoned mid-sentence.
|
||||
*/
|
||||
fun saveDraft(context: Context, sessionId: String, text: String) {
|
||||
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).edit {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.example.aiapp
|
||||
|
||||
/**
|
||||
* A span of milliseconds, written the way somebody reads it.
|
||||
*
|
||||
* A tool's timeout arrives as `480000`, which nobody reads as eight minutes. The rule has two
|
||||
* halves, because a short span and a long one are read for different things. Under a minute the
|
||||
* question is "roughly how long", so only the largest unit is shown and a fraction carries the rest
|
||||
* -- `2.5s`. At a minute or more the question is "how long exactly", so every unit with something
|
||||
* in it is written out -- `5d 12h 4m`. Empty units are left out rather than written as zero.
|
||||
*
|
||||
* Sub-second precision is dropped past a minute: nothing that takes days is measured in
|
||||
* milliseconds.
|
||||
*/
|
||||
fun formatMillis(ms: Long): String {
|
||||
if (ms < 0) return "-" + formatMillis(-ms)
|
||||
if (ms < 1000) return "${ms}ms"
|
||||
if (ms < 60_000) {
|
||||
val tenths = (ms + 50) / 100
|
||||
val whole = tenths / 10
|
||||
val rest = tenths % 10
|
||||
return if (rest == 0L) "${whole}s" else "$whole.${rest}s"
|
||||
}
|
||||
val seconds = ms / 1000
|
||||
val parts =
|
||||
listOf(
|
||||
"d" to seconds / 86_400,
|
||||
"h" to seconds / 3600 % 24,
|
||||
"m" to seconds / 60 % 60,
|
||||
"s" to seconds % 60,
|
||||
)
|
||||
return parts.filter { it.second > 0 }.joinToString(" ") { "${it.second}${it.first}" }
|
||||
}
|
||||
|
||||
/** [text] as a span when it is a whole number of milliseconds, and unchanged when it is not. */
|
||||
fun formatMillisText(text: String): String =
|
||||
text.trim().toLongOrNull()?.let { formatMillis(it) } ?: text
|
||||
@@ -12,7 +12,7 @@ private const val RESET_EVENT = "reset"
|
||||
*
|
||||
* 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.
|
||||
* saw as the new cursor.
|
||||
*/
|
||||
class EventStream(settings: ServerSettings, private val sessionId: String) {
|
||||
private val stream = Sse(settings)
|
||||
@@ -24,15 +24,21 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
|
||||
*
|
||||
* [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.
|
||||
* caller drops what it holds and rebuilds. It arrives before those events, so a caller that
|
||||
* clears on it stays in order.
|
||||
*/
|
||||
fun run(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
|
||||
fun run(
|
||||
after: Long,
|
||||
onOpen: () -> Unit,
|
||||
onReset: () -> Unit,
|
||||
// The frame's own text as well as the event parsed from it: the transcript cache stores the
|
||||
// one and the screen folds the other, and they have to be the same line.
|
||||
onEvent: (raw: String, event: SeqEvent) -> Unit,
|
||||
) {
|
||||
stream.run("/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.
|
||||
// A named frame carries no payload and a data frame has no name.
|
||||
if (name == RESET_EVENT) onReset()
|
||||
else if (data.isNotEmpty()) onEvent(parseSeqEvent(data))
|
||||
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,9 @@ 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.
|
||||
// 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)
|
||||
@@ -13,8 +12,7 @@ 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
|
||||
* More than a label because the reader is deciding rather than confirming. Both are absent on a
|
||||
* permission, whose Allow and Deny mean exactly what they say.
|
||||
*/
|
||||
data class QuestionOption(val label: String, val description: String?, val preview: String?)
|
||||
@@ -30,13 +28,12 @@ sealed class SessionEvent {
|
||||
*/
|
||||
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].
|
||||
* What was attached to it, by the ref the files route serves: images, and any file, told
|
||||
* apart by [isImageRef].
|
||||
*
|
||||
* On the message rather than beside it: these arrived as separate image events until
|
||||
* 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent
|
||||
* it, and left this app deciding from adjacency alone which message an image went with --
|
||||
* something the sender knew and could simply have said.
|
||||
* it, and left this app deciding from adjacency which message an image went with.
|
||||
*/
|
||||
val attachments: List<String>,
|
||||
) : SessionEvent()
|
||||
@@ -45,11 +42,10 @@ sealed class 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.
|
||||
* screen state, so leaving the session drew nothing waiting while the message was still queued
|
||||
* -- and nothing waiting is what "there is nothing" looks like.
|
||||
*
|
||||
* Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by
|
||||
* [CommandSent].
|
||||
* Resolved by the [UserMessage] carrying the same id.
|
||||
*/
|
||||
data class MessageQueued(val id: String, val text: String, val attachments: List<String>) :
|
||||
SessionEvent()
|
||||
@@ -59,8 +55,7 @@ sealed class SessionEvent {
|
||||
*
|
||||
* 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.
|
||||
* coming.
|
||||
*/
|
||||
data class MessageDropped(val id: String) : SessionEvent()
|
||||
|
||||
@@ -108,17 +103,14 @@ sealed class SessionEvent {
|
||||
*
|
||||
* 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`.
|
||||
* for a message read out of a session file, and for one that started no turn.
|
||||
*/
|
||||
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.
|
||||
* A command the session was asked to run on itself and cannot run yet. Resolved by
|
||||
* [CommandSent] with the same id; a command that ran straight away has only that one.
|
||||
*/
|
||||
data class CommandQueued(val id: String, val text: String) : SessionEvent()
|
||||
|
||||
@@ -130,29 +122,26 @@ sealed class 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.
|
||||
* Either field alone: the two are confirmed separately. Asking for a change is not having one,
|
||||
* so this -- not the request -- is what the pickers show.
|
||||
*/
|
||||
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
|
||||
|
||||
/**
|
||||
* What a turn cost, and how much the model was holding when it ended.
|
||||
*
|
||||
* [context] is prompt plus both cache figures, 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.
|
||||
* [context] is prompt plus both cache figures. Carried on the event rather than summed by the
|
||||
* reader, because it is not a sum: a conversation's context drops at a compaction and a clear,
|
||||
* so adding turns up would report a figure the session stopped being true of. Null where the
|
||||
* dialect did not say, which leaves the context unmeasured rather than unchanged.
|
||||
*/
|
||||
data class UsageDelta(val tokens: Long, val context: Long?) : 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.
|
||||
* The counts are nullable because the server sends them only when it was told them: a zero here
|
||||
* would read as "recovered nothing" and a made-up number would read as a measurement.
|
||||
*/
|
||||
data class Compacted(
|
||||
val preTokens: Long?,
|
||||
@@ -163,9 +152,7 @@ sealed class 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
|
||||
* the session's context. An object rather than a class because what it means is entirely its
|
||||
* position in the transcript.
|
||||
*/
|
||||
data object Cleared : SessionEvent()
|
||||
@@ -173,17 +160,15 @@ sealed class 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.
|
||||
* An event type this app build doesn't know -- a newer server. Kept rather than thrown so one
|
||||
* new event kind degrades to a placeholder row instead of killing the stream.
|
||||
*/
|
||||
data class Unknown(val type: String) : SessionEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON array of strings under [name], empty when the field is absent.
|
||||
*
|
||||
* 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.
|
||||
* A JSON array of strings under [name], empty when the field is absent -- the ordinary case, since
|
||||
* the server omits the field rather than sending an empty list.
|
||||
*/
|
||||
private fun JSONObject.stringList(name: String): List<String> {
|
||||
val array = optJSONArray(name) ?: return emptyList()
|
||||
@@ -212,8 +197,8 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
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.
|
||||
// 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"))
|
||||
@@ -282,36 +267,33 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
|
||||
*
|
||||
* One predicate because two readers have to agree on the list: the session screen's working
|
||||
* indicator, and the fold's decision that the newest reply is finished. Two copies would drift the
|
||||
* first time the server grows a state, and the drift would be a reply that never splits or one
|
||||
* split mid-stream.
|
||||
*/
|
||||
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* summary it opened with is a measurement from before this stream started.
|
||||
*
|
||||
* The two that lower it are the point. A clear takes the conversation away and a compaction
|
||||
* replaces it with a summary, so a figure measured before either stopped being true at that moment
|
||||
* -- and carrying it forward is how a session that had just been cleared went on reporting the
|
||||
* context it no longer had.
|
||||
*
|
||||
* Null is "we don't know", which 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.
|
||||
* Null is "we don't know", which each of them can reach.
|
||||
*/
|
||||
/**
|
||||
* 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.
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
||||
/**
|
||||
* The largest file this app will open in the editor, in bytes.
|
||||
*
|
||||
* Measured on the emulator 2026-09-04, in a debug build, on generated Rust:
|
||||
*
|
||||
* | file | lines | scan per keystroke | worst frame record | typing |
|
||||
* |--------|--------|--------------------|--------------------|-------------------|
|
||||
* | 32 kB | 917 | 10ms | 183ms | sluggish, correct |
|
||||
* | 128 kB | 3,633 | 40ms | 2,027ms | characters lost |
|
||||
* | 1 MB | 28,660 | -- | -- | stops responding |
|
||||
*
|
||||
* The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file
|
||||
* costs 40ms a keystroke, which is survivable, while laying the same text out in one
|
||||
* `BasicTextField` costs two seconds. So switching highlighting off above a size -- what
|
||||
* EXPLORER.md expected to have to decide -- would not have saved it; every arrangement of a single
|
||||
* text field pays that cost. A line-by-line editor is the way past this.
|
||||
*
|
||||
* 32 kB because it is the largest size actually measured as usable. The viewer's own limit stays
|
||||
* the server's `FILE_LIMIT` of 1 MiB: reading a big file is fine, and only editing one is not.
|
||||
*/
|
||||
const val EDIT_LIMIT = 32L * 1024
|
||||
|
||||
/**
|
||||
* The same file, editable, in the same face and colours it was being read in.
|
||||
*
|
||||
* `BasicTextField(TextFieldValue)` with a [VisualTransformation] is the one Compose arrangement
|
||||
* that colours a field's own text rather than replacing the field with something that only looks
|
||||
* like one: the transformation returns the text unchanged and the scanner's spans as styles, so
|
||||
* [OffsetMapping.Identity] is correct by construction. The newer `TextFieldState` API has no hook
|
||||
* for styles at all.
|
||||
*
|
||||
* The cost is that the whole file is re-scanned on every keystroke, which is what [EDIT_LIMIT] is
|
||||
* sized against.
|
||||
*
|
||||
* The gutter is one `Text` of `1\n2\n…` beside the field rather than a number per row, because
|
||||
* there are no rows here -- the field is one text object. It lines up for the same reason the
|
||||
* viewer's does: nothing wraps, so a logical line is a visual line.
|
||||
*/
|
||||
@Composable
|
||||
fun FileEditor(
|
||||
value: TextFieldValue,
|
||||
onValueChange: (TextFieldValue) -> Unit,
|
||||
language: Language?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val style = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface)
|
||||
val scroll = rememberScrollState()
|
||||
val count = value.text.removeSuffix("\n").count { it == '\n' } + 1
|
||||
val gutter = gutterWidth(count, style)
|
||||
val numbers = remember(count) { (1..count).joinToString("\n") }
|
||||
val transformation =
|
||||
remember(language) {
|
||||
VisualTransformation { text ->
|
||||
TransformedText(highlight(text.text, language), OffsetMapping.Identity)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.Top, modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
numbers,
|
||||
style = style,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.End,
|
||||
softWrap = false,
|
||||
modifier = Modifier.width(gutter),
|
||||
)
|
||||
// The same gap the viewer puts between its numbers and its code, so switching between
|
||||
// reading and editing does not move the text sideways under the reader.
|
||||
Spacer(Modifier.width(GUTTER_GAP))
|
||||
Box(Modifier.horizontalScroll(scroll)) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = style,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
visualTransformation = transformation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do about a file that changed on the machine while it was open here.
|
||||
*
|
||||
* Three ways out rather than one, and each says what it costs, because there is no answer this app
|
||||
* can pick on somebody's behalf: an agent editing the same file is the ordinary case here, and both
|
||||
* versions are somebody's work.
|
||||
*/
|
||||
@Composable
|
||||
fun ConflictDialog(
|
||||
message: String,
|
||||
busy: Boolean,
|
||||
onOverwrite: () -> Unit,
|
||||
onReload: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
// The server's own sentence as the title, rather than a heading of this app's above it
|
||||
// saying the same thing twice: there is one statement of what happened and it comes from
|
||||
// the side that found out.
|
||||
title = { Text(message.replaceFirstChar { it.uppercase() }) },
|
||||
text = {
|
||||
Text(
|
||||
"Overwrite keeps what you typed and loses the other change. " +
|
||||
"Reload keeps the other change and loses what you typed. " +
|
||||
"Cancel leaves both alone and keeps you here."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onOverwrite, enabled = !busy) {
|
||||
Text(if (busy) "Saving..." else "Overwrite")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = onReload, enabled = !busy) { Text("Reload") }
|
||||
TextButton(onClick = onCancel, enabled = !busy) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Leaving an editor with edits in it, which is the one way to lose them by accident. */
|
||||
@Composable
|
||||
fun UnsavedDialog(onDiscard: () -> Unit, onCancel: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text("Leave without saving?") },
|
||||
text = {
|
||||
Text(
|
||||
"The edits you have made here will be lost. They have not been written to the machine."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDiscard) { Text("Discard") } },
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text("Keep editing") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
|
||||
/**
|
||||
* A file split into lines, with the highlighter's colours already worked out for each one.
|
||||
*
|
||||
* The pure half of the viewer, so it has a JVM unit test and so [of] can run off the main thread:
|
||||
* scanning a megabyte is work, and doing it inside a composable would do it on the drawing thread
|
||||
* and again on every recomposition.
|
||||
*
|
||||
* Why per line at all: the viewer is a `LazyColumn` of lines rather than one `Text`, because text
|
||||
* layout is linear in the text. That means each row needs *its* colours, and the scanner answers in
|
||||
* offsets into the whole file -- so the spans are bucketed here, once, in one pass.
|
||||
*/
|
||||
class FileLines
|
||||
private constructor(
|
||||
/** The text of each line, without its newline. */
|
||||
val lines: List<String>,
|
||||
/** Per line, the spans that fall in it, with offsets relative to that line's start. */
|
||||
private val spans: List<List<Span>>,
|
||||
/**
|
||||
* The longest line, in character columns -- what the viewer sizes every row to.
|
||||
*
|
||||
* Every row has to be the *same* width or they scroll sideways by different amounts; see
|
||||
* [FileViewer]. Columns rather than measured pixels because the face is monospace, so one
|
||||
* number and one character's advance give the width of the widest line without measuring twenty
|
||||
* thousand strings.
|
||||
*/
|
||||
val columns: Int,
|
||||
) {
|
||||
val size: Int
|
||||
get() = lines.size
|
||||
|
||||
/**
|
||||
* One line, coloured. Built when the row is composed rather than up front: a file has far more
|
||||
* lines than a screen shows, and an `AnnotatedString` per line for all of them is the cost the
|
||||
* lazy list exists to avoid.
|
||||
*/
|
||||
fun line(index: Int): AnnotatedString {
|
||||
val text = lines[index]
|
||||
val here = spans[index]
|
||||
if (here.isEmpty()) return AnnotatedString(text)
|
||||
val palette = catppuccinSyntax()
|
||||
return buildAnnotatedString {
|
||||
append(text)
|
||||
here.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* [text] scanned as [language] and cut into lines.
|
||||
*
|
||||
* Exactly one trailing newline is dropped before splitting, so a file that ends the way
|
||||
* text files are supposed to end has the number of lines its author would count -- `wc -l`
|
||||
* agrees. Without that, every well-formed file gained a phantom empty last line. An empty
|
||||
* file is one empty line numbered 1, which is what it is.
|
||||
*/
|
||||
fun of(text: String, language: Language?): FileLines =
|
||||
// Timed, and always, for the reason everything else here is: the cost of opening a
|
||||
// large file is the number that decides whether the server's size limit is right, and
|
||||
// an instrument that is only in the build nobody is running answers nothing.
|
||||
DebugStats.timed("file scanned and cut into lines") {
|
||||
val body = text.removeSuffix("\n")
|
||||
val lines = body.split('\n')
|
||||
val scanned = if (language == null) emptyList() else spansOf(body, language)
|
||||
FileLines(lines, bucket(lines, scanned), lines.maxOf(::columnsOf))
|
||||
}
|
||||
|
||||
/**
|
||||
* How many columns a line occupies.
|
||||
*
|
||||
* A tab counts as eight rather than one, and deliberately upwards: this decides how far the
|
||||
* viewer can scroll, and over-estimating leaves a little empty space past the longest line
|
||||
* where under-estimating makes the end of that line unreachable.
|
||||
*/
|
||||
private fun columnsOf(line: String): Int {
|
||||
var count = 0
|
||||
for (character in line) count += if (character == '\t') 8 else 1
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* The scanner's spans, in file offsets, as spans per line in line offsets.
|
||||
*
|
||||
* One walk down both lists, which is what the scanner's guarantee buys: its spans come out
|
||||
* ordered, non-overlapping and inside the text. A span crossing a line break is cut at each
|
||||
* break and appears in each line it covers, because a row is drawn on its own and cannot
|
||||
* inherit a colour from the row above.
|
||||
*/
|
||||
private fun bucket(lines: List<String>, spans: List<Span>): List<List<Span>> {
|
||||
val out = ArrayList<List<Span>>(lines.size)
|
||||
var lineStart = 0
|
||||
var next = 0
|
||||
for (line in lines) {
|
||||
val lineEnd = lineStart + line.length
|
||||
var here: ArrayList<Span>? = null
|
||||
// Spans that ended before this line begins are behind the walk for good.
|
||||
while (next < spans.size && spans[next].end <= lineStart) next++
|
||||
var at = next
|
||||
while (at < spans.size && spans[at].start < lineEnd) {
|
||||
val span = spans[at]
|
||||
val start = maxOf(span.start, lineStart) - lineStart
|
||||
val end = minOf(span.end, lineEnd) - lineStart
|
||||
if (end > start) {
|
||||
(here ?: ArrayList<Span>().also { here = it }).add(
|
||||
Span(start, end, span.kind)
|
||||
)
|
||||
}
|
||||
at++
|
||||
}
|
||||
out.add(here ?: emptyList())
|
||||
// The newline itself, which is in the text and not in any line.
|
||||
lineStart = lineEnd + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.overscroll
|
||||
import androidx.compose.foundation.rememberOverscrollEffect
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** The face every verbatim thing in this app is drawn in, and the one the gutter has to match. */
|
||||
@Composable
|
||||
fun codeStyle(): TextStyle =
|
||||
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
|
||||
|
||||
/**
|
||||
* [content] scanned off the main thread, then drawn.
|
||||
*
|
||||
* Measured on the emulator 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file (28,660
|
||||
* lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was first
|
||||
* written, that is 460ms of frozen screen at the size the server is willing to send -- long enough
|
||||
* that the accessibility tree cannot be read, which is what "the app has stopped" looks like.
|
||||
*
|
||||
* Keyed on the text and the language, so re-reading the same file does not rescan it.
|
||||
*/
|
||||
@Composable
|
||||
fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) {
|
||||
var lines by remember(content, language) { mutableStateOf<FileLines?>(null) }
|
||||
LaunchedEffect(content, language) {
|
||||
lines = withContext(Dispatchers.Default) { FileLines.of(content, language) }
|
||||
}
|
||||
when (val ready = lines) {
|
||||
null -> CircularProgressIndicator(Modifier.padding(8.dp))
|
||||
else -> FileViewer(ready, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A file, one line per row, coloured by the same scanner that colours a reply's code fences.
|
||||
*
|
||||
* A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a
|
||||
* twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. The cost is
|
||||
* that each row needs its own colours, which is what [FileLines] works out once and off this
|
||||
* thread.
|
||||
*
|
||||
* Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a
|
||||
* block and a long line does not silently become three -- which would put the gutter's numbers
|
||||
* against the wrong text.
|
||||
*
|
||||
* **Every row is given the same content width**, and that is what makes the shared scroll state
|
||||
* behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset
|
||||
* into *its own* range -- `content width - viewport` -- so with rows of their natural widths a
|
||||
* short line's range is zero and it never moves while a long one beside it does. Each row also
|
||||
* writes `maxValue` as it measures, so how far the file could be dragged was decided by whichever
|
||||
* row measured last. Both disappear once every row is [FileLines.columns] wide. Reported by Iris on
|
||||
* 2026-09-04 as "it seems to affect different rows differently", which is what a per-row range
|
||||
* looks like.
|
||||
*
|
||||
* The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box
|
||||
* around the list rather than by each row -- `horizontalScroll` makes its own per node otherwise,
|
||||
* so only the line under the finger stretched. Only possible because every row now has the same
|
||||
* range.
|
||||
*
|
||||
* The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the
|
||||
* numbers out of both effects. The rows leave a spacer and [LineGutter] draws them there; its width
|
||||
* is measured from the digit count of the line count in the style it is drawn in.
|
||||
*
|
||||
* Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and
|
||||
* copying it gives the code rather than the code with a number in front of every line.
|
||||
*/
|
||||
@Composable
|
||||
fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
|
||||
val style = codeStyle()
|
||||
val scroll = rememberScrollState()
|
||||
val overscroll = rememberOverscrollEffect()
|
||||
val rows = rememberLazyListState()
|
||||
val gutter = gutterWidth(lines.size, style)
|
||||
val content = contentWidth(lines.columns, style)
|
||||
Box(modifier.fillMaxSize()) {
|
||||
// One container around the whole file rather than one per line, so a selection can run
|
||||
// across lines -- the same arrangement the transcript uses.
|
||||
SelectionContainer {
|
||||
// The stretch is drawn here, once, over everything this box holds; the rows below only
|
||||
// feed it. `clipToBounds` because a stretch draws outside the box it came from.
|
||||
Box(Modifier.fillMaxSize().clipToBounds().overscroll(overscroll)) {
|
||||
LazyColumn(state = rows, modifier = Modifier.fillMaxSize()) {
|
||||
items(lines.size) { index ->
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
// Where the numbers go, drawn from outside this box.
|
||||
Spacer(Modifier.width(gutter + GUTTER_GAP))
|
||||
Text(
|
||||
lines.line(index),
|
||||
style = style,
|
||||
softWrap = false,
|
||||
// The scroll outside the width: the scrolling node's viewport is
|
||||
// what the row has room for, and its content is the whole file's
|
||||
// widest line. The shared effect is given to every row and rendered
|
||||
// by none of them -- see the box above.
|
||||
modifier =
|
||||
Modifier.horizontalScroll(scroll, overscroll).width(content),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LineGutter(rows, gutter, style)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The line numbers, drawn beside the file rather than in it.
|
||||
*
|
||||
* They have to be outside the box the stretch is rendered on, or they bend with the text; and they
|
||||
* have to stay exactly level with the lines they number. Those two pull in opposite directions.
|
||||
*
|
||||
* A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come
|
||||
* from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during
|
||||
* measurement, so this composes from the answer the list has just produced rather than one it read
|
||||
* a frame ago. A `Column` translated by the scroll position could not: the translation would be
|
||||
* current while the set of numbers was a composition behind, so during a fling the numbers would
|
||||
* slide against their lines.
|
||||
*
|
||||
* The list is measured before this is -- they are siblings in a `Box` and it is declared first.
|
||||
*
|
||||
* `onSurfaceVariant`, because a number is not part of the file. The background is painted because
|
||||
* the stretch can carry the text sideways under this column, and a digit with a smear of code
|
||||
* behind it reads as a rendering fault.
|
||||
*/
|
||||
@Composable
|
||||
private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) {
|
||||
val colour = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val surface = rawSurface
|
||||
SubcomposeLayout(Modifier.fillMaxHeight().width(width).background(surface).clipToBounds()) {
|
||||
constraints ->
|
||||
val visible = rows.layoutInfo.visibleItemsInfo
|
||||
val numbers = visible.map { item ->
|
||||
subcompose(item.index) {
|
||||
Text(
|
||||
(item.index + 1).toString(),
|
||||
style = style,
|
||||
color = colour,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
.first()
|
||||
.measure(Constraints.fixedWidth(constraints.maxWidth))
|
||||
}
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
numbers.forEachIndexed { index, number -> number.place(0, visible[index].offset) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How wide the widest line number is, measured rather than guessed.
|
||||
*
|
||||
* `9` repeated, because digits in a monospace face are all one width -- what matters is how many
|
||||
* there are. Measuring in the style the numbers are drawn in is what makes this survive a font
|
||||
* size, a density or a display scale nobody here chose.
|
||||
*/
|
||||
@Composable
|
||||
fun gutterWidth(lineCount: Int, style: TextStyle): Dp {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val digits = maxOf(1, lineCount.toString().length)
|
||||
return remember(digits, style, density) {
|
||||
with(density) {
|
||||
measurer.measure(AnnotatedString("9".repeat(digits)), style).size.width.toDp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How wide to make every row: the widest line in the file, in this style.
|
||||
*
|
||||
* One character measured rather than the line itself, because the face is monospace and measuring
|
||||
* the actual widest line of a twenty-thousand-line file is work for an answer arithmetic already
|
||||
* has. Sixty-four of them, divided, so the answer does not carry a whole character's worth of
|
||||
* rounding.
|
||||
*
|
||||
* Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary
|
||||
* one: a minified file is a single line of a hundred thousand characters, and laying that out as
|
||||
* one row is a crash rather than a slow scroll. Past the cap the far end of such a line cannot be
|
||||
* reached, which is the tolerable half of that trade.
|
||||
*/
|
||||
@Composable
|
||||
private fun contentWidth(columns: Int, style: TextStyle): Dp {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
return remember(columns, style, density) {
|
||||
val advance = measurer.measure(AnnotatedString("0".repeat(64)), style).size.width / 64f
|
||||
with(density) { (columns * advance).coerceAtMost(MAX_CONTENT_PX).toDp() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The widest a row may be laid out, in pixels. Well under what `Constraints` can carry, and far
|
||||
* past any line anybody reads.
|
||||
*/
|
||||
private const val MAX_CONTENT_PX = 100_000f
|
||||
|
||||
/**
|
||||
* The space between the numbers and the code. A gap, not an alignment: the two are already aligned
|
||||
* by the row, and this is only so the digits and the first character are not touching.
|
||||
*/
|
||||
val GUTTER_GAP = 8.dp
|
||||
@@ -0,0 +1,688 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Which machine's files to show, and where to start.
|
||||
*
|
||||
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says
|
||||
* where it was working. That is what makes a second way in -- from the setups tab -- one more
|
||||
* caller rather than any new code here.
|
||||
*/
|
||||
data class FilesTarget(val setup: String, val setupName: String, val start: String)
|
||||
|
||||
/** Where the explorer is: in a directory, or in one file. */
|
||||
private sealed class Spot(val path: String) {
|
||||
class Dir(path: String) : Spot(path)
|
||||
|
||||
class Doc(path: String) : Spot(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* The files on the machine a session runs on: browse them, read one, change one.
|
||||
*
|
||||
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
|
||||
* flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
|
||||
* viewer, viewer to the directory it came from, directory to the one above -- and only closes from
|
||||
* where it opened.
|
||||
*
|
||||
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is
|
||||
* how one gets asked again on purpose, and creating something refetches the directory it was
|
||||
* created in.
|
||||
*/
|
||||
@Composable
|
||||
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
|
||||
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
||||
// because they are what back has to know about -- and back arrives from two places, the arrow
|
||||
// and the platform's own gesture, which must mean the same thing.
|
||||
var editing by remember { mutableStateOf(false) }
|
||||
var dirty by remember { mutableStateOf(false) }
|
||||
var askUnsaved by remember { mutableStateOf(false) }
|
||||
|
||||
val here = stack.last()
|
||||
|
||||
fun go(spot: Spot) {
|
||||
editing = false
|
||||
dirty = false
|
||||
stack = stack + spot
|
||||
}
|
||||
|
||||
fun back() {
|
||||
when {
|
||||
editing && dirty -> askUnsaved = true
|
||||
editing -> editing = false
|
||||
stack.size > 1 -> {
|
||||
stack = stack.dropLast(1)
|
||||
editing = false
|
||||
dirty = false
|
||||
}
|
||||
else -> onClose()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun load(path: String, again: Boolean) {
|
||||
if (!again && listings[path] is LoadState.Loaded) return
|
||||
listings[path] = LoadState.Loading
|
||||
listings[path] =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
LoadState.Loaded(fetchDir(settings, target.setup, path))
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(onBack = ::back)
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
// The session under this deliberately takes no keyboard inset, so the explorer adds its
|
||||
// own -- otherwise the editor types under the keyboard.
|
||||
.imePadding()
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
when (val spot = here) {
|
||||
is Spot.Dir -> {
|
||||
val state = listings[spot.path] ?: LoadState.Loading
|
||||
// The resolved path once there is one: a directory opened as `~` is called what
|
||||
// it turned out to be, not what it was asked for.
|
||||
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
|
||||
FilesHeader(
|
||||
title = baseName(at),
|
||||
path = at,
|
||||
machine = target.setupName,
|
||||
onBack = ::back,
|
||||
) {
|
||||
GlyphButton(
|
||||
REFRESH_GLYPH,
|
||||
"Refresh this directory",
|
||||
{ scope.launch { load(spot.path, again = true) } },
|
||||
enabled = state !is LoadState.Loading,
|
||||
)
|
||||
GlyphButton(
|
||||
PLUS_GLYPH,
|
||||
"Create here",
|
||||
{ creating = true },
|
||||
enabled = state is LoadState.Loaded,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(spot.path) { load(spot.path, again = false) }
|
||||
DirectoryBody(state, onOpen = ::go)
|
||||
}
|
||||
is Spot.Doc ->
|
||||
DocPane(
|
||||
settings = settings,
|
||||
target = target,
|
||||
path = spot.path,
|
||||
name = baseName(spot.path),
|
||||
editing = editing,
|
||||
onEditing = { editing = it },
|
||||
onDirty = { dirty = it },
|
||||
onBack = ::back,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (askUnsaved) {
|
||||
UnsavedDialog(
|
||||
onDiscard = {
|
||||
askUnsaved = false
|
||||
editing = false
|
||||
dirty = false
|
||||
},
|
||||
onCancel = { askUnsaved = false },
|
||||
)
|
||||
}
|
||||
|
||||
val dir = here as? Spot.Dir
|
||||
val listing = (listings[dir?.path] as? LoadState.Loaded)?.value
|
||||
if (creating && dir != null && listing != null) {
|
||||
CreateDialog(
|
||||
settings = settings,
|
||||
setup = target.setup,
|
||||
directory = listing.path,
|
||||
onDismiss = { creating = false },
|
||||
onCreated = { path, isDirectory ->
|
||||
creating = false
|
||||
scope.launch {
|
||||
// The directory it was created in is the one thing that changed, so that is
|
||||
// what gets asked again -- not the whole stack.
|
||||
load(dir.path, again = true)
|
||||
// A new file has nothing to look at, so it opens where it can be filled in.
|
||||
if (!isDirectory) {
|
||||
go(Spot.Doc(path))
|
||||
editing = true
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The row every view in here has at the top: back, what this is, and what acts on it.
|
||||
*
|
||||
* The path is truncated in the middle when it will not fit, because both ends carry something the
|
||||
* reader needs -- the machine and the top of the tree at one end, the file at the other -- and it
|
||||
* is the longest paths, the ones being read most closely, that get cut.
|
||||
*/
|
||||
@Composable
|
||||
private fun FilesHeader(
|
||||
title: String,
|
||||
path: String,
|
||||
machine: String,
|
||||
onBack: () -> Unit,
|
||||
actions: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
) {
|
||||
GlyphButton(BACK_GLYPH, "Back", onBack)
|
||||
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
|
||||
Text(
|
||||
"$machine · $path",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
}
|
||||
Row { actions() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What is in a directory.
|
||||
*
|
||||
* A listing that failed says why, in the machine's own words, where the rows would be -- never an
|
||||
* empty list, which is what "there is nothing here" looks like and is the one wrong answer that
|
||||
* looks like a right one.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
|
||||
when (state) {
|
||||
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
|
||||
is LoadState.Error ->
|
||||
Text(
|
||||
state.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
is LoadState.Loaded -> {
|
||||
val listing = state.value
|
||||
val sorted = remember(listing) { sortForDisplay(listing.entries) }
|
||||
LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
|
||||
parentOf(listing.path)?.let { parent ->
|
||||
item("..") {
|
||||
EntryRow(
|
||||
glyph = FOLDER_GLYPH,
|
||||
name = "..",
|
||||
trailing = null,
|
||||
onClick = { onOpen(Spot.Dir(parent)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (sorted.isEmpty()) {
|
||||
item("empty") {
|
||||
Text(
|
||||
"Nothing here",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
uniqueItems(sorted, key = { it.name }) { entry ->
|
||||
val path = join(listing.path, entry.name)
|
||||
EntryRow(
|
||||
glyph = if (entry.isDirectory) FOLDER_GLYPH else FILE_GLYPH,
|
||||
name = entry.name,
|
||||
trailing = trailingOf(entry),
|
||||
onClick = {
|
||||
onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a row says after the name, or nothing.
|
||||
*
|
||||
* A symlink says so instead of giving a size, because the size a listing reports for one is the
|
||||
* length of the path it points at -- a number that looks exactly like a file size and is about
|
||||
* something else. `other` covers a fifo, a device, and a link whose target is gone: the row still
|
||||
* appears, because a directory that hid what it held would be lying about being empty.
|
||||
*/
|
||||
private fun trailingOf(entry: DirEntry): String? =
|
||||
when {
|
||||
entry.link -> "link"
|
||||
entry.isDirectory -> null
|
||||
entry.kind == "file" -> humanSize(entry.size) ?: "0 B"
|
||||
else -> "other"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Glyph(glyph, colour = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
trailing?.let {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One file: read, and edited behind the pencil.
|
||||
*
|
||||
* Its own composable so that everything about one file -- what came back, what has been typed, and
|
||||
* whether a save is out -- is remembered under that file's path and thrown away when the reader
|
||||
* moves to another. What is *not* here is edit mode itself: back has to know about it.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.DocPane(
|
||||
settings: ServerSettings,
|
||||
target: FilesTarget,
|
||||
path: String,
|
||||
name: String,
|
||||
editing: Boolean,
|
||||
onEditing: (Boolean) -> Unit,
|
||||
onDirty: (Boolean) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember(path) { mutableStateOf<LoadState<FileContent>>(LoadState.Loading) }
|
||||
var draft by remember(path) { mutableStateOf(TextFieldValue()) }
|
||||
var saving by remember(path) { mutableStateOf(false) }
|
||||
var saveError by remember(path) { mutableStateOf<String?>(null) }
|
||||
var conflict by remember(path) { mutableStateOf<String?>(null) }
|
||||
// The editor's own vertical scroll, hoisted so the gutter and the text move together: they are
|
||||
// two composables in one row, and a scroll inside either would leave the other behind.
|
||||
val editScroll = rememberScrollState()
|
||||
val language = remember(name) { fileLanguage(name) }
|
||||
val loaded = (state as? LoadState.Loaded)?.value as? FileContent.Text
|
||||
// Readable but not editable: see [EDIT_LIMIT]. The size is the one the machine reported, so
|
||||
// this is decided before anything is typed rather than discovered by a keyboard that stops
|
||||
// answering.
|
||||
val editable = loaded != null && loaded.size <= EDIT_LIMIT
|
||||
|
||||
suspend fun fetch() {
|
||||
state = LoadState.Loading
|
||||
state =
|
||||
try {
|
||||
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
|
||||
if (got is FileContent.Text) draft = TextFieldValue(got.content)
|
||||
LoadState.Loaded(got)
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
onDirty(false)
|
||||
}
|
||||
|
||||
LaunchedEffect(path) { fetch() }
|
||||
|
||||
val changed = loaded != null && draft.text != loaded.content
|
||||
LaunchedEffect(changed) { onDirty(changed) }
|
||||
|
||||
/** Writes the draft back, [against] being the digest it is allowed to replace. */
|
||||
fun save(against: String) {
|
||||
if (saving) return
|
||||
saving = true
|
||||
saveError = null
|
||||
scope.launch {
|
||||
try {
|
||||
val written =
|
||||
withContext(Dispatchers.IO) {
|
||||
writeFile(settings, target.setup, path, draft.text, against)
|
||||
}
|
||||
state =
|
||||
LoadState.Loaded(
|
||||
FileContent.Text(
|
||||
path,
|
||||
written.size,
|
||||
written.modified,
|
||||
written.sha256,
|
||||
draft.text,
|
||||
)
|
||||
)
|
||||
conflict = null
|
||||
onDirty(false)
|
||||
onEditing(false)
|
||||
} catch (e: ApiException) {
|
||||
// The one refusal that is a question rather than a message: somebody else's edit is
|
||||
// on the machine, and which of the two survives is not this app's to decide.
|
||||
if (e.status == 409) conflict = e.message ?: "It changed on the machine."
|
||||
else saveError = e.message
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
|
||||
if (editing) {
|
||||
if (saving) {
|
||||
GlyphSpinner("Saving")
|
||||
} else {
|
||||
GlyphButton(
|
||||
SAVE_GLYPH,
|
||||
"Save",
|
||||
{ loaded?.let { save(it.sha256) } },
|
||||
// Disabled rather than hidden while there is nothing to write: a button that
|
||||
// comes and goes makes its own absence the signal.
|
||||
enabled = changed,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
GlyphButton(
|
||||
REFRESH_GLYPH,
|
||||
"Read this file again",
|
||||
{ scope.launch { fetch() } },
|
||||
enabled = state !is LoadState.Loading,
|
||||
)
|
||||
GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = editable)
|
||||
}
|
||||
}
|
||||
|
||||
saveError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it
|
||||
// is disabled -- and a reader who cannot edit a file they can plainly read will otherwise
|
||||
// conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button
|
||||
// never gets.
|
||||
if (loaded != null && !editable) {
|
||||
Text(
|
||||
"Too big to edit here (${humanSize(loaded.size)}; the limit is " +
|
||||
"${humanSize(EDIT_LIMIT)}). A text field this large stops answering the keyboard.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Box(Modifier.weight(1f).fillMaxWidth().background(rawSurface).padding(horizontal = 8.dp)) {
|
||||
when (val current = state) {
|
||||
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp))
|
||||
is LoadState.Error ->
|
||||
Text(
|
||||
current.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
is LoadState.Loaded ->
|
||||
when (val file = current.value) {
|
||||
is FileContent.Text ->
|
||||
if (editing) {
|
||||
FileEditor(
|
||||
draft,
|
||||
{ draft = it },
|
||||
language,
|
||||
Modifier.verticalScroll(editScroll),
|
||||
)
|
||||
} else {
|
||||
ScannedFile(file.content, language)
|
||||
}
|
||||
// Said in words, with the measurement that makes it make sense. Neither of
|
||||
// these is an empty file and neither is an error, so neither may look like one.
|
||||
is FileContent.Binary ->
|
||||
Note(
|
||||
"This is not text (${humanSize(file.size) ?: "0 B"}), so there is nothing to show."
|
||||
)
|
||||
is FileContent.TooBig ->
|
||||
Note(
|
||||
"This file is ${humanSize(file.size)}, which is more than the server will " +
|
||||
"send. Nothing was read, so nothing here is a sample of it."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conflict?.let { message ->
|
||||
ConflictDialog(
|
||||
message = message,
|
||||
busy = saving,
|
||||
onOverwrite = {
|
||||
// Re-read only to learn what it hashes to *now*, which is the digest an overwrite
|
||||
// has to be allowed against. The content is deliberately thrown away: overwriting
|
||||
// is the choice to lose it.
|
||||
scope.launch {
|
||||
val fresh =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
|
||||
} catch (e: ApiException) {
|
||||
saveError = e.message
|
||||
conflict = null
|
||||
return@launch
|
||||
}
|
||||
if (fresh is FileContent.Text) save(fresh.sha256)
|
||||
else {
|
||||
saveError =
|
||||
"It is no longer a text file, so this app will not write over it."
|
||||
conflict = null
|
||||
}
|
||||
}
|
||||
},
|
||||
onReload = {
|
||||
conflict = null
|
||||
scope.launch { fetch() }
|
||||
},
|
||||
onCancel = { conflict = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A sentence where the file's content would be, for the two states that have no content. */
|
||||
@Composable
|
||||
private fun Note(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Naming one thing in the directory that is open.
|
||||
*
|
||||
* A name and a switch, not a name and a body: the editor is where content is typed, and a modal
|
||||
* with a text area in it is a second editor to keep in step with the first. A created file opens
|
||||
* straight into edit mode, because an empty file is not something to look at.
|
||||
*/
|
||||
@Composable
|
||||
private fun CreateDialog(
|
||||
settings: ServerSettings,
|
||||
setup: String,
|
||||
directory: String,
|
||||
onDismiss: () -> Unit,
|
||||
onCreated: (String, Boolean) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by remember { mutableStateOf("") }
|
||||
var isDirectory by remember { mutableStateOf(false) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun create() {
|
||||
val chosen = name.trim()
|
||||
if (busy || chosen.isEmpty()) return
|
||||
busy = true
|
||||
error = null
|
||||
val path = join(directory, chosen)
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (isDirectory) createDir(settings, setup, path)
|
||||
else createFile(settings, setup, path)
|
||||
}
|
||||
onCreated(path, isDirectory)
|
||||
} catch (e: ApiException) {
|
||||
// Beside the button that caused it: this dialog is the only thing on screen that
|
||||
// knows something was being created, and the reason is usually the name itself.
|
||||
error = e.message
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Create in ${baseName(directory)}") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Directory", modifier = Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = isDirectory,
|
||||
onCheckedChange = { isDirectory = it },
|
||||
enabled = !busy,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"A name that is already taken is refused rather than replaced.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { create() }, enabled = !busy && name.isNotBlank()) {
|
||||
Text(if (busy) "Creating..." else "Create")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Directories first, then by name ignoring case, and stably.
|
||||
*
|
||||
* Sorted here rather than by the machine: presentation order is a display decision, and `find`
|
||||
* answers in whatever order the directory happens to be stored in. Dotfiles are not hidden -- in a
|
||||
* repository they are half of what matters.
|
||||
*/
|
||||
internal fun sortForDisplay(entries: List<DirEntry>): List<DirEntry> =
|
||||
entries.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))
|
||||
|
||||
/**
|
||||
* The directory above [path], or null at the root.
|
||||
*
|
||||
* A string operation on a path the *machine* resolved, which is what makes it safe: every listing
|
||||
* answers with its own `pwd -P`, so there is never a `..` or a symlink left in here to reason
|
||||
* about, and this app never has to resolve one.
|
||||
*/
|
||||
internal fun parentOf(path: String): String? {
|
||||
val trimmed = path.trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
val cut = trimmed.lastIndexOf('/')
|
||||
return when {
|
||||
cut < 0 -> null
|
||||
cut == 0 -> "/"
|
||||
else -> trimmed.substring(0, cut)
|
||||
}
|
||||
}
|
||||
|
||||
/** What a path names: its last segment, with `/` naming itself. */
|
||||
internal fun baseName(path: String): String {
|
||||
val trimmed = path.trimEnd('/')
|
||||
return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/')
|
||||
}
|
||||
|
||||
internal fun join(directory: String, name: String): String =
|
||||
if (directory.endsWith("/")) "$directory$name" else "$directory/$name"
|
||||
@@ -19,19 +19,15 @@ import androidx.compose.ui.platform.LocalContext
|
||||
* 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.
|
||||
* move it. If they are large, the work per row is the problem and it is ours to fix.
|
||||
*
|
||||
* The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds,
|
||||
* broken 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.
|
||||
* broken into the parts the UI thread is responsible for and the parts after it.
|
||||
*
|
||||
* One of these for the app, like [DebugStats], because the two are read as one report and
|
||||
* [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session
|
||||
* and the counters were not, so a report copied after visiting two sessions divided every session's
|
||||
* work by the newest one's frame count -- 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.
|
||||
* work by the newest one's frame count -- 36.8 seconds of placement inside a 13.5 second window.
|
||||
*/
|
||||
object FrameStats {
|
||||
private val total = ArrayList<Long>()
|
||||
@@ -54,7 +50,7 @@ object FrameStats {
|
||||
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.
|
||||
// part: the frame being held up by work that is not the frame's.
|
||||
waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)
|
||||
input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION)
|
||||
animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION)
|
||||
@@ -123,7 +119,7 @@ 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].
|
||||
* stretch of time as the counters beside it.
|
||||
*
|
||||
* The listener is handed its own thread because the platform calls it for every frame and the
|
||||
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
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 and a reply's fences, so the same code is the same colours wherever
|
||||
* it appears.
|
||||
*
|
||||
* Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it
|
||||
* off the drawing thread.
|
||||
*
|
||||
* The timing is the number the highlighter is judged by: the library this replaced took **174ms**
|
||||
* on the emulator for a two-hundred-line Kotlin fence, which is why [ParsedReplies.highlighted]
|
||||
* caches the answer rather than a `remember` inside the fence recomputing it on every scroll back.
|
||||
*/
|
||||
fun highlight(code: String, language: Language?): AnnotatedString {
|
||||
if (language == null) return AnnotatedString(code)
|
||||
val spans = DebugStats.timed("code highlighted") { spansOf(code, language) }
|
||||
val palette = catppuccinSyntax()
|
||||
return buildAnnotatedString {
|
||||
append(code)
|
||||
spans.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [code] read once, left to right, into the spans that carry a colour.
|
||||
*
|
||||
* One pass with a small state -- in a comment, in a string, or in ordinary code -- rather than a
|
||||
* locator per token kind over the whole text, which is what the library did and is why it found
|
||||
* comments before it knew the language: a `#` inside a shell string, a `//` inside a URL and a
|
||||
* block-comment opener inside a shell glob each commented out the rest of a line that was nothing
|
||||
* of the sort.
|
||||
*
|
||||
* Every span is produced by advancing an index forward, so the result is ordered, non-overlapping
|
||||
* and inside the code by construction. Nothing here throws: an unterminated string or comment runs
|
||||
* to the end of the code, which is also what it looks like while a fence is still being written.
|
||||
*
|
||||
* In ordinary code the order of recognition is comment, string, attribute, number, word, and
|
||||
* finally a single punctuation or mark character, which are coloured only in ordinary code.
|
||||
*/
|
||||
fun scan(code: String, rules: Rules): List<Span> = Scanner(code, rules).run()
|
||||
|
||||
/** Characters coloured as punctuation, and as marks. Both sets are the ones the library used. */
|
||||
private const val PUNCTUATION = ",.:;"
|
||||
private const val MARKS = "()={}<>-+[]|&"
|
||||
|
||||
private class Scanner(private val code: String, private val rules: Rules) {
|
||||
private val spans = ArrayList<Span>()
|
||||
private var at = 0
|
||||
|
||||
fun run(): List<Span> {
|
||||
while (at < code.length) {
|
||||
// Every branch that answers true has advanced `at`, so this terminates.
|
||||
val consumed =
|
||||
blockComment() ||
|
||||
lineComment() ||
|
||||
rawString() ||
|
||||
characterOrLifetime() ||
|
||||
string() ||
|
||||
attribute() ||
|
||||
number() ||
|
||||
word() ||
|
||||
singleCharacter()
|
||||
if (!consumed) at++
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
private fun emit(start: Int, kind: Kind) {
|
||||
if (at > start) spans.add(Span(start, at, kind))
|
||||
}
|
||||
|
||||
private fun starts(token: String) = code.startsWith(token, at)
|
||||
|
||||
/** Whether a line comment token here opens one; see [Rules.lineCommentsAtWordStart]. */
|
||||
private fun atWordStart() = at == 0 || code[at - 1].isWhitespace() || code[at - 1] in ";|&("
|
||||
|
||||
/** Whether only whitespace stands between the start of this line and here. */
|
||||
private fun atLineStart(): Boolean {
|
||||
var back = at - 1
|
||||
while (back >= 0 && code[back] != '\n') {
|
||||
if (!code[back].isWhitespace()) return false
|
||||
back--
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun toEndOfLine() {
|
||||
while (at < code.length && code[at] != '\n') at++
|
||||
}
|
||||
|
||||
/** From an open bracket through the one that matches it, or to the end if none does. */
|
||||
private fun toMatchingBracket() {
|
||||
var depth = 0
|
||||
while (at < code.length) {
|
||||
when (code[at]) {
|
||||
'[' -> depth++
|
||||
']' -> depth--
|
||||
}
|
||||
at++
|
||||
if (depth == 0) return
|
||||
}
|
||||
}
|
||||
|
||||
private fun blockComment(): Boolean {
|
||||
val comment = rules.blockComment ?: return false
|
||||
if (!starts(comment.open)) return false
|
||||
val start = at
|
||||
at += comment.open.length
|
||||
var depth = 1
|
||||
while (at < code.length && depth > 0) {
|
||||
// The closer is tried first so that a language whose two delimiters are the same string
|
||||
// -- CoffeeScript's `###` -- closes rather than nesting forever.
|
||||
if (starts(comment.close)) {
|
||||
depth--
|
||||
at += comment.close.length
|
||||
} else if (comment.nests && starts(comment.open)) {
|
||||
depth++
|
||||
at += comment.open.length
|
||||
} else {
|
||||
at++
|
||||
}
|
||||
}
|
||||
emit(start, Kind.COMMENT)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun lineComment(): Boolean {
|
||||
if (rules.lineComments.none { starts(it) }) return false
|
||||
if (rules.lineCommentsAtWordStart && !atWordStart()) return false
|
||||
val start = at
|
||||
toEndOfLine()
|
||||
emit(start, Kind.COMMENT)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Rust and RON: `b`? `r` `#`* `"` … `"` `#`*, with no escapes inside. */
|
||||
private fun rawString(): Boolean {
|
||||
if (!rules.rawStrings) return false
|
||||
var ahead = at
|
||||
if (code.getOrNull(ahead) == 'b') ahead++
|
||||
if (code.getOrNull(ahead) != 'r') return false
|
||||
ahead++
|
||||
var hashes = 0
|
||||
while (code.getOrNull(ahead) == '#') {
|
||||
ahead++
|
||||
hashes++
|
||||
}
|
||||
if (code.getOrNull(ahead) != '"') return false
|
||||
val start = at
|
||||
val closer = "\"" + "#".repeat(hashes)
|
||||
val closed = code.indexOf(closer, ahead + 1)
|
||||
at = if (closed < 0) code.length else closed + closer.length
|
||||
emit(start, Kind.STRING)
|
||||
return true
|
||||
}
|
||||
|
||||
/** See [Rules.lifetimes]: an apostrophe that is not a character literal opens nothing. */
|
||||
private fun characterOrLifetime(): Boolean {
|
||||
if (!rules.lifetimes || code[at] != '\'') return false
|
||||
val next = code.getOrNull(at + 1) ?: return false
|
||||
if (next == '\\' || code.getOrNull(at + 2) == '\'') {
|
||||
quoted(Quote("'", "'", escapes = true))
|
||||
} else {
|
||||
at++
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun string(): Boolean {
|
||||
// Longest opener wins, so Kotlin's `"""` is one delimiter rather than an empty string
|
||||
// followed by a quote. A loop rather than filter/maxBy: this runs at every character of
|
||||
// ordinary code, and the pair of lists that would allocate is the whole cost of the scan.
|
||||
var quote: Quote? = null
|
||||
for (candidate in rules.quotes) {
|
||||
if (starts(candidate.open) && candidate.open.length > (quote?.open?.length ?: 0)) {
|
||||
quote = candidate
|
||||
}
|
||||
}
|
||||
quoted(quote ?: return false)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun quoted(quote: Quote) {
|
||||
val start = at
|
||||
at += quote.open.length
|
||||
while (at < code.length) {
|
||||
if (quote.escapes && code[at] == '\\' && at + 1 < code.length) {
|
||||
at += 2
|
||||
continue
|
||||
}
|
||||
if (starts(quote.close)) {
|
||||
at += quote.close.length
|
||||
break
|
||||
}
|
||||
at++
|
||||
}
|
||||
at = at.coerceAtMost(code.length)
|
||||
emit(start, Kind.STRING)
|
||||
}
|
||||
|
||||
private fun attribute(): Boolean {
|
||||
val start = at
|
||||
when (rules.attributes) {
|
||||
Attributes.NONE -> return false
|
||||
Attributes.AT_WORD -> {
|
||||
if (code[at] != '@' || !isWordStart(code.getOrNull(at + 1))) return false
|
||||
at++
|
||||
while (at < code.length && isWordPart(code[at])) at++
|
||||
}
|
||||
Attributes.HASH_BRACKET -> {
|
||||
if (code[at] != '#') return false
|
||||
var ahead = at + 1
|
||||
if (code.getOrNull(ahead) == '!') ahead++
|
||||
if (code.getOrNull(ahead) != '[') return false
|
||||
at = ahead
|
||||
toMatchingBracket()
|
||||
}
|
||||
Attributes.HASH_LINE -> {
|
||||
if (code[at] != '#' || !atLineStart()) return false
|
||||
toEndOfLine()
|
||||
}
|
||||
Attributes.LINE_BRACKET -> {
|
||||
if (code[at] != '[' || !atLineStart()) return false
|
||||
toMatchingBracket()
|
||||
}
|
||||
}
|
||||
emit(start, Kind.METADATA)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A number is a run starting with a digit and carrying on through letters, digits, `_` and `.`
|
||||
* -- which covers `0xFF`, `1_000`, `1u32` and `3.14` without a grammar for any of them.
|
||||
*/
|
||||
private fun number(): Boolean {
|
||||
if (!code[at].isDigit()) return false
|
||||
val start = at
|
||||
while (
|
||||
at < code.length && (code[at].isLetterOrDigit() || code[at] == '_' || code[at] == '.')
|
||||
) {
|
||||
at++
|
||||
}
|
||||
emit(start, Kind.LITERAL)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun word(): Boolean {
|
||||
if (!isWordStart(code[at])) return false
|
||||
val start = at
|
||||
while (at < code.length && isWordPart(code[at])) at++
|
||||
if (code.substring(start, at) in rules.keywords) emit(start, Kind.KEYWORD)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun singleCharacter(): Boolean {
|
||||
val kind =
|
||||
when (code[at]) {
|
||||
in PUNCTUATION -> Kind.PUNCTUATION
|
||||
in MARKS -> Kind.MARK
|
||||
else -> return false
|
||||
}
|
||||
at++
|
||||
emit(at - 1, kind)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun isWordStart(c: Char?) = c != null && (c.isLetter() || c == '_')
|
||||
|
||||
private fun isWordPart(c: Char) = c.isLetterOrDigit() || c == '_'
|
||||
@@ -49,11 +49,9 @@ 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.
|
||||
* Its own word rather than the operation's, because nothing has been done to this session yet, so a
|
||||
* batch stopped here leaves it exactly as it was. Marked from the moment the batch is handed over
|
||||
* all the same -- a queued row that still looked ordinary was still tappable.
|
||||
*/
|
||||
private const val WAITING = "waiting"
|
||||
|
||||
@@ -62,25 +60,22 @@ private const val WAITING = "waiting"
|
||||
*
|
||||
* 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.
|
||||
* this screen that means importing a session nobody chose.
|
||||
*
|
||||
* Swallowed silently rather than shown, because anything drawn on every row a batch passes would be
|
||||
* a flicker running down the list. 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.
|
||||
* a flicker running down the list.
|
||||
*/
|
||||
private const val SETTLE_MS = 500L
|
||||
|
||||
/**
|
||||
* Continuing a Claude Code session the machine already has.
|
||||
*
|
||||
* The list is the machine's answer, not this app's: 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.
|
||||
* The list is the machine's answer, not this app's. Choosing one sends its **id**, never a path, so
|
||||
* an enrolled phone cannot turn this screen into a file reader.
|
||||
*
|
||||
* Holding a row selects it and puts the screen in selection mode, where the options that act on a
|
||||
* selection appear along the bottom. That exists because these arrive in bulk — a machine
|
||||
* accumulates dozens of abandoned sessions — and one confirmation dialog per row is the reason
|
||||
* 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)
|
||||
@@ -91,28 +86,25 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
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.
|
||||
// What is happening to each row right now, as the word the row shows. A map keyed by id rather
|
||||
// than a flag per row, because the rows are rebuilt from whatever the server last said and this
|
||||
// belongs to the request rather than to the session.
|
||||
var running by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
// Which rows the reader has picked out. Empty means selection mode is off: 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.
|
||||
// Which rows the reader has picked out. Empty means selection mode is off: a selection mode
|
||||
// with nothing selected is a state with no controls in it and no way to leave except Back.
|
||||
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
// Failures that belong to one row rather than to the screen, shown on that row. A batch is
|
||||
// exactly where a single banner fails: nine deletes succeeded and one did not, and the
|
||||
// banner cannot say which.
|
||||
// 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.
|
||||
// Same default as the spawn screen: 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.
|
||||
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
|
||||
// so a tap reading it needs no recomposition.
|
||||
val movedAt = remember { mutableMapOf<String, Long>() }
|
||||
fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS
|
||||
|
||||
@@ -120,8 +112,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
* 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.
|
||||
* running, and this screen may be opening on work another phone started.
|
||||
*/
|
||||
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
|
||||
try {
|
||||
@@ -167,13 +158,11 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
* 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.
|
||||
* change stream, which is what lets this screen be left while a batch is still going.
|
||||
*
|
||||
* Marked [WAITING] rather than with the operation's own word until the server confirms. Between
|
||||
* the request leaving and the `started` event coming back, "we have asked" is the truth and "it
|
||||
* is importing" is a guess -- and the row is inert either way, which is the part that matters.
|
||||
* is importing" is a guess.
|
||||
*
|
||||
* The selection is dropped as the work is handed over, not when it finishes: the screen goes
|
||||
* back to how it started, and what says the work is happening is the rows it is happening to.
|
||||
@@ -186,17 +175,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
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.
|
||||
// only as atomic as the network, and what came back was some rows running and some
|
||||
// untouched -- indistinguishable, on the list, from rows nobody had picked.
|
||||
try {
|
||||
withContext(Dispatchers.IO) { send(ids) }
|
||||
} catch (err: Exception) {
|
||||
// The server never took it, so nothing is running and no event will arrive to say
|
||||
// so. This is the one failure the screen must report itself -- and it is now the
|
||||
// whole batch's failure, which is the point: no row was singled out.
|
||||
// so. This is the one failure the screen must report itself -- and it is the whole
|
||||
// batch's failure, which is the point: no row was singled out.
|
||||
running = running - ids.toSet()
|
||||
rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" }
|
||||
return@launch
|
||||
@@ -205,19 +191,17 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
// 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".
|
||||
// finished while it was still connecting is one nothing will ever be said about -- and
|
||||
// the row sits marked for ever. That is not hypothetical: with responses held back far
|
||||
// enough, one row of a pair of deletes cleared and the other stayed on "waiting".
|
||||
//
|
||||
// The listing is the repair, because it carries the same state the events do. Only
|
||||
// when something still looks outstanding, so the ordinary case -- 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.
|
||||
// The listing is the repair, because it carries the same state the events do. Only when
|
||||
// something still looks outstanding, so the ordinary case does not pay for a second
|
||||
// listing, which is the most expensive call this screen makes.
|
||||
if (setup != null && targets.any { running.containsKey(it.id) }) {
|
||||
// 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.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -225,13 +209,6 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
|
||||
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
|
||||
@@ -283,13 +260,12 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Live changes to what the server is doing to these sessions, for as long as this screen is up.
|
||||
// The listing already carried the same state when the screen opened -- this is what keeps it
|
||||
// current afterwards, including for work another phone started.
|
||||
//
|
||||
// Failures here are deliberately quiet. There is nothing for a reader to do about a dropped
|
||||
// event stream, and 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.
|
||||
// event stream, and every state it would have carried is in the next listing.
|
||||
val liveChanges = remember {
|
||||
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
|
||||
}
|
||||
@@ -307,8 +283,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
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.
|
||||
// transcript, an import made it a session.
|
||||
"finished" -> {
|
||||
running = running - change.session
|
||||
forget(change.session)
|
||||
@@ -323,17 +298,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
}
|
||||
}
|
||||
} 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.
|
||||
// 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.
|
||||
// Retried below; the listing is the truth in the meantime. Any failure, not
|
||||
// only an [ApiException]: a stream is an optimisation over the listing here,
|
||||
// and catching only the expected failure means an unexpected one closes the app
|
||||
// from a screen that is merely loading a list.
|
||||
} finally {
|
||||
stream.close()
|
||||
}
|
||||
@@ -351,9 +323,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
// 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.
|
||||
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
|
||||
// can still be scrolled to while it is up.
|
||||
var barHeight by remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
@@ -428,8 +399,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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()
|
||||
@@ -486,8 +457,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
* 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.
|
||||
* and an option that appears here has to work on every row in a selection.
|
||||
*/
|
||||
@Composable
|
||||
private fun SelectionBar(
|
||||
@@ -567,26 +537,23 @@ private fun ImportableList(
|
||||
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.
|
||||
// Off while something is happening to this row -- see
|
||||
// [BusyItem], which draws that but leaves the gestures
|
||||
// alone so the list still scrolls.
|
||||
enabled = running[session.id] == null,
|
||||
onClick = {
|
||||
if (settling(session.id)) return@combinedClickable
|
||||
// In selection mode a tap is a selection, so the
|
||||
// reader is never one mis-tap away from starting
|
||||
// a CLI they were only picking rows for.
|
||||
// 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.
|
||||
// Outside it, a tap continues the session -- except
|
||||
// on a row that cannot be continued, where it
|
||||
// selects instead. That row's only remaining action
|
||||
// is Delete, and a tap that did nothing at all
|
||||
// would be a worse answer. Two `--resume` processes
|
||||
// on one transcript each replay the other's writes,
|
||||
// which is why this must not simply try.
|
||||
if (selecting || session.inUse == "yes")
|
||||
onToggle(session)
|
||||
else onOpen(session)
|
||||
@@ -606,8 +573,7 @@ private fun ImportableList(
|
||||
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.
|
||||
// reflects it.
|
||||
Text(
|
||||
relativeTime(session.modified),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -616,12 +582,11 @@ private fun ImportableList(
|
||||
}
|
||||
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.
|
||||
// one long value with no natural break. Cut at the head,
|
||||
// because a path is identified by its tail and these all share
|
||||
// a long prefix. By the row's real width rather than a
|
||||
// character count, which was one guess for every font size and
|
||||
// screen.
|
||||
session.cwd
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { cwd ->
|
||||
@@ -648,8 +613,7 @@ private fun ImportableList(
|
||||
color = warningColor,
|
||||
)
|
||||
}
|
||||
// Reported where it happened, in the server's own words, the
|
||||
// way every other failure in this app is shown.
|
||||
// Reported where it happened, in the server's own words.
|
||||
errors[session.id]?.let { message ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
@@ -667,29 +631,20 @@ private fun ImportableList(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.
|
||||
// What continuing it costs, which is the question this list is really asked. Absent
|
||||
// rather than zero when nothing has been measured -- a session with no turns yet has no
|
||||
// figure, not a figure of none.
|
||||
session.contextTokens?.let { "${it / 1000}k context" },
|
||||
"${session.lines} lines",
|
||||
// Kept beside the context figure because the two disagree usefully: most of a large
|
||||
// transcript is history from before a compaction, which the model is no longer
|
||||
// given, so a big file can be cheap to continue and a small one expensive.
|
||||
// transcript is history from before a compaction, so a big file can be cheap to
|
||||
// continue.
|
||||
humanSize(session.bytes),
|
||||
)
|
||||
.joinToString(" · ")
|
||||
@@ -698,16 +653,14 @@ private fun statsOf(session: Importable): String =
|
||||
* 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.
|
||||
* and no shade distinguishes them.
|
||||
*/
|
||||
private fun warningOf(session: Importable): String? =
|
||||
when (session.inUse) {
|
||||
// What was measured is that a live process on that machine holds this session open. Which
|
||||
// process is not measured, so it isn't claimed: "a terminal — close it there first" sent
|
||||
// people looking for a window that need not exist. 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.
|
||||
// process is not measured, so it isn't claimed: "a terminal -- close it there first" sent
|
||||
// people looking for a window that need not exist. Naming a place the reader then can't
|
||||
// find turns a correct refusal into a wrong instruction.
|
||||
"yes" -> "something on that machine is running it"
|
||||
"unknown" -> "can't tell if it's open"
|
||||
else -> null
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
package com.example.aiapp
|
||||
|
||||
/**
|
||||
* A language the highlighter can colour.
|
||||
*
|
||||
* The names the reader writes after the backticks are aliases onto these; [fenceLanguage] holds
|
||||
* that table. A word with no entry there is null, and null is drawn plain, because a fence coloured
|
||||
* by another language's rules looks highlighted and is wrong in a way the reader cannot see.
|
||||
*
|
||||
* Nearly all of them are a row of [RULES], read by one shared scanner. [MARKDOWN] is the one that
|
||||
* is not; see [spansOf].
|
||||
*/
|
||||
enum class Language {
|
||||
C,
|
||||
COFFEESCRIPT,
|
||||
CPP,
|
||||
CSHARP,
|
||||
DART,
|
||||
FISH,
|
||||
GO,
|
||||
JAVA,
|
||||
JAVASCRIPT,
|
||||
JSON,
|
||||
KOTLIN,
|
||||
MARKDOWN,
|
||||
PERL,
|
||||
PHP,
|
||||
PYTHON,
|
||||
RON,
|
||||
RUBY,
|
||||
RUST,
|
||||
SHELL,
|
||||
SWIFT,
|
||||
TOML,
|
||||
TYPESCRIPT,
|
||||
}
|
||||
|
||||
/**
|
||||
* What [scan] needs to know about one language -- data, not code, so that adding a language is a
|
||||
* row in [RULES] rather than a branch anywhere.
|
||||
*
|
||||
* The two forms that could not be expressed as data are flags here and a few lines in the scanner:
|
||||
* [rawStrings], because the closing delimiter depends on how many hashes the opener had, and
|
||||
* [lifetimes], because whether `'` opens anything at all depends on what follows it.
|
||||
*/
|
||||
data class Rules(
|
||||
/** Words drawn as keywords. Only plain words; the scanner cannot reach anything else. */
|
||||
val keywords: Set<String>,
|
||||
/** Tokens that open a comment running to the end of the line. */
|
||||
val lineComments: List<String> = emptyList(),
|
||||
/**
|
||||
* Whether [lineComments] count only at the start of a word. The shells need it: `$#`, `${#x}`
|
||||
* and `a#b` are not comments, and greying the rest of those lines is one of the mistakes this
|
||||
* scanner exists to stop.
|
||||
*/
|
||||
val lineCommentsAtWordStart: Boolean = false,
|
||||
val blockComment: BlockComment? = null,
|
||||
/** The string forms. The longest opener that matches wins, so `"""` is tried before `"`. */
|
||||
val quotes: List<Quote> = emptyList(),
|
||||
val attributes: Attributes = Attributes.NONE,
|
||||
/** Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes. */
|
||||
val rawStrings: Boolean = false,
|
||||
/**
|
||||
* Rust: `'` opens a character literal only when a backslash or one character and a `'` follow.
|
||||
* Otherwise it is a lifetime or a label -- without this, `'a` opens a string that runs to the
|
||||
* next apostrophe in the block.
|
||||
*/
|
||||
val lifetimes: Boolean = false,
|
||||
)
|
||||
|
||||
data class BlockComment(val open: String, val close: String, val nests: Boolean)
|
||||
|
||||
/** One string form. [escapes] is whether a backslash escapes the closer (and itself). */
|
||||
data class Quote(val open: String, val close: String, val escapes: Boolean)
|
||||
|
||||
/** What opens a metadata span, of the shapes that exist across these languages. */
|
||||
enum class Attributes {
|
||||
NONE,
|
||||
/** `@` and a word: Kotlin and Java annotations, Python decorators. */
|
||||
AT_WORD,
|
||||
/** `#[` or `#![` through the matching `]`: Rust and RON attributes. */
|
||||
HASH_BRACKET,
|
||||
/** `#` at the start of a line, to the end of it: the C preprocessor. */
|
||||
HASH_LINE,
|
||||
/** `[` at the start of a line through the matching `]`: a TOML table header. */
|
||||
LINE_BRACKET,
|
||||
}
|
||||
|
||||
/**
|
||||
* The spans [language] colours in [code] -- the one way to ask, whatever the language turns out to
|
||||
* be made of.
|
||||
*
|
||||
* Nearly every language here is tokens, which is a row of [RULES] and the one shared scanner.
|
||||
* Markdown has none of those, and what a character means there depends on where on the line it
|
||||
* sits, so it brings a scanner of its own. That is the whole extension point -- a new language is a
|
||||
* row of rules or an entry in [SCANNERS], and no caller learns which one it got.
|
||||
*/
|
||||
fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(language)(code)
|
||||
|
||||
// Lazy for the same reason [RULES] is, since it reads it.
|
||||
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
|
||||
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
|
||||
mapOf(Language.MARKDOWN to ::scanMarkdown)
|
||||
}
|
||||
|
||||
private val C_STYLE = BlockComment("/*", "*/", nests = false)
|
||||
private val NESTING = BlockComment("/*", "*/", nests = true)
|
||||
|
||||
private val DOUBLE = Quote("\"", "\"", escapes = true)
|
||||
private val SINGLE = Quote("'", "'", escapes = true)
|
||||
private val TRIPLE_DOUBLE = Quote("\"\"\"", "\"\"\"", escapes = true)
|
||||
private val TRIPLE_SINGLE = Quote("'''", "'''", escapes = true)
|
||||
|
||||
// Lazy because the keyword sets below are top-level properties too, and a file's properties
|
||||
// initialize in the order they are written: read eagerly here, every set would be null.
|
||||
private val RULES: Map<Language, Rules> by lazy {
|
||||
mapOf(
|
||||
Language.C to
|
||||
Rules(
|
||||
keywords = KEYWORDS_C,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
attributes = Attributes.HASH_LINE,
|
||||
),
|
||||
Language.CPP to
|
||||
Rules(
|
||||
keywords = KEYWORDS_CPP,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
attributes = Attributes.HASH_LINE,
|
||||
),
|
||||
Language.CSHARP to
|
||||
Rules(
|
||||
keywords = KEYWORDS_CSHARP,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
),
|
||||
// `###` opens and closes a block comment and `#` opens a line one, which is why the scanner
|
||||
// tries the block opener first.
|
||||
Language.COFFEESCRIPT to
|
||||
Rules(
|
||||
keywords = KEYWORDS_COFFEESCRIPT,
|
||||
lineComments = listOf("#"),
|
||||
blockComment = BlockComment("###", "###", nests = false),
|
||||
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
|
||||
),
|
||||
Language.DART to
|
||||
Rules(
|
||||
keywords = KEYWORDS_DART,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = NESTING,
|
||||
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
Language.FISH to
|
||||
Rules(
|
||||
keywords = KEYWORDS_FISH,
|
||||
lineComments = listOf("#"),
|
||||
lineCommentsAtWordStart = true,
|
||||
// fish's single quotes escape only `\'` and `\\`, which is what "skip the character
|
||||
// after a backslash" already does.
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
),
|
||||
Language.GO to
|
||||
Rules(
|
||||
keywords = KEYWORDS_GO,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = false)),
|
||||
),
|
||||
Language.JAVA to
|
||||
Rules(
|
||||
keywords = KEYWORDS_JAVA,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
Language.JAVASCRIPT to
|
||||
Rules(
|
||||
keywords = KEYWORDS_JAVASCRIPT,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = true)),
|
||||
),
|
||||
Language.JSON to Rules(keywords = KEYWORDS_JSON, quotes = listOf(DOUBLE)),
|
||||
Language.KOTLIN to
|
||||
Rules(
|
||||
keywords = KEYWORDS_KOTLIN,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = NESTING,
|
||||
quotes = listOf(Quote("\"\"\"", "\"\"\"", escapes = false), DOUBLE, SINGLE),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
Language.PERL to
|
||||
Rules(
|
||||
keywords = KEYWORDS_PERL,
|
||||
lineComments = listOf("#"),
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
),
|
||||
Language.PHP to
|
||||
Rules(
|
||||
keywords = KEYWORDS_PHP,
|
||||
lineComments = listOf("//", "#"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
Language.PYTHON to
|
||||
Rules(
|
||||
keywords = KEYWORDS_PYTHON,
|
||||
lineComments = listOf("#"),
|
||||
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
Language.RON to
|
||||
Rules(
|
||||
keywords = KEYWORDS_RON,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = NESTING,
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
attributes = Attributes.HASH_BRACKET,
|
||||
rawStrings = true,
|
||||
),
|
||||
Language.RUBY to
|
||||
Rules(
|
||||
keywords = KEYWORDS_RUBY,
|
||||
lineComments = listOf("#"),
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
),
|
||||
Language.RUST to
|
||||
Rules(
|
||||
keywords = KEYWORDS_RUST,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = NESTING,
|
||||
// No `'` here: [Rules.lifetimes] decides when one opens a character literal.
|
||||
quotes = listOf(DOUBLE),
|
||||
attributes = Attributes.HASH_BRACKET,
|
||||
rawStrings = true,
|
||||
lifetimes = true,
|
||||
),
|
||||
Language.SHELL to
|
||||
Rules(
|
||||
keywords = KEYWORDS_SHELL,
|
||||
lineComments = listOf("#"),
|
||||
lineCommentsAtWordStart = true,
|
||||
// A shell's single quotes are literal: `'a\'` is not one string.
|
||||
quotes = listOf(DOUBLE, Quote("'", "'", escapes = false)),
|
||||
),
|
||||
Language.SWIFT to
|
||||
Rules(
|
||||
keywords = KEYWORDS_SWIFT,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = NESTING,
|
||||
quotes = listOf(TRIPLE_DOUBLE, DOUBLE),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
Language.TOML to
|
||||
Rules(
|
||||
keywords = KEYWORDS_TOML,
|
||||
lineComments = listOf("#"),
|
||||
quotes =
|
||||
listOf(
|
||||
TRIPLE_DOUBLE,
|
||||
Quote("'''", "'''", escapes = false),
|
||||
DOUBLE,
|
||||
Quote("'", "'", escapes = false),
|
||||
),
|
||||
attributes = Attributes.LINE_BRACKET,
|
||||
),
|
||||
Language.TYPESCRIPT to
|
||||
Rules(
|
||||
keywords = KEYWORDS_TYPESCRIPT,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = true)),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The keyword sets.
|
||||
*
|
||||
* Every list below other than RON, TOML, fish and JSON came from dev.snipme:highlights 1.1.0
|
||||
* (Apache-2.0), the library this scanner replaced, so that no fence which is coloured today turns
|
||||
* plain. Entries that are not plain words were dropped -- Kotlin's `as?`, Swift's `#if` family,
|
||||
* Ruby's `defined?` -- because the word scanner cannot reach them.
|
||||
*/
|
||||
private fun words(list: String): Set<String> =
|
||||
list.split(Regex("\\s+")).filterNot(String::isEmpty).toSet()
|
||||
|
||||
private val KEYWORDS_C =
|
||||
words(
|
||||
"""auto break case char const continue default do double else enum extern float for goto if
|
||||
int long register return short signed sizeof static struct switch typedef union unsigned
|
||||
void volatile while"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_CPP =
|
||||
words(
|
||||
"""asm auto bool break case catch char class const const_cast continue default delete do
|
||||
double dynamic_cast else enum explicit export extern false float for friend goto if inline
|
||||
int long mutable namespace new operator private protected public register reinterpret_cast
|
||||
return short signed sizeof static static_cast struct switch template this throw true try
|
||||
typedef typeid typename union unsigned using virtual void volatile wchar_t while"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_CSHARP =
|
||||
words(
|
||||
"""abstract as base bool break byte case catch char checked class const continue decimal
|
||||
default delegate do double else enum event explicit extern false finally fixed float for
|
||||
foreach goto if implicit in int interface internal is lock long namespace new null object
|
||||
operator out override params private protected public readonly ref return sbyte sealed short
|
||||
sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked
|
||||
unsafe ushort using virtual void volatile while"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_COFFEESCRIPT =
|
||||
words(
|
||||
"""Infinity NaN and arguments await break by case catch class continue debugger delete defer
|
||||
default do else export extends false finally for function if import in instanceof is isnt
|
||||
let loop new no not null of on or package return super switch this throw true try typeof
|
||||
unless undefined var wait when with yield"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_DART =
|
||||
words(
|
||||
"""abstract as assert async await base break case catch class const continue covariant
|
||||
default deferred do dynamic else enum export extends external factory false final finally
|
||||
for get if implements import in interface is late library mixin new null on operator part
|
||||
required rethrow return sealed set show static super switch this throw true try var void
|
||||
when with while yield"""
|
||||
)
|
||||
|
||||
/**
|
||||
* fish is not in the library at all, so its fences are drawn plain today. The list is the shell's
|
||||
* own words, which is what a fish fence is mostly made of.
|
||||
*/
|
||||
private val KEYWORDS_FISH =
|
||||
words(
|
||||
"""and begin break builtin case command continue else end exec for function if in not or
|
||||
return switch while set echo test string math read source"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_GO =
|
||||
words(
|
||||
"""break case chan const continue default defer else fallthrough false for func go goto if
|
||||
import interface map package range return select struct switch true type var"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_JAVA =
|
||||
words(
|
||||
"""abstract assert boolean break byte case catch char class const continue default do double
|
||||
else enum extends final finally float for goto if implements import instanceof int interface
|
||||
long native new null package private protected public return short static strictfp super
|
||||
switch synchronized this throw throws transient try void volatile while"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_JAVASCRIPT =
|
||||
words(
|
||||
"""async await boolean break case catch class const continue debugger default delete do else
|
||||
enum export extends false finally for function if implements import in instanceof interface
|
||||
let new null package private protected public return super switch this throw true try typeof
|
||||
var void while with yield"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_JSON = words("true false null")
|
||||
|
||||
private val KEYWORDS_KOTLIN =
|
||||
words(
|
||||
"""actual abstract annotation as break by catch class companion const constructor continue
|
||||
coroutine crossinline data delegate dynamic do else enum expect external false final finally
|
||||
for fun get if import in infix inline interface internal is lazy lateinit native null object
|
||||
open operator out override package private protected public reified return sealed set super
|
||||
suspend tailrec this throw true try typealias typeof val var vararg when while yield"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_PERL =
|
||||
words(
|
||||
"""__DATA__ __END__ __FILE__ __LINE__ __PACKAGE__ and cmp continue do else elsif eq eval for
|
||||
foreach goto gt if last le lt my ne next no not or package redo ref return sub unless until
|
||||
use while xor"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_PHP =
|
||||
words(
|
||||
"""__halt_compiler abstract and array as break callable case catch class clone const continue
|
||||
declare default die do echo else elseif empty enddeclare endfor endforeach endif endswitch
|
||||
endwhile eval exit extends final finally fn for foreach function global goto if implements
|
||||
include include_once instanceof insteadof interface isset list match new or print private
|
||||
protected public require require_once return static switch throw trait try unset use var
|
||||
while xor yield"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_PYTHON =
|
||||
words(
|
||||
"""False True and as assert async await break class continue def del elif else except finally
|
||||
for from global if import in is lambda nonlocal not or pass raise return try while with
|
||||
yield"""
|
||||
)
|
||||
|
||||
/** RON is not in the library either; these are the words a RON file can hold. */
|
||||
private val KEYWORDS_RON = words("true false Some None inf NaN")
|
||||
|
||||
private val KEYWORDS_RUBY =
|
||||
words(
|
||||
"""__ENCODING__ __END__ __FILE__ __LINE__ BEGIN END alias and begin break case class def do
|
||||
else elsif end ensure false for if in module next nil not or redo rescue retry return self
|
||||
super then true undef unless until when while yield"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_RUST =
|
||||
words(
|
||||
"""as async await break const continue crate dyn else enum extern false fn for if impl in
|
||||
let loop match mod move mut pub ref return Self self static struct super trait true type
|
||||
union unsafe use where while abstract become box do final macro override priv try typeof
|
||||
unsized virtual yield"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_SHELL =
|
||||
words(
|
||||
"""alias bg bind break builtin caller cd command compgen complete compopt continue declare
|
||||
dirs disown echo enable eval exec exit export fc fg getopts hash help history jobs kill let
|
||||
local logout popd printf pushd pwd read readonly return set shift shopt source suspend
|
||||
test"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_SWIFT =
|
||||
words(
|
||||
"""_ associatedtype class deinit enum extension fileprivate func import init inout internal
|
||||
let open operator private precedencegroup protocol public rethrows static struct subscript
|
||||
typealias var break case catch continue default defer do else fallthrough for guard if in
|
||||
repeat return throw switch where while Any as await false is nil self Self super throws true
|
||||
try associativity convenience didSet dynamic final get indirect infix lazy left mutating none
|
||||
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
||||
unowned weak willSet"""
|
||||
)
|
||||
|
||||
/** TOML is not in the library; `inf` and `nan` are values rather than names, like the booleans. */
|
||||
private val KEYWORDS_TOML = words("true false inf nan")
|
||||
|
||||
private val KEYWORDS_TYPESCRIPT =
|
||||
words(
|
||||
"""abstract as asserts await break case catch class const constructor continue debugger
|
||||
default delete do else enum export extends false finally for from function get if implements
|
||||
import in infer instanceof interface is keyof let module namespace new null number object
|
||||
package private protected public readonly require global return set static string super
|
||||
switch this throw true try type typeof undefined unique unknown var void while with yield"""
|
||||
)
|
||||
@@ -8,7 +8,7 @@ package com.example.aiapp
|
||||
* 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.
|
||||
* [T]: one `LoadState.Loading` serves every screen.
|
||||
*/
|
||||
sealed class LoadState<out T> {
|
||||
data object Loading : LoadState<Nothing>()
|
||||
|
||||
@@ -13,8 +13,10 @@ 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
|
||||
@@ -26,14 +28,13 @@ 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.
|
||||
// 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.
|
||||
// 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
|
||||
|
||||
@@ -41,8 +42,8 @@ class MainActivity : ComponentActivity() {
|
||||
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.
|
||||
// Registered up front since permission launchers must be registered before the activity reaches
|
||||
// STARTED.
|
||||
private val requestLocalNetworkPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||
|
||||
@@ -50,8 +51,8 @@ class MainActivity : ComponentActivity() {
|
||||
* 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.
|
||||
* settings, and a service that only ever started at the moment it was granted would stay down
|
||||
* until the app was launched again.
|
||||
*/
|
||||
private val requestNotificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||
@@ -62,21 +63,17 @@ class MainActivity : ComponentActivity() {
|
||||
// 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.
|
||||
// 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.
|
||||
// Dark status-bar icons only over a light background, decided from the scheme rather than
|
||||
// fixed. It was hardcoded to `true`, which was right against the default light surface and
|
||||
// became unreadable the moment the app wore Catppuccin Mocha.
|
||||
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
|
||||
AiAppColors.background.luminance() > 0.5f
|
||||
|
||||
// Android 17+ silently drops local-network traffic without this;
|
||||
// requested up front because a denial is invisible at the socket
|
||||
// layer (it just times out).
|
||||
// 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)
|
||||
}
|
||||
@@ -86,73 +83,72 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
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.
|
||||
// After enrollment, so a first launch that arrives with a token starts the service with
|
||||
// something to connect to rather than stopping it and waiting for the next launch.
|
||||
NotificationService.sync(this)
|
||||
|
||||
setContent {
|
||||
// Selection colours with the theme rather than at each place text is drawn: the
|
||||
// transcript is one selection container, and a selection that ran from a reply into the
|
||||
// code block under it would otherwise change colour halfway.
|
||||
MaterialTheme(colorScheme = AiAppColors) {
|
||||
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)
|
||||
CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier =
|
||||
// Timed like the transcript times itself, and for the same reason:
|
||||
// the frame's draw phase is where Compose's measurement lands, and
|
||||
// a report saying "draw is high" cannot otherwise say whether the
|
||||
// cost is the transcript or the chrome around it. The keyboard is
|
||||
// the case that made it matter.
|
||||
Modifier.layout { measurable, constraints ->
|
||||
val started = System.nanoTime()
|
||||
val placeable = measurable.measure(constraints)
|
||||
DebugStats.record(
|
||||
"place: the app root",
|
||||
System.nanoTime() - placing,
|
||||
"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,
|
||||
)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
// The gesture strip at the bottom of most phones. Without it
|
||||
// the send row sits under the swipe area, where a tap is as
|
||||
// likely to navigate away as to press a button.
|
||||
//
|
||||
// No imePadding here, deliberately: applied at the root it
|
||||
// resizes this whole box on every frame of the keyboard
|
||||
// animation, which re-measures, re-places and re-records every
|
||||
// screen's entire tree per frame. Each screen takes the
|
||||
// keyboard itself, so the per-frame cost is scoped to what
|
||||
// actually moves.
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
AppRoot(settingsVersion, openRequest, shareRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// launchMode="singleTop": an enrollment scan, or a notification tapped
|
||||
// while the app is open, lands here rather than in a second activity
|
||||
// instance.
|
||||
// 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)
|
||||
@@ -163,8 +159,7 @@ class MainActivity : ComponentActivity() {
|
||||
*
|
||||
* 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.
|
||||
* rather than by two entry points, so a further kind is a branch here.
|
||||
*/
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
intent ?: return
|
||||
@@ -187,8 +182,8 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
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.
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -29,12 +29,10 @@ 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.
|
||||
* full. Tabs say the same thing in less space and say one more thing besides: that these are places
|
||||
* to be rather than errands to run. Sessions, the machine's importable history, the models on it
|
||||
* and the machines themselves are all *the same backend*, looked at four ways, and none is a step
|
||||
* down from another. Settings still is, which is why it stays a pushed screen with its own Back.
|
||||
*/
|
||||
private enum class MainTab(val label: String) {
|
||||
Sessions("Sessions"),
|
||||
@@ -61,17 +59,12 @@ fun MainScreen(
|
||||
//
|
||||
// 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.
|
||||
// phone that was away while the tunnel was down came back to "Couldn't reach the server"
|
||||
// sitting at the top of a list the server would now answer for perfectly well. A stale failure
|
||||
// is worse than a stale list: it is a claim about right now.
|
||||
//
|
||||
// Through the same token the Refresh button uses, so this is one instruction the tabs already
|
||||
// understand 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.
|
||||
// understand. Not on the first entry: the tab composing already asks.
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
LaunchedEffect(lifecycleOwner) {
|
||||
var opening = true
|
||||
@@ -81,9 +74,8 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// A tab the app put over the list has to step back to it rather than fall through to the system
|
||||
// default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled.
|
||||
BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions }
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
@@ -98,20 +90,18 @@ fun MainScreen(
|
||||
)
|
||||
// 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.
|
||||
// keep its own line. They sit on the title's row because they act on the whole screen.
|
||||
//
|
||||
// Flush against each other: a glyph button carries its own padding, so two side by side
|
||||
// already have two rings between their marks.
|
||||
Row {
|
||||
GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ })
|
||||
GlyphButton(SETTINGS_GLYPH, "Settings", onSettings)
|
||||
}
|
||||
}
|
||||
// What is waiting to be attached, and what to do about it. Said here because the list
|
||||
// below is where the choice is made, and a share that arrived with nothing on screen
|
||||
// saying so would read as a tap that did nothing.
|
||||
// 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.",
|
||||
@@ -127,8 +117,8 @@ fun MainScreen(
|
||||
.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.
|
||||
// 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(
|
||||
@@ -139,10 +129,9 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Refreshing means "ask again about what I am looking at", so the button feeds the tab that
|
||||
// is showing. The token from above means something else already changed what these show;
|
||||
// the two are the same instruction, so they are summed rather than tracked apart.
|
||||
val token = reloadToken + refreshToken
|
||||
when (tab) {
|
||||
MainTab.Sessions ->
|
||||
|
||||
@@ -55,7 +55,6 @@ 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 dev.snipme.highlights.model.SyntaxLanguage
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -70,13 +69,10 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
||||
*
|
||||
* [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.
|
||||
* message. And its pieces get a layer each, so only the piece that changed is re-recorded. That is
|
||||
* worth a great deal while every delta invalidates the message and worth nothing once it stops
|
||||
* changing -- and it is not free: each layer is a layout node and a display list held for the life
|
||||
* of the row, and live node count is what the transcript's per-frame cost scales with.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownText(
|
||||
@@ -93,8 +89,8 @@ fun MarkdownText(
|
||||
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].
|
||||
// Only the tail is still being written; a frozen segment is finished text that happens
|
||||
// to sit in a live reply, and it takes its colours now.
|
||||
MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) {
|
||||
segment.pieces.forEachIndexed { index, piece ->
|
||||
val gap =
|
||||
@@ -104,9 +100,9 @@ fun MarkdownText(
|
||||
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.
|
||||
// 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,
|
||||
@@ -138,8 +134,7 @@ fun MarkdownText(
|
||||
* 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.
|
||||
* ended with, so the two draw as one list.
|
||||
*/
|
||||
private class Segment(
|
||||
val text: String,
|
||||
@@ -155,14 +150,11 @@ private class Segment(
|
||||
*
|
||||
* 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.
|
||||
* height, and the transcript above it collapses and springs back -- seen with five replies on
|
||||
* screen at once, the whole conversation shrunk to fit a single screen.
|
||||
*
|
||||
* Every parse after the first is off the composing thread, and the row keeps drawing the parse it
|
||||
* already has until the new one lands, so there is never a frame without a height. What is on
|
||||
* screen is always a real prefix of the reply rather than a guess at it; it is simply one parse
|
||||
* behind.
|
||||
* already has until the new one lands, so there is never a frame without a height.
|
||||
*/
|
||||
@Composable
|
||||
private fun liveSegments(text: String): List<Segment> {
|
||||
@@ -188,24 +180,18 @@ private fun liveSegments(text: String): List<Segment> {
|
||||
* 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.
|
||||
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
|
||||
* finished -- nothing appended later can reach back into it. So every block but the last is
|
||||
* [frozen] with the parse that finished it, and only the tail is parsed again.
|
||||
*
|
||||
* A list is cut once more, at its last item, by the same reasoning one level down. Without this a
|
||||
* reply that is one long list -- forty sources -- parsed the whole list per delta. The item the cut
|
||||
* lands on has to have begun in earnest: a bare `-` is an empty item now and the first character of
|
||||
* a paragraph line once `-x` arrives.
|
||||
*
|
||||
* What the cut gives up is one thing: a reference definition arriving later than a link that uses
|
||||
* it, 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.
|
||||
* it. The link draws as its brackets until the reply settles and is parsed whole by [warm].
|
||||
*/
|
||||
private class LiveParse(
|
||||
val text: String,
|
||||
@@ -218,14 +204,14 @@ private class LiveParse(
|
||||
get() = frozen + tail
|
||||
|
||||
fun advanceTo(next: String): LiveParse {
|
||||
// Anything but an append to what was frozen -- a message replaced, a stream reset --
|
||||
// starts over.
|
||||
// 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 || parse !is State.Success) {
|
||||
if (open == null) {
|
||||
return LiveParse(
|
||||
next,
|
||||
frozen,
|
||||
@@ -265,8 +251,7 @@ private class LiveParse(
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* piece of the last block when there is more than one. Null when nothing before it is finished.
|
||||
*/
|
||||
private fun openPiece(parse: State.Success, all: List<Piece>): Piece? {
|
||||
val last = all.lastOrNull() ?: return null
|
||||
@@ -316,28 +301,24 @@ fun MarkdownPiece(
|
||||
* 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
|
||||
* 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.
|
||||
* which was the last of its composables on the hot path and was here only to provide them. So
|
||||
* nothing between a piece and the screen is the library's but the leaf composables named in the
|
||||
* component table.
|
||||
*
|
||||
* Colours come from the theme rather than 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.
|
||||
* Colours come from the theme rather than the renderer's defaults. Nothing here picks one of its
|
||||
* own.
|
||||
*
|
||||
* [streaming] says this parse is the part of a reply still being written, which only the fences
|
||||
* care about: lexing is proportional to how much code there is, 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.
|
||||
* care about: lexing is proportional to how much code there is. Measured streaming a two-hundred-
|
||||
* line Kotlin fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms --
|
||||
* for colours on text being replaced as fast as they were computed. So a fence still being written
|
||||
* is drawn plain and takes its colours when the block freezes.
|
||||
*/
|
||||
@Composable
|
||||
private fun MarkdownRoot(
|
||||
@@ -355,42 +336,40 @@ private fun MarkdownRoot(
|
||||
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.
|
||||
// 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.
|
||||
// The dark surface every verbatim thing in this app sits on -- and the tool call
|
||||
// above this reply, which now matches. `surfaceVariant` was exactly a card's own
|
||||
// fill, so a fenced block inside a tool call had no background at all.
|
||||
codeBackground = rawSurface,
|
||||
// The same colour. Not drawn by the renderer as a span background but by
|
||||
// [LinkedText] behind the text, so a selection lands on top of it -- see
|
||||
// `appendCodeChip`.
|
||||
inlineCodeBackground = rawSurface,
|
||||
// The same tint a code block gets, rather than the renderer's 2%-alpha default:
|
||||
// two adjacent tints that differ by a fiftieth read as one flat block on a phone,
|
||||
// so the table would have had a border-less grid and nothing saying where it began.
|
||||
// The same tint a code block gets, rather than the renderer's 2%-alpha default: two
|
||||
// adjacent tints that differ by a fiftieth read as one flat block on a phone.
|
||||
tableBackground = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
LocalMarkdownTypography provides
|
||||
markdownTypography(
|
||||
// A ladder that starts near the body text and descends, because these are headings
|
||||
// inside a chat message rather than the top of a document. The renderer's defaults
|
||||
// are the Material *display* styles -- `#` came out at 57sp 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.
|
||||
// are the Material *display* styles -- `#` came out at 57sp, bigger than this app's
|
||||
// own screen titles. Every step is a different size, so two levels of nesting never
|
||||
// draw the same.
|
||||
h1 = MaterialTheme.typography.headlineSmall,
|
||||
h2 = MaterialTheme.typography.titleLarge,
|
||||
h3 = MaterialTheme.typography.titleMedium,
|
||||
h4 = MaterialTheme.typography.titleSmall,
|
||||
h5 = MaterialTheme.typography.labelMedium,
|
||||
h6 = MaterialTheme.typography.labelSmall,
|
||||
// Body text at the size everything else in the transcript uses.
|
||||
text = body,
|
||||
paragraph = body,
|
||||
ordered = body,
|
||||
@@ -398,16 +377,10 @@ private fun MarkdownRoot(
|
||||
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.
|
||||
// background are what say "this is code"; colour is not, and it used to be green --
|
||||
// the palette's colour for a *literal*. A block of code is not a literal, and
|
||||
// painting all of it green said the whole block was one. Where a literal really
|
||||
// does appear inside code, what should colour it is a syntax highlighter.
|
||||
code =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
@@ -434,31 +407,26 @@ private fun MarkdownRoot(
|
||||
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.
|
||||
// the default a fifth of the narrowest column went on space rather than on words.
|
||||
tableCellPadding = 8.dp,
|
||||
// What a column narrows to before the table starts scrolling sideways instead. It
|
||||
// is the floor, not the width: a table with room to spare spreads across it.
|
||||
//
|
||||
// Down from the renderer's 160dp, and the number is a measurement rather than a
|
||||
// taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp
|
||||
// makes even a three-column table -- 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.
|
||||
// makes even a three-column table scroll, while 136dp fits three across the phone
|
||||
// this app is read on. Four and up still scroll, which is the right answer for
|
||||
// genuinely too many columns. This is the widest minimum that keeps three on
|
||||
// screen.
|
||||
tableCellWidth = 136.dp,
|
||||
),
|
||||
LocalMarkdownComponents provides
|
||||
markdownComponents(
|
||||
// The m3 renderer's own default, restored: supplying `components` at all replaces
|
||||
// the whole set, and this is the only member of it the Material layer overrides.
|
||||
// the whole set, and this is the only member the Material layer overrides.
|
||||
checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) },
|
||||
// Everything that draws a run of text, so a link is a span rather than a node --
|
||||
// see [LinkedText]. Setext headings take the same styles as `#` and `##`, which
|
||||
// is the renderer's own pairing.
|
||||
// see [LinkedText]. Setext headings take the same styles as `#` and `##`.
|
||||
text = { LinkedText(it, it.typography.text) },
|
||||
paragraph = { LinkedText(it, it.typography.paragraph) },
|
||||
heading1 = { LinkedHeading(it, it.typography.h1) },
|
||||
@@ -469,8 +437,8 @@ private fun MarkdownRoot(
|
||||
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.
|
||||
// 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) },
|
||||
@@ -489,14 +457,12 @@ private fun MarkdownRoot(
|
||||
/**
|
||||
* 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.
|
||||
* Each column has a floor, so the table is at least columns-times-floor wide; narrower than the
|
||||
* room it has, it spreads to fill it, and wider, it scrolls sideways rather than squeezing. The
|
||||
* renderer decided that with a `BoxWithConstraints`, which is a subcomposition; here it is one
|
||||
* layout modifier. `fillMaxWidth` fixes the minimum width to the room available, the horizontal
|
||||
* scroll passes that minimum through while lifting the maximum to unbounded, and the modifier after
|
||||
* it reads the minimum back and sizes the rows to the larger of that and the floor.
|
||||
*/
|
||||
@Composable
|
||||
private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
|
||||
@@ -537,19 +503,15 @@ private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
|
||||
* 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.
|
||||
* most of a table is simply not readable: an elided cell looks like a short one, so a table of
|
||||
* measurements reads as a table of plausible shorter measurements. And they draw a link in a cell
|
||||
* as its own layout node, the cost [LinkedText] exists to avoid.
|
||||
*
|
||||
* So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell
|
||||
* beside a one-line one centred the short one against the middle of the tall one 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.
|
||||
* beside a one-line one centred the short one against the middle of the tall one. What the wrapping
|
||||
* does *not* do is make a wide table fit; [LinkedTable] scrolls it instead.
|
||||
*
|
||||
* The semantics are the renderer's: each cell is an item of the table's collection, and a header
|
||||
* cell is a heading.
|
||||
* The semantics are the renderer's: each cell is an item of the table's collection.
|
||||
*/
|
||||
@Composable
|
||||
private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) {
|
||||
@@ -585,19 +547,14 @@ private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowI
|
||||
* 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.
|
||||
* tuned on -- so a page of history landing composed several rows that each stalled the frame.
|
||||
*
|
||||
* Nothing here changes what a row does when it has no answer waiting: it parses inline, 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.
|
||||
* Nothing here changes what a row does when it has no answer waiting: it parses inline, because a
|
||||
* row measured at nothing before its real height collapses the transcript above it. The point is
|
||||
* only that by the time the reader scrolls to a row, the answer is usually already made.
|
||||
*
|
||||
* A miss is not stored, and that is what bounds this: the map holds one entry per message a page
|
||||
* warmed 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.
|
||||
* warmed, so a reply still streaming cannot fill it with hundreds of copies of itself.
|
||||
*/
|
||||
@Stable
|
||||
class ParsedReplies {
|
||||
@@ -605,14 +562,13 @@ class ParsedReplies {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* fold, and walking the tree again each time is proportional to the message.
|
||||
*/
|
||||
private val pieces = ConcurrentHashMap<String, List<Piece>>()
|
||||
|
||||
/**
|
||||
* How each message divides into prose and memory notes, cached for the same reason as
|
||||
* [piecesOf]: the regex scan behind [messageParts] is proportional to the message.
|
||||
* How each message divides into prose and memory notes, cached for the same reason: the regex
|
||||
* scan behind [messageParts] is proportional to the message.
|
||||
*/
|
||||
private val parts = ConcurrentHashMap<String, List<MessagePart>>()
|
||||
|
||||
@@ -625,7 +581,7 @@ class ParsedReplies {
|
||||
* 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.
|
||||
* measured.
|
||||
*/
|
||||
private val highlights = ConcurrentHashMap<String, AnnotatedString>()
|
||||
|
||||
@@ -647,11 +603,10 @@ class ParsedReplies {
|
||||
* 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.
|
||||
* message and the flatten runs on the composing thread, so a reply not marked yet stays whole
|
||||
* until the screen has warmed it. An explicit mark rather than a peek into the parse cache,
|
||||
* because a message with memory notes is warmed as its *parts*: nothing ever parses its full
|
||||
* text, and inferring readiness from the cache left exactly that message unsplittable forever.
|
||||
*/
|
||||
fun splitReady(text: String): Boolean = text in ready
|
||||
|
||||
@@ -666,11 +621,10 @@ class ParsedReplies {
|
||||
}
|
||||
|
||||
/**
|
||||
* [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.
|
||||
* [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: SyntaxLanguage?): AnnotatedString =
|
||||
fun highlighted(code: String, language: Language?): AnnotatedString =
|
||||
if (language == null) AnnotatedString(code)
|
||||
else highlights.computeIfAbsent("$language\n$code") { highlight(code, language) }
|
||||
|
||||
@@ -684,9 +638,9 @@ class ParsedReplies {
|
||||
*
|
||||
* 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.
|
||||
* a twelve second scroll on a Pixel 9 Pro XL -- and on the default dispatcher that is every
|
||||
* core busy, with the frame's own thread waiting for one: 21ms of `waited` at the 90th
|
||||
* percentile.
|
||||
*/
|
||||
suspend fun warm(texts: List<String>) {
|
||||
texts.forEach { text ->
|
||||
@@ -695,9 +649,8 @@ class ParsedReplies {
|
||||
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.
|
||||
// parse this just made, and lexing one is the same kind of cost as parsing the message
|
||||
// it is in.
|
||||
fences(parse).forEach { (code, language) -> highlighted(code, language) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
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
|
||||
@@ -37,27 +44,22 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
||||
*
|
||||
* 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.
|
||||
* layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one annotation
|
||||
* per link. Measured on the emulator against the same paragraphs with each link replaced by its
|
||||
* label and address as plain words -- *more* text, the same gestures -- the linked version cost
|
||||
* five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time.
|
||||
*
|
||||
* Here a link is the link colour and underline, a string annotation carrying its address, and one
|
||||
* tap detector for the whole text that asks the layout which character was under the finger. What
|
||||
* that gives up is a link being its own accessibility node with a pressed state; the app's link
|
||||
* style never defined a pressed style, so nothing visible changes.
|
||||
*
|
||||
* Every block the renderer dispatches through its component table comes here, 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.
|
||||
* Every block the renderer dispatches through its component table comes here, and so does every
|
||||
* table cell. Reference-style links are the one kind still drawn the renderer's way.
|
||||
*
|
||||
* An image is a link too, carrying its alt text. The app has no image loader and the renderer's
|
||||
* transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the
|
||||
* model put something, 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.
|
||||
* model put something. The link says what was there and where, and opens it.
|
||||
*/
|
||||
@Composable
|
||||
fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
|
||||
@@ -67,8 +69,7 @@ fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
|
||||
/**
|
||||
* 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.
|
||||
* does not know, so handed the heading node itself it draws an empty line.
|
||||
*/
|
||||
@Composable
|
||||
fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) {
|
||||
@@ -88,24 +89,92 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
||||
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 =
|
||||
modifier.pointerInput(text) {
|
||||
detectTapGestures { position ->
|
||||
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
|
||||
uriHandler.openUri(url)
|
||||
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the second
|
||||
// one has to be answered from inside the text rather than left to the card.
|
||||
modifier.then(chipFill).pointerInput(text, onPlainTap) {
|
||||
awaitEachGesture {
|
||||
// Unconsumed is not required: something outside may already be tracking this
|
||||
// press, and it is still the press that may land on a link.
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
// A tap and nothing else. Null when the gesture became somebody else's -- a
|
||||
// scroll, or a press held past the long-press timeout, which is how a selection
|
||||
// starts. The timeout is the load-bearing half: without it a press held for a
|
||||
// second and released was still an up with nothing consumed, so holding a peer
|
||||
// message to select from it shut the card instead.
|
||||
val up =
|
||||
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
|
||||
waitForUpOrCancellation()
|
||||
} ?: return@awaitEachGesture
|
||||
val url = text.linkAt(layout.value, up.position)
|
||||
when {
|
||||
url != null -> {
|
||||
up.consume()
|
||||
uriHandler.openUri(url)
|
||||
}
|
||||
onPlainTap != null -> {
|
||||
up.consume()
|
||||
onPlainTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
style = style,
|
||||
color = { color },
|
||||
onTextLayout = { layout.value = it },
|
||||
onTextLayout = {
|
||||
layout.value = it
|
||||
chipFills.value = chips.flatMap { chip -> it.chipRects(chip.start, chip.end) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a tap on markdown text means when it lands on no link -- shutting the card it is drawn in,
|
||||
* usually -- or null where a plain tap means nothing.
|
||||
*
|
||||
* A composition local because there is nowhere else to put it. The paragraphs of a message are
|
||||
* composed by the renderer's own dispatch, so nothing between a card and the text inside it is ours
|
||||
* to pass a parameter through.
|
||||
*
|
||||
* It exists because a pointer-input node over the glyphs takes the tap and the card's own click
|
||||
* handler never sees it. Measured against an opened peer message: with a handler on the text --
|
||||
* consuming or not -- a tap on its words did nothing at all, and with the handler removed the same
|
||||
* tap shut the card. So a card whose body is markdown cannot be shut by pressing its words unless
|
||||
* the words do the shutting.
|
||||
*
|
||||
* Provided as a value that outlives a recomposition, since a fresh lambda per composition would
|
||||
* invalidate every paragraph reading it.
|
||||
*/
|
||||
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
|
||||
|
||||
/**
|
||||
* [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.
|
||||
*
|
||||
@@ -127,15 +196,80 @@ private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset):
|
||||
private const val LINK_URL = "url"
|
||||
|
||||
/**
|
||||
* The renderer's annotator settings with [appendPlainLink] answering for links. 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.
|
||||
* Appends [node] as inline code -- the renderer's own span, padded by a space each side as it does,
|
||||
* but with no background of its own -- if it is a code span; false leaves anything else to the
|
||||
* renderer.
|
||||
*
|
||||
* The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's
|
||||
* background is part of the text's own drawing, and the text node draws the selection first and the
|
||||
* glyphs over it, so a chip painted as a span background covered the selection: selecting a
|
||||
* sentence highlighted every word except the ones in backticks. Anything drawn by a modifier on the
|
||||
* text is under both, which is where a fenced block's box already is.
|
||||
*/
|
||||
private fun appendCodeChip(
|
||||
builder: AnnotatedString.Builder,
|
||||
content: String,
|
||||
node: ASTNode,
|
||||
settings: AnnotatorSettings,
|
||||
): Boolean {
|
||||
if (node.type != MarkdownElementTypes.CODE_SPAN) return false
|
||||
builder.pushStringAnnotation(CODE_CHIP, "")
|
||||
builder.pushStyle(settings.codeSpanStyle.copy(background = Color.Unspecified))
|
||||
builder.append(' ')
|
||||
// The backticks are the first and last children.
|
||||
builder.buildMarkdownAnnotatedString(content, node.children.drop(1).dropLast(1), settings)
|
||||
builder.append(' ')
|
||||
builder.pop()
|
||||
builder.pop()
|
||||
return true
|
||||
}
|
||||
|
||||
private const val CODE_CHIP = "code"
|
||||
|
||||
/**
|
||||
* One box per line of the text [start] until [end] covers, in the layout's own coordinates.
|
||||
*
|
||||
* Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every
|
||||
* line but the last, so a chip whose code wrapped left a full-width empty box behind on the line
|
||||
* above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space
|
||||
* stops being drawn -- the same rule the selection rectangle obeys, so the two agree.
|
||||
*
|
||||
* A run's extent is taken from the boxes of its first and last characters, which is exact while a
|
||||
* line reads in one direction; mixed directions inside a code span would draw one box across the
|
||||
* whole run, and code spans are code.
|
||||
*/
|
||||
private fun TextLayoutResult.chipRects(start: Int, end: Int): List<Rect> {
|
||||
val rects = mutableListOf<Rect>()
|
||||
for (line in getLineForOffset(start)..getLineForOffset(end - 1)) {
|
||||
val from = maxOf(start, getLineStart(line))
|
||||
val to = minOf(end, getLineEnd(line, visibleEnd = true))
|
||||
if (from >= to) continue
|
||||
val head = getBoundingBox(from)
|
||||
val tail = getBoundingBox(to - 1)
|
||||
rects +=
|
||||
Rect(
|
||||
left = minOf(head.left, tail.left),
|
||||
top = minOf(head.top, tail.top),
|
||||
right = maxOf(head.right, tail.right),
|
||||
bottom = maxOf(head.bottom, tail.bottom),
|
||||
)
|
||||
}
|
||||
return rects
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer's annotator settings with [appendPlainLink] answering for links and [appendCodeChip]
|
||||
* for inline code. The annotator needs the settings to draw a link's label, and the settings hold
|
||||
* the annotator, so the reference goes through a cell filled in once both exist.
|
||||
*/
|
||||
@Composable
|
||||
private fun plainLinkSettings(): AnnotatorSettings {
|
||||
val cell = remember { Ref<AnnotatorSettings>() }
|
||||
val annotator = remember {
|
||||
markdownAnnotator { content, node -> appendPlainLink(this, content, node, cell.value!!) }
|
||||
markdownAnnotator { content, node ->
|
||||
appendPlainLink(this, content, node, cell.value!!) ||
|
||||
appendCodeChip(this, content, node, cell.value!!)
|
||||
}
|
||||
}
|
||||
return annotatorSettings(annotator = annotator).also { cell.value = it }
|
||||
}
|
||||
|
||||
@@ -34,22 +34,18 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
||||
*
|
||||
* 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.
|
||||
* hundred short ones; and the list composes an item whole in the frame it scrolls into. Measured on
|
||||
* a Pixel 9 Pro XL, the tallest row still being drawn was 36,982px -- twenty-five screens in one
|
||||
* message. A piece is a paragraph, a fence, a table, one bullet: bounded, so both costs are.
|
||||
*
|
||||
* Cut where the parser says the blocks are, which is 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.
|
||||
* Cut where the parser says the blocks are, which is what makes it safe: a fence, a table and a
|
||||
* nested list are each one node whatever is inside them. A list is the one block that is not
|
||||
* bounded -- a reply's list of sources can be forty items -- so it is cut once more, into its
|
||||
* items.
|
||||
*
|
||||
* A piece is an *address* into the message's one parse ([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.
|
||||
* A piece is an *address* into the message's one parse rather than a substring of it. Every piece
|
||||
* is drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and
|
||||
* a reference definition at its foot still resolves the links above it.
|
||||
*/
|
||||
@Immutable
|
||||
data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
|
||||
@@ -59,8 +55,7 @@ data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The pieces of [parse], in reading order. Blank nodes between blocks -- the parser keeps the
|
||||
* newlines -- are not pieces.
|
||||
* The pieces of [parse], in reading order. Blank nodes between blocks are not pieces.
|
||||
*
|
||||
* A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a
|
||||
* message that drew as nothing would be a hole in the transcript with no sign of what fell out.
|
||||
@@ -90,17 +85,15 @@ fun gapBefore(previous: Piece?, piece: Piece): Dp =
|
||||
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.
|
||||
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which carries the theme,
|
||||
* the components and the reference links to the renderer's element composables.
|
||||
*
|
||||
* A whole block goes to the renderer's own dispatch with this app's component table, 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.
|
||||
* A whole block goes to the renderer's own dispatch with this app's component table. Only the list
|
||||
* item is drawn directly, because a list item is the one piece the renderer has no element for.
|
||||
*
|
||||
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply (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.
|
||||
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply: an
|
||||
* item that is the first or last of its own parse but not of the list the reader sees keeps an
|
||||
* inner item's padding, so nothing moves when the seam between segments does.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownPiece(
|
||||
@@ -112,8 +105,8 @@ fun MarkdownPiece(
|
||||
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.
|
||||
// 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
|
||||
}
|
||||
@@ -144,8 +137,7 @@ fun MarkdownPiece(
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* list inside a quote, and the nested lists an item holds. Top-level lists never come here.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) {
|
||||
@@ -170,9 +162,8 @@ fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier
|
||||
* 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.
|
||||
* The marker is drawn here rather than by a handler because it is the thing a reader might one day
|
||||
* want styled -- a different glyph per depth, a colour -- and this is the one place it is drawn.
|
||||
*/
|
||||
@Composable
|
||||
private fun MarkdownListItem(
|
||||
@@ -231,8 +222,8 @@ private fun Marker(text: String, style: TextStyle) {
|
||||
/**
|
||||
* 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.
|
||||
* Checked on the emulator's system fonts; a glyph the platform lacks draws as a box, and that check
|
||||
* is the price of adding one here.
|
||||
*/
|
||||
private val BULLETS = listOf("• ", "◦ ", "▪ ")
|
||||
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
package com.example.aiapp
|
||||
|
||||
/**
|
||||
* Markdown read into the spans that carry a colour -- a ```markdown fence in a reply, and a `.md`
|
||||
* file in the viewer.
|
||||
*
|
||||
* Its own scanner rather than a row of [Rules] because markdown has neither keywords nor strings:
|
||||
* what a character means depends on where it sits. A `#` opens a heading at the start of a line and
|
||||
* is an ordinary character three words in; a `*` opens emphasis only if something closes it on the
|
||||
* same line. The token scanner cannot ask either question.
|
||||
*
|
||||
* Structure is read a line at a time and each line's prose left to right, so every decision is made
|
||||
* inside one line -- except the two that are not. A fenced block is state carried forward, so an
|
||||
* unclosed fence colours the rest of the text, which is what it looks like while somebody is
|
||||
* writing it. A table is found by its delimiter row (`|---|---|`), the only line of one that cannot
|
||||
* be anything else, and its header is the line before that -- the one place here that looks ahead.
|
||||
*
|
||||
* What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is
|
||||
* one, four spaces after a bullet is a list item's second paragraph, and the two are told apart by
|
||||
* what came before. Colouring the wrong one as code is a mistake the reader cannot see.
|
||||
*
|
||||
* Like [scan], the spans come out ordered, non-overlapping and inside the text by construction.
|
||||
*/
|
||||
fun scanMarkdown(code: String): List<Span> = MarkdownScanner(code).run()
|
||||
|
||||
/** The characters an unordered list may be bulleted with. */
|
||||
private const val BULLETS = "-*+"
|
||||
|
||||
/** The characters a thematic break, or a setext heading's underline, can be drawn with. */
|
||||
private const val RULE_MARKERS = "-*_="
|
||||
|
||||
/** The characters that can open emphasis, strong emphasis or a strikethrough. */
|
||||
private const val EMPHASIS = "*_~"
|
||||
|
||||
/** Characters that end a bare URL wherever they appear, and ones only trimmed off the end. */
|
||||
private const val URL_STOPS = "<>\"'`|"
|
||||
private const val URL_TRAILING = ".,:;!?"
|
||||
|
||||
private class MarkdownScanner(private val code: String) {
|
||||
private val spans = ArrayList<Span>()
|
||||
|
||||
fun run(): List<Span> {
|
||||
var at = 0
|
||||
// The delimiter run that opened the fenced block we are inside, or null between them.
|
||||
var fence: String? = null
|
||||
// Whether the row above was part of a table, which is what makes this one a body row.
|
||||
var table = false
|
||||
while (at <= code.length) {
|
||||
val end = lineEnd(at)
|
||||
val open = fence
|
||||
if (open != null) {
|
||||
// The content and the closing line alike: a fence is one block of code, and its own
|
||||
// delimiters belong to it the way a string's quotes belong to the string.
|
||||
emit(at, end, Kind.STRING)
|
||||
if (closesFence(at, end, open)) fence = null
|
||||
} else {
|
||||
val opened = opensFence(at, end)
|
||||
fence = opened
|
||||
if (opened != null) table = false else table = row(at, end, table)
|
||||
}
|
||||
if (end == code.length) break
|
||||
at = end + 1
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
/** The end of the line beginning at [at]: the newline, or the end of the text. */
|
||||
private fun lineEnd(at: Int): Int {
|
||||
val newline = code.indexOf('\n', at)
|
||||
return if (newline < 0) code.length else newline
|
||||
}
|
||||
|
||||
/**
|
||||
* One line that is not inside a fence, and whether the table it may be part of is still open.
|
||||
*
|
||||
* A table is recognised by its delimiter row, the only line of one that cannot be anything
|
||||
* else. That row comes *after* the header it belongs to, so the header is found by looking one
|
||||
* line ahead -- the single piece of lookahead here, and cheaper than colouring every `|` in the
|
||||
* document, which would mark the pipes in a shell command written in a paragraph.
|
||||
*/
|
||||
private fun row(start: Int, end: Int, table: Boolean): Boolean {
|
||||
if (tableDelimiter(start, end)) {
|
||||
emit(indented(start, end), end, Kind.MARK)
|
||||
return true
|
||||
}
|
||||
val header = end < code.length && tableDelimiter(end + 1, lineEnd(end + 1))
|
||||
if ((table || header) && hasPipe(start, end)) {
|
||||
tableRow(start, end)
|
||||
return true
|
||||
}
|
||||
structure(start, end)
|
||||
return false
|
||||
}
|
||||
|
||||
/** A line of nothing but pipes, dashes, alignment colons and space, with one of each needed. */
|
||||
private fun tableDelimiter(start: Int, end: Int): Boolean {
|
||||
var dashes = false
|
||||
var pipes = false
|
||||
for (at in indented(start, end) until end) {
|
||||
when (code[at]) {
|
||||
'-' -> dashes = true
|
||||
'|' -> pipes = true
|
||||
':',
|
||||
' ',
|
||||
'\t' -> {}
|
||||
else -> return false
|
||||
}
|
||||
}
|
||||
return dashes && pipes
|
||||
}
|
||||
|
||||
private fun hasPipe(start: Int, end: Int): Boolean {
|
||||
var at = start
|
||||
while (at < end) {
|
||||
if (code[at] == '\\') at += 2 else if (code[at] == '|') return true else at++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** A table row: the pipes are the structure, and what is between them is prose. */
|
||||
private fun tableRow(start: Int, end: Int) {
|
||||
var at = indented(start, end)
|
||||
var cell = at
|
||||
while (at < end) {
|
||||
when (code[at]) {
|
||||
'\\' -> at += 2
|
||||
'|' -> {
|
||||
inline(cell, at)
|
||||
emit(at, at + 1, Kind.MARK)
|
||||
at++
|
||||
cell = at
|
||||
}
|
||||
else -> at++
|
||||
}
|
||||
}
|
||||
inline(cell, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Spans, coalesced with the one before when they touch and agree. Worth doing here rather than
|
||||
* leaving it to the caller: the line scanner emits per marker and per word, so a heading would
|
||||
* otherwise arrive as a dozen abutting spans of one colour.
|
||||
*/
|
||||
private fun emit(start: Int, end: Int, kind: Kind) {
|
||||
if (end <= start) return
|
||||
val last = spans.lastOrNull()
|
||||
if (last != null && last.kind == kind && last.end == start) {
|
||||
spans[spans.size - 1] = Span(last.start, end, kind)
|
||||
} else {
|
||||
spans.add(Span(start, end, kind))
|
||||
}
|
||||
}
|
||||
|
||||
/** The first character of the line at or after [start] that is not indentation. */
|
||||
private fun indented(start: Int, end: Int): Int {
|
||||
var at = start
|
||||
while (at < end && (code[at] == ' ' || code[at] == '\t')) at++
|
||||
return at
|
||||
}
|
||||
|
||||
/** The run of backticks or tildes that could open or close a fence on this line, or null. */
|
||||
private fun fenceRun(start: Int, end: Int): IntRange? {
|
||||
val at = indented(start, end)
|
||||
if (at == end) return null
|
||||
val marker = code[at]
|
||||
if (marker != '`' && marker != '~') return null
|
||||
var run = at
|
||||
while (run < end && code[run] == marker) run++
|
||||
return if (run - at >= 3) at until run else null
|
||||
}
|
||||
|
||||
/** Draws an opening fence line and answers its delimiter, or null if this is not one. */
|
||||
private fun opensFence(start: Int, end: Int): String? {
|
||||
val run = fenceRun(start, end) ?: return null
|
||||
emit(run.first, run.last + 1, Kind.STRING)
|
||||
// The info word is what the fence is a fence *of*, which is metadata about the block rather
|
||||
// than part of it.
|
||||
emit(indented(run.last + 1, end), end, Kind.METADATA)
|
||||
return code.substring(run.first, run.last + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this line closes a fence opened by [open]: the same character, at least as many of
|
||||
* them, and nothing else on the line -- so a longer run closes a shorter one and a line of
|
||||
* backticks with a word after it does not close anything.
|
||||
*/
|
||||
private fun closesFence(start: Int, end: Int, open: String): Boolean {
|
||||
val run = fenceRun(start, end) ?: return false
|
||||
if (code[run.first] != open[0] || run.last + 1 - run.first < open.length) return false
|
||||
return indented(run.last + 1, end) == end
|
||||
}
|
||||
|
||||
/** One ordinary line: what its opening characters make it, and then its prose. */
|
||||
private fun structure(start: Int, end: Int) {
|
||||
var at = indented(start, end)
|
||||
// Quote markers come before everything else and can be several deep, and what follows one
|
||||
// is an ordinary line again -- a heading inside a quote is still a heading.
|
||||
while (at < end && code[at] == '>') {
|
||||
at++
|
||||
emit(at - 1, at, Kind.MARK)
|
||||
at = indented(at, end)
|
||||
}
|
||||
if (at == end) return
|
||||
if (heading(at, end) || thematicBreak(at, end)) return
|
||||
inline(bullet(at, end), end)
|
||||
}
|
||||
|
||||
/** `#` to `######` and a space. Without the space it is a word beginning with a hash. */
|
||||
private fun heading(start: Int, end: Int): Boolean {
|
||||
var at = start
|
||||
while (at < end && code[at] == '#') at++
|
||||
val depth = at - start
|
||||
if (depth !in 1..6) return false
|
||||
if (at < end && code[at] != ' ' && code[at] != '\t') return false
|
||||
emit(start, end, Kind.KEYWORD)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A line made of one repeated rule character and nothing else.
|
||||
*
|
||||
* `---`, `***` and `___` are thematic breaks; `===` and `---` are also the underline of a
|
||||
* setext heading. The two are the same line to look at and mean the same thing to a reader, so
|
||||
* they get one appearance rather than a lookback. One `=` is enough because a setext underline
|
||||
* may be a single character; a break needs three, which keeps a `- ` bullet out of here.
|
||||
*/
|
||||
private fun thematicBreak(start: Int, end: Int): Boolean {
|
||||
val marker = code[start]
|
||||
if (marker !in RULE_MARKERS) return false
|
||||
var seen = 0
|
||||
for (at in start until end) {
|
||||
val character = code[at]
|
||||
if (character == marker) seen++ else if (!character.isWhitespace()) return false
|
||||
}
|
||||
if (seen < if (marker == '=') 1 else 3) return false
|
||||
emit(start, end, Kind.MARK)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Draws a list marker if the line opens with one, and answers where the item's text starts. */
|
||||
private fun bullet(start: Int, end: Int): Int {
|
||||
val marker = code[start]
|
||||
if (marker in BULLETS && spaceOrEnd(start + 1, end)) {
|
||||
emit(start, start + 1, Kind.MARK)
|
||||
return indented(start + 1, end)
|
||||
}
|
||||
var digits = start
|
||||
while (digits < end && code[digits].isDigit()) digits++
|
||||
val delimiter = code.getOrNull(digits)
|
||||
if (
|
||||
digits > start && (delimiter == '.' || delimiter == ')') && spaceOrEnd(digits + 1, end)
|
||||
) {
|
||||
emit(start, digits + 1, Kind.MARK)
|
||||
return indented(digits + 1, end)
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
private fun spaceOrEnd(at: Int, end: Int) = at >= end || code[at] == ' ' || code[at] == '\t'
|
||||
|
||||
/**
|
||||
* The inline forms, left to right.
|
||||
*
|
||||
* Every branch answers a position strictly after [start] of its call, so this terminates
|
||||
* whether or not the form it was looking at turned out to be one.
|
||||
*/
|
||||
private fun inline(start: Int, end: Int) {
|
||||
var at = start
|
||||
while (at < end) {
|
||||
val character = code[at]
|
||||
at =
|
||||
when {
|
||||
// A backslash takes the character after it out of the running entirely, which
|
||||
// is how `\*` stays an asterisk rather than opening emphasis.
|
||||
character == '\\' -> at + 2
|
||||
character == '`' -> codeSpan(at, end)
|
||||
character == '[' -> link(at, at, end)
|
||||
character == '!' && code.getOrNull(at + 1) == '[' -> link(at, at + 1, end)
|
||||
character == '<' -> autolink(at, end)
|
||||
character in EMPHASIS -> emphasis(at, end)
|
||||
else -> url(at, end) ?: (at + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `` `code` ``, closed by a run of exactly as many backticks as opened it. That count is what
|
||||
* lets a span hold a backtick of its own, and why the search skips over a shorter or longer run
|
||||
* rather than stopping at the first backtick.
|
||||
*/
|
||||
private fun codeSpan(start: Int, end: Int): Int {
|
||||
var open = start
|
||||
while (open < end && code[open] == '`') open++
|
||||
val ticks = open - start
|
||||
var at = open
|
||||
while (at < end) {
|
||||
if (code[at] != '`') {
|
||||
at++
|
||||
continue
|
||||
}
|
||||
var close = at
|
||||
while (close < end && code[close] == '`') close++
|
||||
if (close - at == ticks) {
|
||||
emit(start, close, Kind.STRING)
|
||||
return close
|
||||
}
|
||||
at = close
|
||||
}
|
||||
// Nothing closes it on this line, so those were ordinary backticks.
|
||||
return open
|
||||
}
|
||||
|
||||
/**
|
||||
* `[text](destination)`, and the same with a leading `!` for an image.
|
||||
*
|
||||
* The text is drawn as prose -- it is what the reader reads -- so only the brackets around it
|
||||
* are marked, and the destination is metadata. A `[text]` with no destination after it is left
|
||||
* plain, because that is what a reference link and a bracketed aside look like.
|
||||
*/
|
||||
private fun link(start: Int, bracket: Int, end: Int): Int {
|
||||
var depth = 0
|
||||
var close = bracket
|
||||
while (close < end) {
|
||||
when (code[close]) {
|
||||
'\\' -> close++
|
||||
'[' -> depth++
|
||||
']' -> {
|
||||
depth--
|
||||
if (depth == 0) break
|
||||
}
|
||||
}
|
||||
close++
|
||||
}
|
||||
if (close >= end) return start + 1
|
||||
val destination = close + 1
|
||||
if (code.getOrNull(destination) != '(') return start + 1
|
||||
val paren = code.indexOf(')', destination)
|
||||
if (paren < 0 || paren >= end) return start + 1
|
||||
emit(start, bracket + 1, Kind.MARK)
|
||||
inline(bracket + 1, close)
|
||||
emit(close, destination, Kind.MARK)
|
||||
emit(destination, paren + 1, Kind.METADATA)
|
||||
return paren + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* `<https://example.com>` and `<name@example.com>`, drawn as the destination they are.
|
||||
*
|
||||
* The angle brackets have to hold no whitespace and something that makes an address of it -- a
|
||||
* scheme's colon or an at sign -- which is what keeps an HTML tag out.
|
||||
*/
|
||||
private fun autolink(start: Int, end: Int): Int {
|
||||
var at = start + 1
|
||||
var addressed = false
|
||||
while (at < end) {
|
||||
val character = code[at]
|
||||
if (character.isWhitespace() || character == '<') return start + 1
|
||||
if (character == '>') {
|
||||
if (!addressed) return start + 1
|
||||
emit(start, at + 1, Kind.METADATA)
|
||||
return at + 1
|
||||
}
|
||||
if (character == ':' || character == '@') addressed = true
|
||||
at++
|
||||
}
|
||||
return start + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare `scheme://…` written in prose, or null if one does not start here.
|
||||
*
|
||||
* A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry.
|
||||
*
|
||||
* Where it ends is the part worth stating: the sentence's punctuation is not the address, so a
|
||||
* trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the
|
||||
* URL -- otherwise a link in parentheses loses its `)`. A pipe stops it too, because a URL in a
|
||||
* table cell must not swallow the cell's edge.
|
||||
*/
|
||||
private fun url(start: Int, end: Int): Int? {
|
||||
if (start > 0 && isWord(code[start - 1])) return null
|
||||
var scheme = start
|
||||
while (scheme < end && code[scheme].isLetter()) scheme++
|
||||
if (scheme == start || !code.startsWith("://", scheme)) return null
|
||||
val body = scheme + 3
|
||||
var at = body
|
||||
var openers = 0
|
||||
var closers = 0
|
||||
while (at < end && !code[at].isWhitespace() && code[at] !in URL_STOPS) {
|
||||
if (code[at] == '(') openers++ else if (code[at] == ')') closers++
|
||||
at++
|
||||
}
|
||||
while (at > body) {
|
||||
val last = code[at - 1]
|
||||
if (last in URL_TRAILING) at--
|
||||
else if (last == ')' && closers > openers) {
|
||||
closers--
|
||||
at--
|
||||
} else break
|
||||
}
|
||||
if (at == body) return null
|
||||
emit(start, at, Kind.METADATA)
|
||||
return at
|
||||
}
|
||||
|
||||
/**
|
||||
* `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all -- which is how the
|
||||
* token scanner draws a string: the quotes are part of the thing.
|
||||
*
|
||||
* The two guards keep this off code that happens to be in a paragraph: the opener must be
|
||||
* followed by something to emphasise and the closer preceded by something emphasised, so `a * b
|
||||
* * c` opens nothing and neither does the `*p = *q` of a C fragment. Underscores may not start
|
||||
* or end inside a word, or every `snake_case_name` would be half emphasised.
|
||||
*/
|
||||
private fun emphasis(start: Int, end: Int): Int {
|
||||
val marker = code[start]
|
||||
var open = start
|
||||
while (open < end && code[open] == marker) open++
|
||||
val length = open - start
|
||||
if (marker == '~' && length != 2) return open
|
||||
if (length > 3) return open
|
||||
if (open == end || code[open].isWhitespace()) return open
|
||||
if (marker == '_' && start > 0 && isWord(code[start - 1])) return open
|
||||
var at = open
|
||||
while (at < end) {
|
||||
if (code[at] == '\\') {
|
||||
at += 2
|
||||
continue
|
||||
}
|
||||
if (code[at] != marker) {
|
||||
at++
|
||||
continue
|
||||
}
|
||||
var close = at
|
||||
while (close < end && code[close] == marker) close++
|
||||
val finish = at + length
|
||||
if (
|
||||
close - at >= length &&
|
||||
!code[at - 1].isWhitespace() &&
|
||||
!(marker == '_' && finish < end && isWord(code[finish]))
|
||||
) {
|
||||
emit(start, finish, Kind.LITERAL)
|
||||
return finish
|
||||
}
|
||||
at = close
|
||||
}
|
||||
return open
|
||||
}
|
||||
}
|
||||
|
||||
private fun isWord(character: Char) = character.isLetterOrDigit() || character == '_'
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -24,12 +25,11 @@ import androidx.compose.ui.unit.dp
|
||||
* 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.
|
||||
* HTML. It is really the opposite: a claim about where something came from, and "I was told this
|
||||
* before" and "I worked this out just now" are different things the reader cannot otherwise tell
|
||||
* apart.
|
||||
*
|
||||
* A tag that has not finished arriving is left alone. Streaming means the closing tag may be
|
||||
* seconds away, and a half-written marker is not a marker yet.
|
||||
* A tag that has not finished arriving is left alone: a half-written marker is not a marker yet.
|
||||
*/
|
||||
@Composable
|
||||
fun AssistantMessage(
|
||||
@@ -65,12 +65,10 @@ fun AssistantMessage(
|
||||
* 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.
|
||||
* the rows draw: a string warmed under a key no row ever looks up is a miss nothing reports.
|
||||
*
|
||||
* Public because [transcriptUnits] flattens settled replies into the same parts; go through
|
||||
* [ParsedReplies.partsOf] on any path that runs per fold or per page, so the scan happens once per
|
||||
* message.
|
||||
* [ParsedReplies.partsOf] on any path that runs per fold or per page.
|
||||
*/
|
||||
fun messageParts(text: String): List<MessagePart> {
|
||||
val parts = splitMemoryNotes(text)
|
||||
@@ -82,16 +80,14 @@ fun messageParts(text: String): List<MessagePart> {
|
||||
*
|
||||
* 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.
|
||||
* reply in half around a card, and these arrive several to a message.
|
||||
*
|
||||
* What stays visible is which file it came from, because that is the whole of what the note claims
|
||||
* and it is the part a reader scanning for "why does it think that" is looking for.
|
||||
* and the part a reader scanning for "why does it think that" is looking for.
|
||||
*
|
||||
* Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to
|
||||
* still be open on the way back, and a card that remembered for itself would forget the moment the
|
||||
* list stopped composing it. 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.
|
||||
* list stopped composing it.
|
||||
*/
|
||||
@Composable
|
||||
fun MemoryNote(
|
||||
@@ -123,7 +119,12 @@ fun MemoryNote(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (expanded) MarkdownText(note.text, replies, Modifier.padding(top = 4.dp))
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,10 +143,8 @@ 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.
|
||||
* Splits [text] into prose and memory notes, in order. Always returns at least one part, so a
|
||||
* message with no notes is one piece of prose and costs nothing extra to draw.
|
||||
*/
|
||||
fun splitMemoryNotes(text: String): List<MessagePart> {
|
||||
val parts = mutableListOf<MessagePart>()
|
||||
|
||||
@@ -5,26 +5,23 @@ package com.example.aiapp
|
||||
*
|
||||
* 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.
|
||||
* It is also the Claude CLI's own word for "whatever is configured".
|
||||
*/
|
||||
const val DEFAULT_MODEL = "default"
|
||||
|
||||
/**
|
||||
* A model's name as a person reads it.
|
||||
*
|
||||
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to
|
||||
* `claude-haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session
|
||||
* using" and far too long for a button in a row that also has to hold Stop and Send.
|
||||
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to `claude-
|
||||
* haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session using"
|
||||
* and far too long for a button in a row that also holds Stop and Send.
|
||||
*
|
||||
* So the two ends that identify nothing are dropped and nothing else is: the vendor prefix, which
|
||||
* is the same on every model this app can show, and the release date, which distinguishes builds of
|
||||
* one model rather than one model from another. 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.
|
||||
* one model rather than one model from another. Anything that does not look like that is returned
|
||||
* untouched.
|
||||
*
|
||||
* 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.
|
||||
* A display decision, not a correction: the full name is what the session reports.
|
||||
*/
|
||||
fun modelLabel(model: String?): String {
|
||||
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
|
||||
|
||||
@@ -57,12 +57,9 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Polled rather than pushed: a download belongs to the machine, not to any session, so it has
|
||||
// no event stream of its own. Keyed on the token as well, so the header's Refresh restarts the
|
||||
// loop with a read now rather than leaving the reader watching for a second and a half.
|
||||
LaunchedEffect(reloadToken) {
|
||||
while (true) {
|
||||
reload()
|
||||
@@ -186,11 +183,9 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
// 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 -> {}
|
||||
@@ -257,15 +252,15 @@ private fun DownloadCard(download: Download, onCancel: () -> Unit) {
|
||||
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.
|
||||
// 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.
|
||||
// nearing success, and colouring it like a limit being approached would say the
|
||||
// opposite.
|
||||
color = progressColor,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -327,8 +322,8 @@ private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
|
||||
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.
|
||||
// The owner is the part that repeats; the model name at the end is what tells
|
||||
// two entries apart.
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
)
|
||||
Text(
|
||||
@@ -356,11 +351,9 @@ private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -
|
||||
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.
|
||||
// Disabled rather than absent, so the row reads the same whether this one is absent,
|
||||
// already here, or on its way. Offering "Download" for a file that is downloading would be
|
||||
// a button that does nothing anyone can see.
|
||||
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
|
||||
Text(
|
||||
when {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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
|
||||
@@ -23,26 +26,21 @@ import androidx.compose.ui.unit.sp
|
||||
* 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.
|
||||
* grounds that a system font may not have the glyph. That objection is about *relying* on a system
|
||||
* font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
|
||||
* is `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
|
||||
* font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
|
||||
* the script did not subset is a glyph that silently isn't there.
|
||||
*
|
||||
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
|
||||
* That is what makes two icons the same size without either 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.
|
||||
* That is what makes two icons the same size without either being given a size: the proportional
|
||||
* face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side by side came
|
||||
* out visibly different widths. [GLYPH_SIZE] carries the cost.
|
||||
*
|
||||
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
|
||||
* Material Design codepoints. Those two must not drift: 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.
|
||||
* Material Design codepoints. Those two must not drift. The script is copied rather than shared
|
||||
* because most of what looks like duplication is the `GLYPHS` list, which has to differ -- the
|
||||
* point of subsetting is to ship only the codepoints one app draws.
|
||||
*/
|
||||
val NerdIcons = FontFamily(Font(R.font.nerd_icons))
|
||||
|
||||
@@ -71,8 +69,7 @@ val STOP_GLYPH = glyph(0xF04DB)
|
||||
*
|
||||
* 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.
|
||||
* ends a turn and nothing else, which is a pause, not a stop.
|
||||
*/
|
||||
val PAUSE_GLYPH = glyph(0xF03E4)
|
||||
|
||||
@@ -83,8 +80,8 @@ 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.
|
||||
* starting one, and one glyph doing both jobs would promise something immediate and do something
|
||||
* that waits.
|
||||
*/
|
||||
val QUEUE_GLYPH = glyph(0xF1163)
|
||||
|
||||
@@ -101,8 +98,7 @@ 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.
|
||||
* for by name, and Material's chart glyphs are a bare line where this one has its axes.
|
||||
*/
|
||||
val USAGE_GLYPH = glyph(0xF201)
|
||||
|
||||
@@ -114,14 +110,39 @@ val USAGE_GLYPH = glyph(0xF201)
|
||||
*/
|
||||
val SPEED_GLYPH = glyph(0xF04C5)
|
||||
|
||||
/**
|
||||
* `md-folder` -- the files on the machine this session runs on.
|
||||
*
|
||||
* The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and
|
||||
* the refresh arrow must not. Doubles as the mark on a directory row inside the explorer, which is
|
||||
* what makes the button say where it leads.
|
||||
*/
|
||||
val FOLDER_GLYPH = glyph(0xF024B)
|
||||
|
||||
/** `md-file_outline` -- one file, in a listing beside the directories. */
|
||||
val FILE_GLYPH = glyph(0xF0224)
|
||||
|
||||
/** `md-plus` -- make something here. dev-updater's codepoint as well. */
|
||||
val PLUS_GLYPH = glyph(0xF0415)
|
||||
|
||||
/** `md-pencil` -- change what this file says, rather than only reading it. */
|
||||
val EDIT_GLYPH = glyph(0xF03EB)
|
||||
|
||||
/**
|
||||
* `md-content_save` -- write the edits back to the machine.
|
||||
*
|
||||
* The floppy disk, which is what save has meant for longer than most of the people reading it have
|
||||
* been alive and is still the only mark anybody recognises for it.
|
||||
*/
|
||||
val SAVE_GLYPH = glyph(0xF0193)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* most 0.83 em of its point size, so the number was standing in for the headroom above the tallest
|
||||
* one; in the Mono face every glyph fills its em exactly, and keeping 20 would have stepped every
|
||||
* icon in the app up by a fifth.
|
||||
*/
|
||||
private val GLYPH_SIZE = 17.sp
|
||||
|
||||
@@ -135,16 +156,14 @@ private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
|
||||
*
|
||||
* 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
|
||||
* its own. That is what it was: the box was the size of the mark (28dp) and the separation was
|
||||
* bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the
|
||||
* edge, so a pair that acts on one screen read as two unrelated marks with one falling off it.
|
||||
* edge.
|
||||
*
|
||||
* 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.
|
||||
* to find, and what the pressed-state ripple draws: at 28dp that circle was inscribed in the mark's
|
||||
* own corners and beside a title it arrived at the first letter. And it is taller than any header's
|
||||
* text, which is what lets the button fill a header row rather than sit in the middle of one.
|
||||
*/
|
||||
private val GLYPH_BUTTON_SIZE = 48.dp
|
||||
|
||||
@@ -152,11 +171,9 @@ 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.
|
||||
* Two glyph buttons need nothing between them: each brings its own ring and the two add up. Text
|
||||
* brings none, so the second ring has to be asked for -- without it the pressed-state circle
|
||||
* arrives at the first letter of the title.
|
||||
*/
|
||||
val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
|
||||
|
||||
@@ -165,8 +182,7 @@ val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
|
||||
*
|
||||
* 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.
|
||||
* which is also the answer to "what was that button for" six months from now.
|
||||
*
|
||||
* [enabled] is passed through rather than left to callers hiding the button: a control that comes
|
||||
* and goes makes its own absence the signal, and absence cannot say whether there was nothing to do
|
||||
@@ -180,13 +196,49 @@ fun GlyphButton(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
colour: Color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
MarkButton(label, onClick, modifier, enabled) {
|
||||
Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same square, around a mark that is not a glyph.
|
||||
*
|
||||
* A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the
|
||||
* size, spacing and touch target every other icon button already is. The caller still owes it a
|
||||
* [label]: nothing here draws a word.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
mark: @Composable () -> Unit,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
|
||||
) {
|
||||
Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline)
|
||||
mark()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The square a glyph button occupies, with a spinner in it instead of a mark.
|
||||
*
|
||||
* For a button whose work is under way. It takes the button's whole box rather than the mark's, so
|
||||
* swapping one for the other leaves everything in the row exactly where it was.
|
||||
*/
|
||||
@Composable
|
||||
fun GlyphSpinner(label: String, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(GLYPH_EXTENT), strokeWidth = 2.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,10 +256,9 @@ fun Glyph(
|
||||
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.
|
||||
// ascent and descent add up to exactly one em. Left to the inherited body style the line box
|
||||
// was 24sp tall around a 17sp-wide mark, so a glyph took a seventh more vertical space than
|
||||
// horizontal.
|
||||
Text(
|
||||
glyph,
|
||||
fontFamily = NerdIcons,
|
||||
|
||||
@@ -34,12 +34,11 @@ import org.json.JSONObject
|
||||
* gets a push from Google's servers, which would mean this backend talking to Google about
|
||||
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
|
||||
*
|
||||
* 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.
|
||||
* The cost Android charges is a notification of its own that cannot be dismissed. That is made as
|
||||
* quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
|
||||
* no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
|
||||
* cannot be and because it should not be: it is the honest indicator that something is holding a
|
||||
* connection open.
|
||||
*/
|
||||
class NotificationService : Service() {
|
||||
@Volatile private var stream: HttpURLConnection? = null
|
||||
@@ -50,18 +49,18 @@ class NotificationService : Service() {
|
||||
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.
|
||||
// Nothing to connect to. Stopping rather than idling: a service holding no connection
|
||||
// still costs the ongoing notification, which would be announcing work that is not
|
||||
// happening.
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// Through ServiceCompat so the type is stated once and ignored on
|
||||
// the versions that predate types, rather than branching here.
|
||||
// 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.
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -73,11 +72,10 @@ class NotificationService : Service() {
|
||||
/**
|
||||
* 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.
|
||||
* A dropped connection is the ordinary case here rather than an error, so it retries quietly
|
||||
* and forever. Nothing is shown when it cannot connect: a notification saying "I could not tell
|
||||
* you whether anything happened" is noise about a condition nobody can act on, and the session
|
||||
* list already says what is waiting when they next look.
|
||||
*/
|
||||
private fun follow(settings: ServerSettings) {
|
||||
while (!stopping) {
|
||||
@@ -102,8 +100,8 @@ class NotificationService : Service() {
|
||||
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.
|
||||
// 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")
|
||||
@@ -134,28 +132,23 @@ class NotificationService : Service() {
|
||||
*
|
||||
* 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.
|
||||
* question. A stack of stale rows is how a drawer becomes something to clear rather than read.
|
||||
*/
|
||||
private fun show(notification: SessionNotification) {
|
||||
// Nothing to tell somebody about the session they are reading. The transcript in front of
|
||||
// them is already saying it, and a sound over the top of it would be this app announcing
|
||||
// what the screen is showing.
|
||||
// them is already saying it.
|
||||
if (isOnScreen(notification.sessionId)) return
|
||||
// The app is up: it says this itself, as a banner over whatever screen they are on. 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.
|
||||
// The app is up: it says this itself, as a banner over whatever screen they are on. Never
|
||||
// both -- one thing happened, and a drawer filling up behind an app that already showed you
|
||||
// each one is a drawer nobody reads.
|
||||
if (handOver(notification)) return
|
||||
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.
|
||||
// refused, and notifications switched off for the app in Android's own settings.
|
||||
//
|
||||
// The permission only exists from Android 13. Asking an older version about it gets
|
||||
// "denied" for a name it does not know, which read as the person having said no -- so
|
||||
// every notification on Android 12 and below was silently dropped. Before 13 the
|
||||
// switch in Android's own settings, checked below, is the whole of the answer.
|
||||
// "denied" for a name it does not know, which read as the person having said no -- so every
|
||||
// notification on Android 12 and below was silently dropped.
|
||||
val allowed =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
@@ -187,9 +180,8 @@ class NotificationService : Service() {
|
||||
* 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.
|
||||
* compile time and would be handed to platforms that have no concept of it, which is what
|
||||
* lint's InlinedApi exists to catch.
|
||||
*/
|
||||
private fun foregroundType(): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
@@ -226,11 +218,10 @@ class NotificationService : Service() {
|
||||
/**
|
||||
* 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.
|
||||
* The alerts are what somebody turned this on for, so they get the default importance. The
|
||||
* ongoing one is the platform's tax for staying connected, so it takes the lowest
|
||||
* importance that exists. Both are created before the service starts, since posting to a
|
||||
* channel that does not exist is silently dropped.
|
||||
*/
|
||||
private fun createChannels(context: Context) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
@@ -256,12 +247,10 @@ class NotificationService : Service() {
|
||||
* 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.
|
||||
* service and the composition draws the screen, so the two have no common owner. Clearing
|
||||
* names the session rather than setting null outright, because moving from one session to
|
||||
* another composes the new screen before the old one's coroutine is cancelled -- an
|
||||
* unconditional clear would throw away the new screen's claim.
|
||||
*/
|
||||
@Volatile private var onScreen: String? = null
|
||||
|
||||
@@ -271,10 +260,9 @@ class NotificationService : Service() {
|
||||
* 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.
|
||||
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
|
||||
* nor blocks the thread reading the stream, and the buffer is there so a handful of
|
||||
* sessions finishing together all land rather than the last one winning.
|
||||
*/
|
||||
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
|
||||
|
||||
@@ -287,9 +275,8 @@ class NotificationService : Service() {
|
||||
/** 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.
|
||||
// Whatever was posted about it before is about to be read, so it has nothing left to
|
||||
// say.
|
||||
NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID)
|
||||
}
|
||||
|
||||
@@ -311,8 +298,8 @@ class NotificationService : Service() {
|
||||
* 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.
|
||||
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look
|
||||
* at.
|
||||
*
|
||||
* The id rides in the intent's **data** rather than in an extra, which is not a style choice:
|
||||
* PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring
|
||||
@@ -345,9 +332,8 @@ data class SessionNotification(
|
||||
* 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.
|
||||
* nothing to somebody reading a lock screen. One function because the same fact is shown in two
|
||||
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift.
|
||||
*/
|
||||
fun attentionLine(kind: String): String =
|
||||
when (kind) {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@@ -30,13 +31,12 @@ import androidx.compose.ui.unit.dp
|
||||
*
|
||||
* 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.
|
||||
* follows.
|
||||
*
|
||||
* Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block,
|
||||
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for 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.
|
||||
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for what that bought;
|
||||
* what matters here is that the pieces have to add up to the card that was there before, so the
|
||||
* fill, the corner radius and the padding all live in [peerSurface].
|
||||
*/
|
||||
@Composable
|
||||
fun PeerHeadRow(
|
||||
@@ -87,25 +87,28 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl
|
||||
onPress = 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))
|
||||
// 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.
|
||||
* A filled Material card is elevation zero, so there is no shadow that a seam would show through --
|
||||
* which is the whole reason a card can be cut up at all. Each piece paints the caller's container
|
||||
* colour and rounds only the corners at the ends of the message, so the pieces abut into one
|
||||
* continuous card. Shared by the two rows cut this way -- an opened peer message and a long user
|
||||
* message -- because two copies of the corner logic is how one of them grows a seam.
|
||||
*
|
||||
* The padding is the other half 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.
|
||||
* The padding is the other half: 12dp all round was the card's own, so the top piece keeps the top
|
||||
* of it, the bottom piece the bottom, and the middle pieces neither.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.cardPiece(
|
||||
|
||||
@@ -17,6 +17,7 @@ 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
|
||||
@@ -33,13 +34,10 @@ 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.
|
||||
* find out *which* image was to send it. A control belongs with the thing it acts on.
|
||||
*
|
||||
* Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is
|
||||
* in it, so four attachments look like four of the same thing rather than four smaller ones. A file
|
||||
* is a tile of the same height carrying its name, since a name is all there is to show of it.
|
||||
* in it, so four attachments look like four of the same thing rather than four smaller ones.
|
||||
*/
|
||||
@Composable
|
||||
fun PendingAttachments(
|
||||
@@ -66,8 +64,7 @@ fun PendingAttachments(
|
||||
*
|
||||
* 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.
|
||||
* corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip.
|
||||
*/
|
||||
@Composable
|
||||
private fun PendingThumbnail(
|
||||
@@ -83,8 +80,7 @@ private fun PendingThumbnail(
|
||||
.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.
|
||||
// background the tile then had no edge at all.
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||
// Behind the picture as well as under a missing one, so the tile is a tile before
|
||||
// anything has arrived to fill it.
|
||||
@@ -97,11 +93,18 @@ private fun PendingThumbnail(
|
||||
// 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 ->
|
||||
Text(
|
||||
if (failed) "!" else "…",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (failed) {
|
||||
Text(
|
||||
"!",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
// A spinner, as the transcript's images have: one appearance for "a picture is
|
||||
// on its way", learned once. An ellipsis had to be read as a spinner not
|
||||
// moving.
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
else ->
|
||||
Image(
|
||||
bitmap = image,
|
||||
@@ -110,14 +113,12 @@ private fun PendingThumbnail(
|
||||
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 whole square removes it, and this only says so. A cross small enough to sit in the
|
||||
// corner of a 64dp thumbnail is smaller than a fingertip.
|
||||
//
|
||||
// The disc is sized here and the mark centred inside it, rather than the glyph being
|
||||
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box
|
||||
// to the corner hung the visible mark over the edge and put its backing somewhere the
|
||||
// eye reads as a second, misplaced square.
|
||||
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box to
|
||||
// the corner hung the visible mark over the edge.
|
||||
Box(
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
.padding(2.dp)
|
||||
|
||||
@@ -3,15 +3,13 @@ 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.
|
||||
// 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.
|
||||
// The pinning itself lives in wg-app-link, since dev-updater needs exactly the same thing. What
|
||||
// stays here is which certificate this app pins.
|
||||
private val pinned = PinnedTls(PINNED_CA_PEM)
|
||||
|
||||
/** Every request this app makes goes through this -- there is no unpinned path. */
|
||||
|
||||
@@ -16,20 +16,17 @@ import androidx.compose.ui.unit.dp
|
||||
*
|
||||
* 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.
|
||||
* copies of "clip, fill, pad" drift apart the first time one 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.
|
||||
* The colour is [rawSurface], which is also what a code block inside a reply is given.
|
||||
*/
|
||||
@Composable
|
||||
fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a
|
||||
// rounded rectangle drawn at the same radius as the rounded rectangle behind it reads
|
||||
// as a misprint rather than as nesting.
|
||||
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a rounded
|
||||
// rectangle drawn at the same radius as the one behind it reads as a misprint.
|
||||
.clip(MaterialTheme.shapes.extraSmall)
|
||||
.background(rawSurface)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
|
||||
@@ -4,16 +4,25 @@ 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.
|
||||
// arithmetic is the same in both, so everything here returns the span or the state on its own and
|
||||
// leaves the wording to the caller.
|
||||
|
||||
/** "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words. */
|
||||
fun formatSpan(until: Duration): String =
|
||||
when {
|
||||
until.toHours() >= 24 -> "${until.toDays()}d ${until.toHours() % 24}h"
|
||||
until.toHours() > 0 -> "${until.toHours()}h ${until.toMinutes() % 60}m"
|
||||
else -> "${until.toMinutes()}m"
|
||||
/**
|
||||
* "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words.
|
||||
*
|
||||
* Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s
|
||||
* left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the
|
||||
* figure on a minute it has already spent. One rule, so the session bar and the usage dialog cannot
|
||||
* round a shared measurement two different ways.
|
||||
*/
|
||||
fun formatSpan(until: Duration): String {
|
||||
val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1)
|
||||
return when {
|
||||
up.toHours() >= 24 -> "${up.toDays()}d ${up.toHours() % 24}h"
|
||||
up.toHours() > 0 -> "${up.toHours()}h ${up.toMinutes() % 60}m"
|
||||
else -> "${up.toMinutes()}m"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What is known about when a usage window ends.
|
||||
@@ -21,14 +30,12 @@ fun formatSpan(until: Duration): String =
|
||||
* 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.
|
||||
* counting down and the API says so by omitting the field. A timestamp that did arrive and could
|
||||
* not be read is the genuinely unknown case.
|
||||
*
|
||||
* Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on
|
||||
* the one row somebody reads before starting something big -- and the usage dialog, looking at the
|
||||
* same field, quietly drew nothing. Two rules for one missing value; this is the rule.
|
||||
* same field, quietly drew nothing.
|
||||
*/
|
||||
sealed class WindowEnd {
|
||||
/** No reset time was sent, so nothing is running in this window. Not a failure to find out. */
|
||||
|
||||
@@ -10,24 +10,21 @@ private const val ANCHORS = "session-scroll"
|
||||
*
|
||||
* 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.
|
||||
* fetched newest-first. The row key looks stable and is not: a tool row is named after its run,
|
||||
* `joinPages` gives a run the name of its newest half, and the newest half is whatever the newest
|
||||
* page started with -- so an active session renames its tool runs every time it is reopened. A seq
|
||||
* is the server's own numbering, assigned once and never moved.
|
||||
*
|
||||
* [unit] is which unit of the row the viewport started at -- 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.
|
||||
* [unit] is which unit of the row the viewport started at and [offset] how far that unit was
|
||||
* scrolled past the viewport's newest edge. A seq alone is not a place: a reply is one seq and can
|
||||
* be forty blocks long.
|
||||
*/
|
||||
data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0)
|
||||
|
||||
/**
|
||||
* On this device rather than on the backend, which is where this app otherwise keeps state so every
|
||||
* device sees it. Scroll position is the same exception a draft is: it is where the phone in
|
||||
* somebody's hand is pointed, and having one device jump because another was scrolled would be a
|
||||
* surprise rather than a convenience.
|
||||
* somebody's hand is pointed.
|
||||
*/
|
||||
fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
|
||||
val stored =
|
||||
@@ -36,8 +33,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
|
||||
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.
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -45,9 +42,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
|
||||
* 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.
|
||||
* left at the bottom has nothing to restore. A session *deleted* while it held an anchor leaves its
|
||||
* key behind, for the reason and at the cost `Drafts.kt` describes.
|
||||
*/
|
||||
fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) {
|
||||
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit {
|
||||
|
||||
@@ -14,10 +14,9 @@ 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.
|
||||
* Both values are load-bearing. The scheme is what routes a scanned QR here rather than to Dev
|
||||
* Updater, and the key alias names the Android Keystore key the token is already sealed under on
|
||||
* every enrolled phone -- changing it would leave those phones reading as not enrolled.
|
||||
*/
|
||||
private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key")
|
||||
|
||||
|
||||
@@ -33,16 +33,14 @@ 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`.
|
||||
* Two places can carry the same fact and only one is right at a time. A row in the shade is for
|
||||
* somebody looking at something else: it makes a sound, it waits however long it has to, and acting
|
||||
* on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
|
||||
* while these are on screen the stream is delivered here instead, which is arranged by the
|
||||
* collection below and nothing else.
|
||||
*
|
||||
* 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.
|
||||
* A banner can go three ways, each somebody deciding something different: tapped, which opens the
|
||||
* session; pushed off either side; or left alone, in which case it goes when the bar runs out.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) {
|
||||
@@ -58,28 +56,26 @@ fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Mod
|
||||
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.
|
||||
// drawer follows: a session that finished and then asked a question is one
|
||||
// thing to know about, the question. It keeps its place in the queue rather
|
||||
// than moving to the end, because the reader may already be reaching for it.
|
||||
val already = queue.indexOfFirst {
|
||||
it.notification.sessionId == notification.sessionId
|
||||
}
|
||||
if (already >= 0) queue[already] = alert else queue.add(alert)
|
||||
}
|
||||
} finally {
|
||||
// Leaving the app hands the job back to the drawer, so nothing arriving while it
|
||||
// is away is lost. What would be lost is the truth of what is already up: these
|
||||
// say a session wants somebody *now*, and one still sitting here on a return
|
||||
// several minutes later is a claim nobody checked. Frozen, too -- Compose stops
|
||||
// the clock with the window, so the timer that was going to retire it has been
|
||||
// standing still the whole time.
|
||||
// Leaving the app hands the job back to the drawer, so nothing arriving while it is
|
||||
// away is lost. What would be lost is the truth of what is already up: these say a
|
||||
// session wants somebody *now*, and one still sitting here on a return several
|
||||
// minutes later is a claim nobody checked. Frozen, too -- Compose stops the clock
|
||||
// with the window.
|
||||
queue.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Oldest at the top, so a new one appears below the ones already being read instead of
|
||||
// shoving them down the screen mid-reach.
|
||||
// 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) {
|
||||
@@ -103,9 +99,7 @@ private data class SessionAlert(val notification: SessionNotification, val arriv
|
||||
* 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.
|
||||
* would be two accounts of the same countdown and only one can be the one that fires.
|
||||
*/
|
||||
@Composable
|
||||
private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) {
|
||||
@@ -135,9 +129,7 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
|
||||
),
|
||||
// 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.
|
||||
// relying on colour alone reads as one more row in the way. The border is the one cue.
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
|
||||
) {
|
||||
@@ -145,8 +137,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
|
||||
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.
|
||||
// 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,
|
||||
)
|
||||
@@ -163,9 +155,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
|
||||
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.
|
||||
// judgement on it. Stated beside the track, which is the card's own colour so that
|
||||
// the spent part reads as empty rather than as a second bar.
|
||||
color = progressColor,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
drawStopIndicator = {},
|
||||
@@ -180,7 +171,6 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U
|
||||
* 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.
|
||||
* while somebody is still on the screen that produced them. The bar makes the number visible.
|
||||
*/
|
||||
private const val ALERT_LIFE_MS = 6_000
|
||||
@@ -9,6 +9,8 @@ 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
|
||||
@@ -20,6 +22,7 @@ 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
|
||||
@@ -46,10 +49,9 @@ 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.
|
||||
* 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 {
|
||||
@@ -72,15 +74,12 @@ fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: Stri
|
||||
* 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.
|
||||
* finished loading pushed everything below it, so a transcript being read scrolled itself -- and in
|
||||
* a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes.
|
||||
*
|
||||
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
|
||||
* than as a page of its own. 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.
|
||||
* than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the
|
||||
* screen, which draws [SessionImageViewer] outside the list.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionImage(
|
||||
@@ -94,14 +93,18 @@ fun SessionImage(
|
||||
val heightPx = with(LocalDensity.current) { height.roundToPx() }
|
||||
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
||||
when (val image = bitmap) {
|
||||
// Two states, not one: an image still arriving and an image that will never arrive look
|
||||
// nothing alike to a reader who can do something about the second.
|
||||
null ->
|
||||
Text(
|
||||
// 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.
|
||||
if (failed) "[image $ref unavailable]" else "[loading image…]",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (failed) {
|
||||
Text(
|
||||
"[image $ref unavailable]",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LoadingImage(height)
|
||||
}
|
||||
else ->
|
||||
Image(
|
||||
bitmap = image,
|
||||
@@ -122,15 +125,12 @@ fun SessionImage(
|
||||
* `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.
|
||||
* transcript because the session made another tool call.
|
||||
*
|
||||
* Held by the screen, none of that reaches it: what is open is a property of the screen, not of
|
||||
* whichever row happened to draw the thumbnail.
|
||||
* Held by the screen, none of that reaches it: what is open is a property of the screen.
|
||||
*
|
||||
* The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go
|
||||
* through. Paid deliberately 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.
|
||||
* through. Paid deliberately: it is one request for a picture somebody asked to see.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionImageViewer(
|
||||
@@ -149,27 +149,59 @@ fun SessionImageViewer(
|
||||
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.
|
||||
// Two states, not one, exactly as the thumbnail has them. Stated in white because
|
||||
// this box paints its own black behind them and a theme colour would be picked
|
||||
// against a surface that is not there.
|
||||
null ->
|
||||
Text(
|
||||
if (failed) "Image $ref is unavailable" else "Loading image…",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (failed) {
|
||||
Text(
|
||||
"Image $ref is unavailable",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else {
|
||||
// The whole dialog is the area this picture is about to fill, so the
|
||||
// spinner sits in the middle of it. White for the same reason the words
|
||||
// beside it are.
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
else -> 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 turn out to be.
|
||||
*
|
||||
* Tinted, so the reader can see that something is being kept for a picture -- which is also what
|
||||
* distinguishes it from the failure beside it, words on the ordinary surface.
|
||||
*/
|
||||
@Composable
|
||||
private fun LoadingImage(height: Dp) {
|
||||
Box(
|
||||
Modifier.size(height)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(LOADING_SPINNER), strokeWidth = 2.dp)
|
||||
}
|
||||
}
|
||||
|
||||
/** Small enough to sit inside the thumbnail's square without filling it. */
|
||||
private val LOADING_SPINNER = 24.dp
|
||||
|
||||
/**
|
||||
* Four lines of the body style the transcript is set in.
|
||||
*
|
||||
* Measured from the type rather than written as a dp, so it stays four lines when the text size
|
||||
* changes -- including when the reader has scaled fonts up, which is exactly when a hardcoded
|
||||
* height would be wrong.
|
||||
* changes -- including when the reader has scaled fonts up, which is when a hardcoded height is
|
||||
* wrong.
|
||||
*/
|
||||
@Composable
|
||||
private fun thumbnailHeight(): Dp {
|
||||
@@ -184,8 +216,7 @@ private fun thumbnailHeight(): 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.
|
||||
* image with hard pixel edges stays readable. Shrinking wants the opposite.
|
||||
*/
|
||||
private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality =
|
||||
if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High
|
||||
@@ -195,7 +226,7 @@ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality
|
||||
*
|
||||
* 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.
|
||||
* visible.
|
||||
*/
|
||||
@Composable
|
||||
private fun ZoomableImage(image: ImageBitmap) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -52,22 +53,22 @@ fun SessionListScreen(
|
||||
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.
|
||||
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
|
||||
// own card. The two scopes are decided by whether the server answered: it answered and refused,
|
||||
// so this says nothing about the other rows.
|
||||
//
|
||||
// Cleared on the next successful load below -- an entry outlives its
|
||||
// session otherwise, and would reappear against whatever the phone
|
||||
// fetched next.
|
||||
// Cleared on the next successful load below -- an entry outlives its session otherwise.
|
||||
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.
|
||||
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, because
|
||||
// the rows are rebuilt from whatever the server last said and this belongs to the request.
|
||||
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
|
||||
// This phone's copies of these sessions' transcripts, pruned from here because this is where a
|
||||
// session stops existing. See TranscriptCache.
|
||||
val context = LocalContext.current
|
||||
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
||||
|
||||
fun refresh() {
|
||||
listState = LoadState.Loading
|
||||
scope.launch {
|
||||
@@ -76,6 +77,13 @@ fun SessionListScreen(
|
||||
val loaded =
|
||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
|
||||
deleteErrors = emptyMap()
|
||||
// The path out for a cached transcript whose session was deleted somewhere
|
||||
// else. This list is the only place that ever learns the full set. On the
|
||||
// answer rather than in `finally`: a list that failed to arrive says nothing
|
||||
// about which sessions exist.
|
||||
withContext(Dispatchers.IO) {
|
||||
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
|
||||
}
|
||||
loaded
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
@@ -89,12 +97,9 @@ fun SessionListScreen(
|
||||
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.
|
||||
// The message as Api.kt wrote it, with nothing added: it is already a whole
|
||||
// sentence naming the address and what to check, so a prefix here read "Couldn't
|
||||
// reach the server: Couldn't reach the server at ...".
|
||||
is LoadState.Error ->
|
||||
Text(
|
||||
state.message,
|
||||
@@ -108,8 +113,7 @@ fun SessionListScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Awaiting-answer first (the point of the screen), then
|
||||
// most recently active.
|
||||
// Awaiting-answer first (the point of the screen), then most recently active.
|
||||
val ordered =
|
||||
state.value.sortedWith(
|
||||
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
|
||||
@@ -141,40 +145,37 @@ fun SessionListScreen(
|
||||
|
||||
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].
|
||||
// next. 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.
|
||||
// separates them is whether the *driver* keeps its own record of the conversation
|
||||
// -- the Claude Code CLI does, whether this app spawned the session or imported it;
|
||||
// echo and llama.cpp do not.
|
||||
//
|
||||
// This used to branch on `imported`, above a comment asserting that "a session
|
||||
// started here has no copy anywhere". That was 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.
|
||||
// started here has no copy anywhere". That was false for every claude-cli session
|
||||
// this app spawned, and getting it wrong in that direction is the expensive one:
|
||||
// "this can't be undone", said of something that can, spends the credibility the
|
||||
// sentence needs.
|
||||
//
|
||||
// Neither branch promises a restore. The recoverable one says what is known --
|
||||
// the driver keeps its own record -- rather than that the file is still there,
|
||||
// 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.
|
||||
// Neither branch promises a restore. The recoverable one says what is known -- the
|
||||
// driver keeps its own record -- rather than that the file is still there, and it
|
||||
// names what goes either way, because this app's transcript holds images, peer
|
||||
// messages and commands the CLI's own record never had.
|
||||
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.
|
||||
// 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 " +
|
||||
@@ -189,12 +190,12 @@ fun SessionListScreen(
|
||||
)
|
||||
// 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.
|
||||
// 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.
|
||||
// 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",
|
||||
@@ -215,19 +216,20 @@ fun SessionListScreen(
|
||||
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.
|
||||
// something is happening to it from the moment it is asked for.
|
||||
deleting = deleting + session.id
|
||||
deleteErrors = deleteErrors - session.id
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteSession(settings, session.id, alsoDeleteForeign)
|
||||
// After it succeeded, not before: a refused delete leaves the
|
||||
// session exactly as it was, and its transcript with it.
|
||||
transcriptCache.session(session.id).purge()
|
||||
}
|
||||
// Only this row, and only what changed. Refetching the list
|
||||
// instead put every other session back through loading and
|
||||
// handed the reader an empty screen -- to report on something
|
||||
// that was never in doubt.
|
||||
// Only this row, and only what changed. Refetching the list instead
|
||||
// put every other session back through loading and handed the
|
||||
// reader an empty screen, to report on something never in doubt.
|
||||
val loaded = listState
|
||||
if (loaded is LoadState.Loaded) {
|
||||
listState =
|
||||
@@ -246,8 +248,8 @@ fun SessionListScreen(
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Coloured by consequence: this takes something away, and does so wherever
|
||||
// it appears -- the same rule the import screen's Delete follows.
|
||||
// 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)
|
||||
}
|
||||
},
|
||||
@@ -269,8 +271,7 @@ private fun SessionCard(
|
||||
*
|
||||
* 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.
|
||||
* about a request that has not been answered yet.
|
||||
*/
|
||||
deleting: Boolean,
|
||||
onOpen: () -> Unit,
|
||||
@@ -278,9 +279,9 @@ private fun SessionCard(
|
||||
) {
|
||||
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.
|
||||
// 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,
|
||||
@@ -303,9 +304,9 @@ private fun SessionCard(
|
||||
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.
|
||||
// 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,
|
||||
@@ -324,8 +325,7 @@ private fun SessionCard(
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// The server's own words, unprefixed, the way every other
|
||||
// failure in this app is shown.
|
||||
// The server's own words, unprefixed, the way every other failure is shown.
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -345,16 +345,16 @@ fun StatusText(status: String) {
|
||||
"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.
|
||||
// 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.
|
||||
// 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,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -39,12 +39,11 @@ import kotlinx.coroutines.withContext
|
||||
* 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.
|
||||
* are changed *while* reading a turn -- "not this model, try that one".
|
||||
*
|
||||
* 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.
|
||||
* Captions are for what a control costs rather than for what it is. A paragraph under every control
|
||||
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
|
||||
* and Reload do, because what those two take away is not visible from here.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionSettingsDialog(
|
||||
@@ -55,7 +54,18 @@ fun SessionSettingsDialog(
|
||||
*/
|
||||
title: String,
|
||||
onRenamed: (String) -> Unit,
|
||||
/**
|
||||
* What this phone is holding of the conversation, or null while that is being measured -- see
|
||||
* the Reload row below, which is what would discard it.
|
||||
*/
|
||||
cachedBytes: Long?,
|
||||
onReload: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
/**
|
||||
* Copies what this session costs to draw. Built by the session screen, because everything it
|
||||
* measures is that screen's own state.
|
||||
*/
|
||||
onCopyRenderReport: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by remember(sessionId) { mutableStateOf(title) }
|
||||
@@ -63,16 +73,14 @@ fun SessionSettingsDialog(
|
||||
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.
|
||||
// position that may have been changed since. Until the answer arrives the switch is disabled
|
||||
// and a spinner sits beside it, which is what not knowing looks like.
|
||||
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
||||
var notifyError by remember { mutableStateOf<String?>(null) }
|
||||
// Where the session works. Null until the server has been asked, for the same reason the
|
||||
// switch above is: 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.
|
||||
// Where the session works. Null until the server has been asked, for the same reason the switch
|
||||
// above is. An empty answer is a session that was never given a directory, which is not the
|
||||
// same as one whose directory is unknown -- the field is only enabled once one of those is
|
||||
// settled.
|
||||
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
var typedCwd by remember(sessionId) { mutableStateOf("") }
|
||||
var cwdError by remember { mutableStateOf<String?>(null) }
|
||||
@@ -85,8 +93,8 @@ fun SessionSettingsDialog(
|
||||
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.
|
||||
// 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
|
||||
}
|
||||
@@ -118,8 +126,8 @@ fun SessionSettingsDialog(
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
@@ -149,7 +157,7 @@ fun SessionSettingsDialog(
|
||||
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.
|
||||
// knows a rename was attempted.
|
||||
error = e.message
|
||||
saving = false
|
||||
}
|
||||
@@ -168,8 +176,8 @@ fun SessionSettingsDialog(
|
||||
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.
|
||||
// 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() }),
|
||||
)
|
||||
@@ -194,8 +202,8 @@ fun SessionSettingsDialog(
|
||||
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.
|
||||
// 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,
|
||||
@@ -212,9 +220,9 @@ fun SessionSettingsDialog(
|
||||
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.
|
||||
// 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,
|
||||
@@ -234,9 +242,8 @@ fun SessionSettingsDialog(
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
// directory is settled when the process is spawned, so it is ended and the next
|
||||
// thing said to the session starts it in the new place.
|
||||
Text(
|
||||
"Moving stops the session's process. It starts again in the new directory " +
|
||||
"with the next message, or with Start.",
|
||||
@@ -250,6 +257,44 @@ fun SessionSettingsDialog(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Transcript", modifier = Modifier.weight(1f))
|
||||
// The size is what the button discards, and the unknown state is drawn rather
|
||||
// than guessed: a spinner while the directory is being measured, and words when
|
||||
// there is nothing there, because "nothing cached" and "0 B" read as different
|
||||
// claims.
|
||||
when {
|
||||
cachedBytes == null ->
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(16.dp).height(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
else ->
|
||||
Text(
|
||||
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
// Enabled whether or not anything is cached: "what I see disagrees with the
|
||||
// machine" is a state an empty cache can be in too, and a control that comes
|
||||
// and goes makes its own presence the signal.
|
||||
TextButton(onClick = onReload) { Text("Reload") }
|
||||
}
|
||||
// Captioned, unlike the controls above it, for the same reason Move is: what it
|
||||
// costs is not visible, and neither is the case it exists for.
|
||||
Text(
|
||||
"Reload throws away this phone's copy and fetches the transcript from the " +
|
||||
"server again. Use it when what is shown here disagrees with the file " +
|
||||
"on the machine.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
@@ -258,10 +303,24 @@ fun SessionSettingsDialog(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// About this session, which is what everything in here is -- and it was on the
|
||||
// header until 2026-09-03, where the folder button now is. It copies rather than
|
||||
// opening anything, so it says so and then says it happened: a row that looks like
|
||||
// a control and gives no sign of having run is one people press twice.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Render timings", modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
||||
}
|
||||
}
|
||||
},
|
||||
// Disabled rather than absent while there is nothing to save: a button that comes and
|
||||
// goes makes its own presence the signal, and its absence cannot say why.
|
||||
// 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")
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
@@ -33,20 +34,19 @@ sealed class 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.
|
||||
* Separate from [Unavailable], and the distinction is the point: a session on `echo` or on a
|
||||
* local llama.cpp has no paid quota at all, which is a fact about how it was set up and not a
|
||||
* failure to find something out. The backend never asks such a machine, and reading that
|
||||
* silence as "couldn't find out" is answering with the nearest available word.
|
||||
*/
|
||||
data object NotMetered : SessionUsage()
|
||||
|
||||
/**
|
||||
* The question could not be answered, and why.
|
||||
*
|
||||
* Its own state because "we couldn't find out" and "none of it is used" 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.
|
||||
* Its own state because "we couldn't find out" and "none of it is used" must never share an
|
||||
* appearance: a bar sitting at zero because a machine is unreachable reads as plenty of
|
||||
* headroom.
|
||||
*/
|
||||
data class Unavailable(val why: String) : SessionUsage()
|
||||
}
|
||||
@@ -55,28 +55,60 @@ sealed class SessionUsage {
|
||||
private const val REFRESH_MS = 60_000L
|
||||
|
||||
/**
|
||||
* One machine's rate limits, polled.
|
||||
* One poll of every machine's limits, and the handle to ask again.
|
||||
*
|
||||
* Hoisted out of [SessionUsageBar] because two things on a session's screen show this same answer
|
||||
* -- the bar, and the colour of the button that opens the usage dialog. Fetching it twice would
|
||||
* cost two round trips to say one thing, and the two copies would disagree for up to a minute at a
|
||||
* time, which is the interface contradicting itself about a number somebody is deciding on.
|
||||
* A screen shows this answer in more than one place -- the bar under the session header, the colour
|
||||
* of the button beside it, and the dialog that button opens -- and each used to fetch for itself.
|
||||
* Two fetches say one thing twice and then disagree: the bar's copy can be a whole refresh interval
|
||||
* old when the dialog opens with a fresh one, so the header read 42% while the screen over it read
|
||||
* 47%.
|
||||
*/
|
||||
class UsageFeed(
|
||||
val snapshots: LoadState<List<UsageSnapshot>>,
|
||||
/** A fetch is outstanding. Only ever true over an answer already shown. */
|
||||
val refreshing: Boolean,
|
||||
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
||||
val refresh: () -> Unit,
|
||||
) {
|
||||
/** What [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 rememberSessionUsage(settings: ServerSettings, setup: String): SessionUsage {
|
||||
var usage by remember(setup) { mutableStateOf<SessionUsage>(SessionUsage.Waiting) }
|
||||
LaunchedEffect(setup) {
|
||||
fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
|
||||
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
|
||||
var refreshing by remember { mutableStateOf(true) }
|
||||
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh also
|
||||
// resets the countdown rather than leaving one due immediately after.
|
||||
var asked by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(asked) {
|
||||
while (true) {
|
||||
usage =
|
||||
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 {
|
||||
usageFor(withContext(Dispatchers.IO) { fetchUsage(settings) }, setup)
|
||||
LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) })
|
||||
} catch (e: ApiException) {
|
||||
SessionUsage.Unavailable(e.message ?: "couldn't reach the backend")
|
||||
LoadState.failed(e)
|
||||
}
|
||||
refreshing = false
|
||||
delay(REFRESH_MS)
|
||||
}
|
||||
}
|
||||
return usage
|
||||
return remember(snapshots, refreshing) { UsageFeed(snapshots, refreshing) { asked++ } }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,13 +117,11 @@ fun rememberSessionUsage(settings: ServerSettings, setup: String): SessionUsage
|
||||
* 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.
|
||||
* passes windows it does not recognise straight through.
|
||||
*
|
||||
* Every state that is not a measurement takes the ordinary control colour instead. That is the
|
||||
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
|
||||
* unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind
|
||||
* the button is where those say, in words, which one they are.
|
||||
* unknown blue would say "measured, and fine" about a machine nobody could reach.
|
||||
*/
|
||||
@Composable
|
||||
fun usageGlyphColour(usage: SessionUsage): Color =
|
||||
@@ -109,18 +139,17 @@ fun usageGlyphColour(usage: SessionUsage): Color =
|
||||
* 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.
|
||||
* What it shows is the paid service's own metering, never derived from what this app has watched go
|
||||
* past: the transcript's token counts are a different quantity, measured differently, and a bar
|
||||
* built out of them would be a guess wearing a measurement's clothes.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
||||
DebugStats.count("usage bar recomposed")
|
||||
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
|
||||
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
|
||||
// value, Compose skips the recomposition, and a "left" that only ticked when the quota
|
||||
// happened to move would sit at a stale figure for hours.
|
||||
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
|
||||
// would sit at a stale figure for hours.
|
||||
var now by remember { mutableStateOf(OffsetDateTime.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
@@ -129,8 +158,8 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
}
|
||||
@@ -186,9 +215,8 @@ private fun UsageNote(text: String) {
|
||||
* 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.
|
||||
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
|
||||
* that is not running gets the percentage and nothing else.
|
||||
*/
|
||||
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
||||
val percent = "${window.percent.toInt()}%"
|
||||
@@ -208,8 +236,7 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
||||
/**
|
||||
* 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.
|
||||
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
|
||||
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
||||
* machine having no quota rather than the question going unanswered.
|
||||
*/
|
||||
|
||||
@@ -47,15 +47,14 @@ fun SettingsScreen(
|
||||
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.
|
||||
// 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.
|
||||
// Null contents means the user backed out of the scanner -- not an error.
|
||||
val contents = result.contents ?: return@rememberLauncherForActivityResult
|
||||
val settings = parseEnrollmentUri(contents.toUri())
|
||||
if (settings == null) {
|
||||
@@ -83,8 +82,8 @@ fun SettingsScreen(
|
||||
// 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.
|
||||
// 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))
|
||||
@@ -106,14 +105,11 @@ fun SettingsScreen(
|
||||
|
||||
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.
|
||||
// Hold the camera permission before the scanner starts. Letting its activity ask on
|
||||
// our behalf is what the library does by default, and it opens the camera without
|
||||
// waiting for the answer: the first-ever scan comes up as a live preview with
|
||||
// "Sorry, the Android camera encountered a problem" over it, and works on the
|
||||
// second try.
|
||||
if (
|
||||
context.checkSelfPermission(Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
@@ -187,11 +183,10 @@ fun SettingsScreen(
|
||||
* 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.
|
||||
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a dark-
|
||||
* themed terminal it comes out as a photographic negative the scanner silently never matches. The
|
||||
* mixed decoder alternates normal and inverted frames, costing half the frame rate at each
|
||||
* polarity.
|
||||
*/
|
||||
private fun enrollmentScanOptions(): ScanOptions =
|
||||
ScanOptions()
|
||||
|
||||
@@ -58,8 +58,8 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
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.
|
||||
// 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") }
|
||||
}
|
||||
@@ -200,9 +200,8 @@ private fun SetupCard(
|
||||
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.
|
||||
// Not "this machine": the seeded setup is *called* that, and the card read "this
|
||||
// machine / this machine".
|
||||
setup.address ?: "runs where the backend does",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -275,9 +274,8 @@ private fun AddSetupDialog(
|
||||
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.
|
||||
// Just the shape. What a blank one means is said once, in the text above this
|
||||
// form -- repeating it here wrapped the label onto a second line.
|
||||
label = { Text("user@host[:port]") },
|
||||
singleLine = true,
|
||||
)
|
||||
@@ -288,8 +286,7 @@ private fun AddSetupDialog(
|
||||
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.
|
||||
// session's own directory, which is what most people want.
|
||||
OutlinedTextField(
|
||||
value = attachmentsDir,
|
||||
onValueChange = { attachmentsDir = it },
|
||||
@@ -309,9 +306,8 @@ private fun AddSetupDialog(
|
||||
},
|
||||
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.
|
||||
// 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 = {
|
||||
@@ -377,14 +373,13 @@ private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String)
|
||||
/**
|
||||
* 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.
|
||||
* One field rather than two because that is how an address is written and read everywhere else, and
|
||||
* because a port that is almost always 22 does not deserve a box of its own on a phone keyboard.
|
||||
* Null rather than 22: the backend already decides the default.
|
||||
*
|
||||
* A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it,
|
||||
* `[::1]:22`; a bare `::1` keeps every colon, 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.
|
||||
* `[::1]:22`; a bare `::1` keeps every colon. So the rule is: brackets, or exactly one colon
|
||||
* followed by digits.
|
||||
*/
|
||||
private fun splitHostAndPort(typed: String): Pair<String, Int?> {
|
||||
if (typed.startsWith("[")) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import androidx.core.content.IntentCompat
|
||||
*
|
||||
* 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.
|
||||
* requests, for the reason [SessionOpenRequest] carries one.
|
||||
*/
|
||||
data class ShareRequest(val uris: List<Uri>, val text: String?, val serial: Int)
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.aiapp
|
||||
|
||||
/**
|
||||
* A byte count at the coarsest unit that still says something, so rows stay comparable.
|
||||
*
|
||||
* Null at zero and below, because the screens that ask disagree about what nothing means and only
|
||||
* the caller knows: a transcript of no bytes is a measurement that has not happened; a file of no
|
||||
* bytes is a file with nothing in it, and the explorer says `0 B`; a session with no cached
|
||||
* transcript says "nothing cached", because a figure of none would read as a measurement.
|
||||
*
|
||||
* Its own file rather than the import screen's, where it started: three screens now say a size, and
|
||||
* a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B.
|
||||
*/
|
||||
fun humanSize(bytes: Long): String? =
|
||||
when {
|
||||
bytes <= 0L -> null
|
||||
bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB"
|
||||
bytes >= 1_000L -> "${bytes / 1_000L} kB"
|
||||
else -> "$bytes B"
|
||||
}
|
||||
@@ -37,7 +37,7 @@ 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.
|
||||
* with no app rebuild.
|
||||
*/
|
||||
@Composable
|
||||
fun SpawnScreen(
|
||||
@@ -46,33 +46,27 @@ fun SpawnScreen(
|
||||
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.
|
||||
// What the form is made of, and whether we have it yet. A failure here is not the same as a
|
||||
// server with nothing to offer, so it must not reach the pickers as empty lists.
|
||||
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
|
||||
// answering "allow Bash?" dozens of times per task is what this app exists to avoid.
|
||||
var permissionMode by remember { mutableStateOf("auto") }
|
||||
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.
|
||||
// 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.
|
||||
// Downloaded models, for a llama provider to choose between. Kept separate from the setups: a
|
||||
// Claude session needs none, so failing to list them must not stop the screen rendering.
|
||||
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
|
||||
var modelKey by remember { mutableStateOf<String?>(null) }
|
||||
var contextSize by remember { mutableStateOf("") }
|
||||
@@ -105,10 +99,9 @@ fun SpawnScreen(
|
||||
}
|
||||
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.
|
||||
// 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 -> {
|
||||
@@ -123,10 +116,9 @@ fun SpawnScreen(
|
||||
}
|
||||
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.
|
||||
// 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"
|
||||
|
||||
@@ -137,9 +129,9 @@ fun SpawnScreen(
|
||||
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.
|
||||
// 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
|
||||
},
|
||||
@@ -150,13 +142,13 @@ fun SpawnScreen(
|
||||
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.
|
||||
// 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.
|
||||
// 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.",
|
||||
@@ -183,10 +175,9 @@ fun SpawnScreen(
|
||||
)
|
||||
|
||||
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.
|
||||
// A llama session names one of the models this backend has downloaded, so the choice is
|
||||
// that list rather than free text -- a name that is not on disk is a session that
|
||||
// cannot start.
|
||||
if (models.isEmpty()) {
|
||||
Text(
|
||||
"No models downloaded yet. Get one from the Models screen first.",
|
||||
@@ -196,9 +187,8 @@ fun SpawnScreen(
|
||||
} 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.
|
||||
// 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 },
|
||||
@@ -280,13 +270,9 @@ fun SpawnScreen(
|
||||
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.
|
||||
// The id, not the label: labels are editable and the server
|
||||
// resolves by id. Non-null here, since `chosen` came from
|
||||
// `setup`'s own provider list.
|
||||
setup = setup.id,
|
||||
provider = chosen.name,
|
||||
title = title.trim(),
|
||||
@@ -294,9 +280,8 @@ fun SpawnScreen(
|
||||
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.
|
||||
// Sent only when set, so blank means "whatever llama.cpp does
|
||||
// by default" rather than a zero.
|
||||
params =
|
||||
buildMap {
|
||||
if (isLlama) {
|
||||
|
||||
@@ -17,14 +17,13 @@ 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.
|
||||
* line ends the frame, comments start with `:`, and a frame is either named with no payload or a
|
||||
* payload with no name. Two screens follow two different streams and neither should re-derive that.
|
||||
*
|
||||
* Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the
|
||||
* cancellation path — it disconnects the socket, which unblocks the read, and [run] then returns
|
||||
* rather than throwing, 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.
|
||||
* cancellation path -- it disconnects the socket, which unblocks the read, and [run] then returns
|
||||
* rather than throwing. Reconnecting belongs to the caller, which is the only one that knows where
|
||||
* to resume from.
|
||||
*/
|
||||
class Sse(private val settings: ServerSettings) {
|
||||
@Volatile private var connection: HttpURLConnection? = null
|
||||
@@ -38,19 +37,16 @@ class Sse(private val settings: ServerSettings) {
|
||||
/**
|
||||
* 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.
|
||||
* because a caller that reconnects usually resumes from somewhere new.
|
||||
*
|
||||
* [onOpen] fires once the server has accepted the connection. That is the measured moment the
|
||||
* stream is live, and the only honest thing to clear a previous failure on: clearing on the
|
||||
* first *event* instead left an idle stream displaying a connection error it had already
|
||||
* recovered from, indefinitely.
|
||||
* first *event* instead left an idle stream displaying an error it had already recovered from.
|
||||
*/
|
||||
fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) {
|
||||
// Opening is inside the try, not before it. Everything this method can fail at owes the
|
||||
// caller the same kind of failure -- 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.
|
||||
// caller the same kind of failure, and a connection that could not even be constructed used
|
||||
// to escape as a raw `IOException` from a line no `catch` covered.
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
connection =
|
||||
@@ -93,7 +89,7 @@ class Sse(private val settings: ServerSettings) {
|
||||
if (!closed) {
|
||||
throw ApiException(
|
||||
"Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})",
|
||||
e,
|
||||
cause = e,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import dev.snipme.highlights.model.SyntaxTheme
|
||||
|
||||
/**
|
||||
* Catppuccin Mocha, as published in `catppuccin/palette`.
|
||||
@@ -27,7 +26,9 @@ private object Mocha {
|
||||
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)
|
||||
@@ -43,16 +44,14 @@ private object Mocha {
|
||||
*
|
||||
* 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.
|
||||
* is a preference, not a contract.
|
||||
*
|
||||
* The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle,
|
||||
* Base, Surface 0, Surface 1 -- 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.
|
||||
* Base, Surface 0, Surface 1 -- so the page is Base, a component's outlined card stays Base beside
|
||||
* it, and a project's card is Surface 0: one visible step up, which is the whole of what the
|
||||
* nesting has to say.
|
||||
*
|
||||
* Accents on this palette are light, so anything filled with one takes Crust for its text rather
|
||||
* than the near-white the roles default to.
|
||||
* Accents on this palette are light, so anything filled with one takes Crust for its text.
|
||||
*/
|
||||
val AiAppColors =
|
||||
darkColorScheme(
|
||||
@@ -93,10 +92,8 @@ val AiAppColors =
|
||||
* 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.
|
||||
* other files, so the same state was a slightly different colour depending which screen you looked
|
||||
* at. A colour that carries meaning is part of the scheme, not a value typed where it was needed.
|
||||
*/
|
||||
val runningColor: Color
|
||||
@Composable get() = Mocha.Green
|
||||
@@ -106,7 +103,7 @@ val runningColor: Color
|
||||
*
|
||||
* 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.
|
||||
* action.
|
||||
*/
|
||||
val failedColor: Color
|
||||
@Composable get() = MaterialTheme.colorScheme.error
|
||||
@@ -115,10 +112,9 @@ val failedColor: Color
|
||||
* 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.
|
||||
* through what was asked of it; this is the session acting on itself, and none of it appears in the
|
||||
* transcript as an answer to anything. A reader who has learned that blue means "not stuck, but not
|
||||
* replying to you either" has learned what distinguishes it from a session that has hung.
|
||||
*/
|
||||
val commandColor: Color
|
||||
@Composable get() = Mocha.Blue
|
||||
@@ -127,10 +123,9 @@ val commandColor: Color
|
||||
* 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.
|
||||
* deliberate choice is not a problem to report. The same red as [failedColor] and [stopColor] for a
|
||||
* third reason: this is neither a fault nor a button, it is the mark left where something was taken
|
||||
* away. No two of the three can appear as the same kind of thing.
|
||||
*/
|
||||
val clearedColor: Color
|
||||
@Composable get() = Mocha.Red
|
||||
@@ -147,9 +142,9 @@ val warningColor: Color
|
||||
* 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].
|
||||
* made it the loudest thing on a screen the reader opened to do something else. A download has no
|
||||
* limit to be near: it finishes. Only a bar measuring a *quota* escalates -- that one is
|
||||
* [quotaColor].
|
||||
*/
|
||||
val progressColor: Color
|
||||
@Composable get() = Mocha.Blue
|
||||
@@ -157,15 +152,13 @@ val progressColor: Color
|
||||
/**
|
||||
* 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.
|
||||
* One function rather than the same `when` written beside each bar, because the point of colouring
|
||||
* by consequence is that the reader learns the step once. It reads as a difference in degree, which
|
||||
* is all colour can carry: the states that differ in *kind* -- a window nobody could read, a
|
||||
* machine that meters nothing -- are said in words elsewhere.
|
||||
*
|
||||
* [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent
|
||||
* without each converting it first and one of them getting it wrong by a factor of a hundred.
|
||||
* without one of them getting it wrong by a factor of a hundred.
|
||||
*/
|
||||
@Composable
|
||||
fun quotaColor(percent: Double): Color =
|
||||
@@ -184,13 +177,11 @@ 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
|
||||
* The darkest value in the palette rather than a step up from the page, and that is the point --
|
||||
* everything else on this screen is somebody's prose, and this is what a machine was handed and
|
||||
* what it said back, character for character. Crust sits *below* Base, so the same colour reads as
|
||||
* one clear step down both on the page, 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 clear step down both on the page and on a card; a tint chosen upwards has to be picked twice
|
||||
* and still collides with the card it lands on.
|
||||
*
|
||||
* One colour for all three, so "this is verbatim" is learnable once.
|
||||
*/
|
||||
@@ -198,24 +189,79 @@ val rawSurface: Color
|
||||
@Composable get() = Mocha.Crust
|
||||
|
||||
/**
|
||||
* Catppuccin Mocha as a syntax theme, for the highlighter used on a tool call's input.
|
||||
* Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette].
|
||||
*
|
||||
* Here with the rest of the palette rather than beside the code that highlights: a library's own
|
||||
* theme would otherwise be the one surface in the app whose colours came from somewhere else, and
|
||||
* the accents below are the same ones every other coloured thing already uses.
|
||||
* Here with the rest of the palette rather than beside the code that highlights: the colours a
|
||||
* fence is drawn in are the same accents every other coloured thing already uses.
|
||||
*
|
||||
* Not a composable, because [highlight] runs off the drawing thread; these never vary with the
|
||||
* theme.
|
||||
*/
|
||||
fun catppuccinSyntax(): SyntaxTheme =
|
||||
SyntaxTheme(
|
||||
key = "catppuccin-mocha",
|
||||
code = Mocha.Text.toArgb(),
|
||||
keyword = Mocha.Mauve.toArgb(),
|
||||
string = Mocha.Green.toArgb(),
|
||||
literal = Mocha.Peach.toArgb(),
|
||||
comment = Mocha.Overlay0.toArgb(),
|
||||
metadata = Mocha.Yellow.toArgb(),
|
||||
multilineComment = Mocha.Overlay0.toArgb(),
|
||||
punctuation = Mocha.Subtext0.toArgb(),
|
||||
mark = Mocha.Sky.toArgb(),
|
||||
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.
|
||||
*
|
||||
* The background is [rawSurface] because that is what a tool's output is drawn on, and reverse
|
||||
* video needs to know what it is reversing against.
|
||||
*/
|
||||
fun ansiPalette(): AnsiPalette =
|
||||
AnsiPalette(
|
||||
colours =
|
||||
listOf(
|
||||
Mocha.Surface1,
|
||||
Mocha.Red,
|
||||
Mocha.Green,
|
||||
Mocha.Yellow,
|
||||
Mocha.Blue,
|
||||
Mocha.Pink,
|
||||
Mocha.Teal,
|
||||
Mocha.Subtext1,
|
||||
Mocha.Surface2,
|
||||
Mocha.Red,
|
||||
Mocha.Green,
|
||||
Mocha.Yellow,
|
||||
Mocha.Blue,
|
||||
Mocha.Pink,
|
||||
Mocha.Teal,
|
||||
Mocha.Subtext0,
|
||||
),
|
||||
foreground = Mocha.Text,
|
||||
background = Mocha.Crust,
|
||||
)
|
||||
|
||||
/**
|
||||
* What a selection looks like, stated rather than left to Material's default.
|
||||
*
|
||||
* The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app
|
||||
* draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a
|
||||
* code block, on Crust, the same 40% composites to a barely-there smudge, so selecting a line of
|
||||
* code looks like nothing happened even though it copies correctly.
|
||||
*
|
||||
* Fixed and stronger, because "this is selected" is a meaning rather than decoration. Raised only
|
||||
* as far as it takes to read on the darkest of them -- past this the fill starts competing with the
|
||||
* syntax colours it sits behind.
|
||||
*/
|
||||
val AiAppSelectionColors =
|
||||
TextSelectionColors(
|
||||
handleColor = Mocha.Mauve,
|
||||
backgroundColor = Mocha.Mauve.copy(alpha = 0.55f),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -228,11 +274,10 @@ val linkColor: Color
|
||||
* 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.
|
||||
* than words: coloured, the items of a list can be counted without reading them. Lavender is not
|
||||
* one of the colours that mean something here, and it is the same at every depth, since depth is
|
||||
* said by the glyph and the indent -- a colour per depth would make a difference in degree look
|
||||
* like one in kind.
|
||||
*/
|
||||
val listMarkerColor: Color
|
||||
@Composable get() = Mocha.Lavender
|
||||
@@ -245,11 +290,9 @@ val overLimitColor: Color
|
||||
* 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.
|
||||
* flight, red ends the process. The near-collisions with the states above are deliberate: those are
|
||||
* *states*, and these are *actions*. A reader never has to tell them apart, because nothing here is
|
||||
* a state and nothing there is pressable.
|
||||
*/
|
||||
val sendColor: Color
|
||||
@Composable get() = Mocha.Green
|
||||
@@ -262,9 +305,8 @@ val queueColor: Color
|
||||
* 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.
|
||||
* there holding the conversation. Red is spent on [stopColor], which is the same button in the same
|
||||
* place when what it would end is the session's process.
|
||||
*/
|
||||
val pauseColor: Color
|
||||
@Composable get() = Mocha.Peach
|
||||
@@ -287,8 +329,7 @@ val startColor: Color
|
||||
*
|
||||
* 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.
|
||||
* will not change to rescue a foreground that stops being readable on one of them.
|
||||
*/
|
||||
@Composable
|
||||
fun actionButtonColors(fill: Color): ButtonColors =
|
||||
|
||||
@@ -11,28 +11,26 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.snipme.highlights.model.SyntaxLanguage
|
||||
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.
|
||||
* Every tool's input arrives as JSON, and showing it raw makes the reader parse
|
||||
* `{"command":"…","timeout":120000}` themselves to find the one line they care about. So the fields
|
||||
* that carry the meaning are pulled out, and anything left over is still shown, because dropping a
|
||||
* field would be claiming the tool has no other input when it might.
|
||||
*/
|
||||
data class ToolInput(
|
||||
/** The thing that will actually be run or read, if this tool has one. */
|
||||
val subject: String?,
|
||||
/** The language [subject] is written in, for highlighting. */
|
||||
val language: SyntaxLanguage?,
|
||||
val language: Language?,
|
||||
/** The tool's own one-line summary, when it wrote one. */
|
||||
val description: String?,
|
||||
/**
|
||||
* How long the call may take, as the tool expressed it. Shown apart because it is a limit on
|
||||
* the call rather than part of what the call does.
|
||||
* How long the call may take, in the largest units it fits. Shown apart because it is a limit
|
||||
* on the call rather than part of what the call does.
|
||||
*/
|
||||
val timeout: String?,
|
||||
/** Everything else, as `name: value` lines. Never dropped. */
|
||||
@@ -48,11 +46,11 @@ data class ToolInput(
|
||||
*
|
||||
* 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.
|
||||
* subject, everything is rest".
|
||||
*/
|
||||
private val SUBJECTS: Map<String, Pair<String, SyntaxLanguage?>> =
|
||||
private val SUBJECTS: Map<String, Pair<String, Language?>> =
|
||||
mapOf(
|
||||
"Bash" to ("command" to SyntaxLanguage.SHELL),
|
||||
"Bash" to ("command" to Language.SHELL),
|
||||
"Read" to ("file_path" to null),
|
||||
"Write" to ("file_path" to null),
|
||||
"Edit" to ("file_path" to null),
|
||||
@@ -69,8 +67,8 @@ fun parseToolInput(tool: String, input: String): ToolInput {
|
||||
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.
|
||||
// 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,
|
||||
@@ -84,7 +82,7 @@ fun parseToolInput(tool: String, input: String): ToolInput {
|
||||
val description = DESCRIPTIONS.firstNotNullOfOrNull {
|
||||
json.optString(it).takeIf { v -> v.isNotBlank() }
|
||||
}
|
||||
val timeout = json.optString("timeout").takeIf { it.isNotBlank() }
|
||||
val timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
|
||||
val rest =
|
||||
json
|
||||
.keys()
|
||||
@@ -101,9 +99,9 @@ fun parseToolInput(tool: String, input: String): ToolInput {
|
||||
/**
|
||||
* 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.
|
||||
* On the dark surface every verbatim thing in the app sits on. Drawn as nothing at all when the
|
||||
* call carried neither, rather than as an empty block: a tinted rectangle with nothing in it is a
|
||||
* rendering fault.
|
||||
*
|
||||
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
|
||||
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
|
||||
@@ -114,11 +112,11 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
||||
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.
|
||||
// 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.
|
||||
// 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,
|
||||
|
||||
@@ -19,7 +19,10 @@ 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
|
||||
@@ -39,14 +42,11 @@ import androidx.compose.ui.unit.dp
|
||||
* 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.
|
||||
* Immutable, and said so, because Compose cannot tell: a row is rebuilt from the transcript rather
|
||||
* than edited, and two rows describing the same events are equal. Compose infers stability from a
|
||||
* class's fields, and a `List` field -- which several of these carry -- makes it assume the worst,
|
||||
* so a page of history landing recomposed all 148 loaded rows including the markdown inside them,
|
||||
* measured as 701 compositions for 148 rows in one scroll.
|
||||
*
|
||||
* The promise this makes is real and has to stay true: nothing here is mutated after it is built.
|
||||
*/
|
||||
@@ -56,16 +56,12 @@ 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.
|
||||
* the other, moves the rows and not the reader. When a key changes, the list loses its anchor
|
||||
* and the transcript steps under whoever is reading it.
|
||||
*
|
||||
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
|
||||
* and it is the *same* value whether the run is drawn as one card or as a group. 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.
|
||||
* and it is the *same* value whether the run is drawn as one card or as a group. Which value
|
||||
* that is belongs to the item ([TranscriptItem.key]), not to a `when` here.
|
||||
*/
|
||||
abstract val key: Any
|
||||
|
||||
@@ -73,12 +69,9 @@ sealed class TranscriptRow {
|
||||
* 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].
|
||||
* decision; a seq is the server's own numbering, assigned once and meaning the same thing to
|
||||
* every device. So anything that has to point at a place in the conversation and still find it
|
||||
* later -- a saved scroll position -- points with this.
|
||||
*/
|
||||
abstract val startSeq: Long
|
||||
|
||||
@@ -130,8 +123,7 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
|
||||
// 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.
|
||||
// change which call is *first*.
|
||||
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
|
||||
run += item
|
||||
} else {
|
||||
@@ -149,18 +141,14 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
|
||||
* 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.
|
||||
* [GROUP_INSET], which is the container's own padding rather than an indent.
|
||||
*
|
||||
* Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so
|
||||
* the run reads as one thing broken into its parts; [GROUP_GAP] keeps the parts legible without
|
||||
* separating them. See [connectedShape].
|
||||
* the run reads as one thing broken into its parts; see [connectedShape].
|
||||
*
|
||||
* It closes from either end. A long group's header scrolls off while its last call is still on
|
||||
* screen, and the reader who wants it shut is looking at the bottom, 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.
|
||||
* screen, and the reader who wants it shut is looking at the bottom. The bar at the foot is the
|
||||
* same height as the heading at the top.
|
||||
*/
|
||||
@Composable
|
||||
fun ToolGroup(
|
||||
@@ -168,13 +156,12 @@ fun ToolGroup(
|
||||
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.
|
||||
* at each end, and only the row knows where its own ends are.
|
||||
*/
|
||||
onToggle: () -> Unit,
|
||||
isToolExpanded: (String) -> Boolean,
|
||||
onToolToggle: (String) -> Unit,
|
||||
onAnswer: (questionId: String, answers: List<String>) -> Unit,
|
||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||
image: @Composable (String) -> Unit,
|
||||
) {
|
||||
val heading = "Called ${group.calls.size} tools"
|
||||
@@ -219,8 +206,8 @@ fun ToolGroup(
|
||||
)
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -229,8 +216,8 @@ fun ToolGroup(
|
||||
* 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.
|
||||
* match and a pair of numbers chosen to look equal stops being equal the moment the density
|
||||
* changes.
|
||||
*/
|
||||
@Composable
|
||||
private fun groupBarHeight(): Dp {
|
||||
@@ -239,10 +226,9 @@ private fun groupBarHeight(): Dp {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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].
|
||||
* The bottom half of a group's toggle: an arrow back up to its heading. Given the heading's height
|
||||
* rather than padded to something that looks close, so the surface the calls sit on is the same
|
||||
* thickness at both ends.
|
||||
*/
|
||||
@Composable
|
||||
private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
|
||||
@@ -254,7 +240,7 @@ private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Chevron(pointingUp = true, colour = colour)
|
||||
Chevron(Pointing.Up, colour = colour)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,8 +249,7 @@ private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
|
||||
* 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.
|
||||
* that are one rule -- and the middle one is what a hand-written first/last pair gets wrong.
|
||||
*/
|
||||
@Composable
|
||||
private fun connectedShape(index: Int, count: Int): CornerBasedShape {
|
||||
@@ -291,12 +276,10 @@ 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.
|
||||
* on it, because a wrapped command turns one row into four and a run of them into a wall.
|
||||
*
|
||||
* Open, it shows the command, whatever else the input carried, and the output. The timeout sits at
|
||||
* the top right: it is a limit on the call rather than part of what the call does, and it is worth
|
||||
* seeing beside the command it constrains rather than buried in the fields below it.
|
||||
* the top right: it is a limit on the call rather than part of what the call does.
|
||||
*
|
||||
* A call waiting on permission is shown open whatever the reader last chose, since the command is
|
||||
* the thing being decided and a row saying only "Bash" cannot be decided on.
|
||||
@@ -306,7 +289,7 @@ fun ToolCard(
|
||||
tool: TranscriptItem.ToolRun,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
onAnswer: (questionId: String, answers: List<String>) -> 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,
|
||||
@@ -339,10 +322,9 @@ fun ToolCard(
|
||||
)
|
||||
} ?: 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.
|
||||
// A spinner says the machine is working. While this call is waiting on an answer
|
||||
// the machine is doing nothing at all -- the turn is stopped on the person reading
|
||||
// it -- so it says whose move it is instead.
|
||||
if (deciding) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
@@ -367,9 +349,9 @@ fun ToolCard(
|
||||
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.
|
||||
// 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))
|
||||
}
|
||||
@@ -378,29 +360,31 @@ fun ToolCard(
|
||||
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.
|
||||
// prose, and a proportional font silently destroys the alignment that carried
|
||||
// the meaning.
|
||||
//
|
||||
// Its terminal styling applied and the rest of the escapes taken out: colour is
|
||||
// often the whole of what a diff or a test run is saying. Remembered against
|
||||
// the text, so a card that is open through a scroll parses once.
|
||||
val palette = remember { ansiPalette() }
|
||||
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
|
||||
RawBlock(Modifier.padding(top = 2.dp)) {
|
||||
Text(
|
||||
tool.output,
|
||||
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.
|
||||
// Shown open or closed. A call that produced a picture is one whose result *is* the
|
||||
// picture, and a row that hides it says less than the one line it replaced.
|
||||
tool.images.forEach { ref -> image(ref) }
|
||||
if (tool.asks.isNotEmpty()) {
|
||||
if (tool.tool == ASK_USER_QUESTION) {
|
||||
AskUserQuestionBody(tool.asks, onAnswer)
|
||||
} else {
|
||||
tool.asks.forEach { ask ->
|
||||
PermissionAsk(ask) { answers -> onAnswer(ask.id, answers) }
|
||||
}
|
||||
tool.asks.forEach { ask -> PermissionAsk(ask, onAnswer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,7 +398,16 @@ fun ToolCard(
|
||||
* 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<String>) -> Unit) {
|
||||
private fun PermissionAsk(
|
||||
ask: TranscriptItem.QuestionCard,
|
||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||
) {
|
||||
// What was pressed, before the answer has been round-tripped. Two bare words with no submit
|
||||
// step -- unlike a question card, where the answer is worth reviewing -- so the press has to be
|
||||
// its own acknowledgement or the row sits unchanged for a round trip. Cleared when the request
|
||||
// settles: by then either the answer is in `ask.answers`, or it failed and the buttons come
|
||||
// back.
|
||||
var pressed by remember(ask.id) { mutableStateOf<String?>(null) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
ask.prompt.substringBefore('\n'),
|
||||
@@ -423,9 +416,19 @@ private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List<Stri
|
||||
)
|
||||
// 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.
|
||||
AnswerOptions(ask.options, ask.answers, onAnswer.takeIf { ask.answers.isEmpty() })
|
||||
// matters most: "Answered: Deny" alone does not say that Allow was the alternative.
|
||||
val settled = ask.answers.isNotEmpty()
|
||||
AnswerOptions(
|
||||
ask.options,
|
||||
if (settled) ask.answers else listOfNotNull(pressed),
|
||||
onPick =
|
||||
if (settled || pressed != null) null
|
||||
else
|
||||
{ label ->
|
||||
pressed = label
|
||||
onAnswer(listOf(QuestionAnswer(ask.id, listOf(label)))) { pressed = null }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.util.Log
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.io.IOException
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* This phone's copy of the transcripts it has already been sent, so reopening a session does not
|
||||
* download it again.
|
||||
*
|
||||
* What is stored is the server's own JSON for one event per line, in transcript order. Reading the
|
||||
* cache means running the same [parseSeqEvent] the network path runs, so a cached transcript and a
|
||||
* fetched one cannot draw differently, and an event type this build does not know keeps every field
|
||||
* it arrived with for the build that will. Rows are deliberately *not* what is stored: a row is a
|
||||
* rendering, and a cache of rows would need throwing away on every update that touched `foldEvent`.
|
||||
*
|
||||
* See TRANSCRIPT_CACHE.md for the design. Four rules run through all of it:
|
||||
* 1. what is on screen is what the server's transcript says, in order, with nothing missing -- the
|
||||
* cache is a copy and is never inferred, folded or edited here;
|
||||
* 2. a cached line is never ahead of the live cursor, and the cursor never ahead of the cache;
|
||||
* 3. the cache is never load-bearing -- missing, evicted, damaged or unwritable all degrade to a
|
||||
* cold open, never to a blank or a wrong screen; 4. a line already on the phone is not fetched
|
||||
* again.
|
||||
*
|
||||
* A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs
|
||||
* under the JVM unit tests. That is also why there is no JSON parser here: what it needs off a line
|
||||
* is the sequence number and whether the line is a streamed delta, both read with a regex. A line
|
||||
* it cannot read that way is treated as damage. [warn] is where failures are said for the same
|
||||
* reason.
|
||||
*/
|
||||
class TranscriptCache(
|
||||
private val root: File,
|
||||
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
|
||||
) {
|
||||
/** The cache for one session, whether or not anything has been stored for it yet. */
|
||||
fun session(id: String): SessionCache = SessionCache(File(root, id), warn)
|
||||
|
||||
/**
|
||||
* Deletes every session directory not in [ids], called after a successful list fetch. The path
|
||||
* out for a session deleted on another device: nothing here would otherwise hear about it, and
|
||||
* unlike a draft's few bytes what it leaves behind is megabytes.
|
||||
*/
|
||||
fun retainOnly(ids: Set<String>) =
|
||||
guardIo(Unit, warn) {
|
||||
sessionDirs().forEach { if (it.name !in ids) it.deleteRecursively() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes least-recently-touched session directories, never [keep], until the whole of this
|
||||
* server's cache is under [budget]. Least-recently-touched rather than largest: what a reader
|
||||
* is likely to open again is what they opened last, and evicting the big ones first would empty
|
||||
* the cache for exactly the conversations it exists for.
|
||||
*/
|
||||
fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) =
|
||||
guardIo(Unit, warn) {
|
||||
val dirs = sessionDirs().sortedBy { it.lastModified() }
|
||||
var total = dirs.sumOf { sizeOf(it) }
|
||||
for (dir in dirs) {
|
||||
if (total <= budget) break
|
||||
if (dir.name == keep) continue
|
||||
val was = sizeOf(dir)
|
||||
if (dir.deleteRecursively()) total -= was
|
||||
}
|
||||
}
|
||||
|
||||
fun purgeAll() = guardIo(Unit, warn) { root.deleteRecursively() }
|
||||
|
||||
private fun sessionDirs(): List<File> = root.listFiles()?.filter { it.isDirectory }.orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of this phone's cache directory all of one server's transcripts may take. A dozen of the
|
||||
* largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small fraction of a phone.
|
||||
* A number to revisit against real use rather than a measurement of anything.
|
||||
*/
|
||||
const val CACHE_BUDGET_BYTES: Long = 256L * 1000 * 1000
|
||||
|
||||
/**
|
||||
* What the newest cached line says, which is what the probe checks against the server. Both halves
|
||||
* are wanted together: the seq is what the request asks about, and the line is what its answer is
|
||||
* compared with.
|
||||
*/
|
||||
data class CachedTail(val seq: Long, val line: String)
|
||||
|
||||
/**
|
||||
* One session's cached lines, as a directory of chunks.
|
||||
*
|
||||
* A chunk is a set of lines *and a claim about what they cover*, and the two are not the same
|
||||
* thing: a coalesced page joins each run of streamed deltas into one event carrying the seq of the
|
||||
* run's oldest delta, so a page whose newest event is seq 1,200 may cover everything up to the
|
||||
* 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open range
|
||||
* in the file's name:
|
||||
* ```
|
||||
* <first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
|
||||
* <first>-<end>.raw.jsonl an uncoalesced page, or a closed live run <first>-open.raw.jsonl the
|
||||
* live run; end is its last line's seq + 1
|
||||
* ```
|
||||
*
|
||||
* Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run ending
|
||||
* at the newest chunk -- the **suffix** -- is ever served: chunks behind a gap are kept, because
|
||||
* the gap is usually closed by paging back through it, but nothing is served across one.
|
||||
*
|
||||
* **The newest chunk is always raw**, which is what makes the stream cursor and the probe well
|
||||
* defined. It holds by construction (the opening window and every stream frame are raw) and is
|
||||
* checked on read: a `.rows` chunk at the newest end can only mean this app died between closing
|
||||
* one live run and opening the next, and it discards the session.
|
||||
*
|
||||
* Nothing here is load-bearing. Every operation that touches the disk answers as though the cache
|
||||
* were empty when it cannot, and a write failure disables writing for the rest of this instance's
|
||||
* life so that a full disk costs one log line rather than one per delta.
|
||||
*
|
||||
* Every operation is synchronized, because two of them really do run at once: the stream appends
|
||||
* live events from its own IO thread while a reader scrolling back reads pages from another. What
|
||||
* it buys is that the open chunk's name, its end and its writer are never read half-rotated.
|
||||
*/
|
||||
class SessionCache(
|
||||
private val dir: File,
|
||||
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
|
||||
) {
|
||||
/** Set by the first write that fails: a second would fail the same way, once per delta. */
|
||||
private var disabled = false
|
||||
/**
|
||||
* The open chunk's writer, its file, and the seq that chunk now ends at.
|
||||
*
|
||||
* Buffered, and flushed on [flush], because a delta is a hundred bytes and arrives dozens of
|
||||
* times a second while a reply streams. What that costs is the unflushed tail on a crash, which
|
||||
* is safe: a shorter cache is a longer catch-up, never a wrong one.
|
||||
*/
|
||||
private var writer: BufferedWriter? = null
|
||||
private var openFile: File? = null
|
||||
private var openEnd: Long = 0
|
||||
|
||||
/**
|
||||
* The newest line of the suffix, or null when there is none or the newest chunk is not raw.
|
||||
*
|
||||
* This is the cursor the live stream would resume from, so it is also what has to be shown to
|
||||
* still be the server's own line before anything is resumed from it.
|
||||
*/
|
||||
@Synchronized
|
||||
fun tail(): CachedTail? =
|
||||
guard(null) {
|
||||
val newest = suffix().lastOrNull() ?: return@guard null
|
||||
var found: CachedTail? = null
|
||||
eachLine(newest) { line ->
|
||||
found = CachedTail(seqOf(line)!!, line)
|
||||
false
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/** The newest [limit] lines of the suffix, oldest first -- the opening window. */
|
||||
@Synchronized
|
||||
fun newest(limit: Int): List<String> =
|
||||
guard(emptyList()) {
|
||||
val taken = ArrayDeque<String>()
|
||||
for (chunk in suffix().asReversed()) {
|
||||
if (taken.size >= limit) break
|
||||
eachLine(chunk) { line ->
|
||||
taken.addFirst(line)
|
||||
taken.size < limit
|
||||
}
|
||||
}
|
||||
taken.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* The page of lines before [before], oldest first, or null when the cache cannot answer.
|
||||
*
|
||||
* Null is a miss -- the suffix does not cover the ground immediately below [before] -- and
|
||||
* means the server has to be asked. Deliberately not an empty list: an empty page is how the
|
||||
* screen is told it has reached the start of the conversation, and a cache saying that of
|
||||
* history it merely does not hold would stop the transcript scrolling back for good.
|
||||
*
|
||||
* [before] is anywhere inside the suffix, not only at a chunk boundary. The cursor a warm open
|
||||
* leaves behind is in the middle of the live run, so a cache that could only answer at a
|
||||
* boundary would send the very first backwards page to the server and, since that page would
|
||||
* overlap the run, keep none of it.
|
||||
*
|
||||
* With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`.
|
||||
* The deltas are not joined here -- `foldEvent` does that, and the joined row keeps the seq of
|
||||
* its first delta either way.
|
||||
*/
|
||||
@Synchronized
|
||||
fun page(before: Long, limit: Int, rows: Boolean): List<String>? =
|
||||
guard(null) {
|
||||
val suffix = suffix()
|
||||
val newest = suffix.lastOrNull() ?: return@guard null
|
||||
// Above what is held, or at or below where it starts: either way the run the caller is
|
||||
// scrolling into is not continuous with this one, and only the server has it.
|
||||
if (before > newest.end || before <= suffix.first().first) return@guard null
|
||||
val taken = ArrayDeque<String>()
|
||||
var counted = 0
|
||||
var inRun = false
|
||||
var wanting = true
|
||||
for (chunk in suffix.asReversed()) {
|
||||
if (!wanting) break
|
||||
if (chunk.first >= before) continue
|
||||
eachLine(chunk) { line ->
|
||||
// The page is what is *before* the cursor; the rows at or above it are already
|
||||
// on screen.
|
||||
if (seqOf(line)!! >= before) return@eachLine true
|
||||
if (rows) {
|
||||
val delta = isDelta(line)
|
||||
// Stop only between rows: a delta continuing the run being gathered is part
|
||||
// of a row already counted, and breaking on it would drop the half of that
|
||||
// row already taken.
|
||||
if (counted >= limit && !(delta && inRun)) wanting = false
|
||||
else {
|
||||
if (!delta || !inRun) counted++
|
||||
inRun = delta
|
||||
}
|
||||
} else if (taken.size >= limit) {
|
||||
wanting = false
|
||||
}
|
||||
if (wanting) taken.addFirst(line)
|
||||
wanting
|
||||
}
|
||||
}
|
||||
taken.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* The `end` of the nearest chunk at or below [before], which is the floor a fetched page is
|
||||
* asked with so that it stops where this phone's copy starts. Null when there is no such chunk.
|
||||
*
|
||||
* Any chunk, not only the suffix's: the whole point is to reach the run behind a gap, so that
|
||||
* the gap is closed with exactly the bytes it is wide.
|
||||
*/
|
||||
@Synchronized
|
||||
fun coveredUpTo(before: Long): Long? =
|
||||
guard(null) { chunks().map { it.end }.filter { it <= before }.maxOrNull() }
|
||||
|
||||
/**
|
||||
* Stores a fetched page covering `[first, end)`; false when it was not stored.
|
||||
*
|
||||
* Refused when it overlaps a chunk already here, because there is no clean cut: a coalesced
|
||||
* event cannot be split at a seq inside its own delta run. `TranscriptSource` keeps that from
|
||||
* arising by bounding what it fetches, and this is the guard for a page that arrives anyway.
|
||||
* Such a page is still drawn; it is only not kept.
|
||||
*
|
||||
* The newest chunk is never stored through here: the opening window and every live frame go
|
||||
* through [append], which is what keeps the newest chunk raw and open.
|
||||
*/
|
||||
@Synchronized
|
||||
fun storePage(lines: List<String>, first: Long, end: Long, rows: Boolean): Boolean =
|
||||
guard(false) {
|
||||
if (disabled || lines.isEmpty() || end <= first) return@guard false
|
||||
if (chunks().any { first < it.end && it.first < end }) return@guard false
|
||||
dir.mkdirs()
|
||||
val kind = if (rows) "rows" else "raw"
|
||||
File(dir, "$first-$end.$kind.jsonl").writeText(lines.joinToString("\n", postfix = "\n"))
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends one live event, which is also how a freshly fetched opening window is stored.
|
||||
*
|
||||
* A seq equal to the open chunk's end extends it. A larger one is a gap -- which is what a
|
||||
* `reset` looks like from here -- and closes the open chunk under the end it turned out to
|
||||
* have. A smaller one is already covered and is ignored; the SSE contract is `seq > after`.
|
||||
*/
|
||||
@Synchronized
|
||||
fun append(line: String, seq: Long) =
|
||||
guard(Unit) {
|
||||
if (disabled) return@guard
|
||||
val writer = writerFor(seq) ?: return@guard
|
||||
// Written as it arrived. A newline inside it would split one event into two unreadable
|
||||
// halves, but neither source can produce one: SSE framing forbids it, and a page's
|
||||
// elements are re-serialized compactly, which escapes it.
|
||||
writer.write(line)
|
||||
writer.write("\n")
|
||||
openEnd = seq + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes what [append] has buffered. Called on each `Status` event -- the boundaries of a
|
||||
* turn, which is the granularity a crash may as well lose -- and when the stream closes.
|
||||
*/
|
||||
@Synchronized fun flush() = guard(Unit) { writer?.flush() }
|
||||
|
||||
/** What [purge] would discard, for the reload row in session settings. */
|
||||
@Synchronized fun bytes(): Long = guard(0L) { sizeOf(dir) }
|
||||
|
||||
/** Marks this session as visited, which is what eviction ranks by. */
|
||||
@Synchronized
|
||||
fun touch() =
|
||||
guard(Unit) { if (dir.isDirectory) dir.setLastModified(System.currentTimeMillis()) }
|
||||
|
||||
@Synchronized
|
||||
fun purge() =
|
||||
guard(Unit) {
|
||||
closeWriter()
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
// -- chunks ------------------------------------------------------------------------------
|
||||
|
||||
private data class Chunk(val file: File, val first: Long, val end: Long, val open: Boolean) {
|
||||
val rows: Boolean
|
||||
get() = file.name.endsWith(".rows.jsonl")
|
||||
}
|
||||
|
||||
/**
|
||||
* Every chunk on disk, oldest first. A name this does not recognise is not ours and is ignored.
|
||||
* Recomputed per operation rather than kept: another operation may have changed the directory.
|
||||
*/
|
||||
private fun chunks(): List<Chunk> {
|
||||
writer?.flush()
|
||||
return dir.listFiles()
|
||||
.orEmpty()
|
||||
.mapNotNull { file ->
|
||||
val match = CHUNK_NAME.matchEntire(file.name) ?: return@mapNotNull null
|
||||
val first = match.groupValues[1].toLongOrNull() ?: return@mapNotNull null
|
||||
val open = match.groupValues[2] == "open"
|
||||
val end = if (open) openEndOf(file, first) else match.groupValues[2].toLongOrNull()
|
||||
// A chunk covering nothing is one that was created and never written to -- an
|
||||
// append whose very first write failed. It says nothing, so it is not a chunk.
|
||||
if (end == null || end <= first) null else Chunk(file, first, end, open)
|
||||
}
|
||||
.sortedBy { it.first }
|
||||
}
|
||||
|
||||
/**
|
||||
* The open chunk's end: its last line's seq plus one, or the in-memory end while this instance
|
||||
* is the one writing it.
|
||||
*
|
||||
* An open chunk whose last line cannot be read is this app having died mid-write. That line is
|
||||
* dropped and the file truncated to the last good one, which is the one place damage is
|
||||
* repaired rather than discarded: the tail of an append-only file is the only place a partial
|
||||
* line can be.
|
||||
*/
|
||||
private fun openEndOf(file: File, first: Long): Long {
|
||||
if (openFile == file && openEnd > 0) return openEnd
|
||||
repairTail(file)
|
||||
var end = first
|
||||
eachLineBackwards(file) { _, line ->
|
||||
seqOf(line)?.let { end = it + 1 }
|
||||
false
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
/**
|
||||
* The contiguous run of adjacent chunks ending at the newest one, oldest first.
|
||||
*
|
||||
* A newest chunk that is not raw cannot happen while this code is the only writer, and means
|
||||
* the directory is not to be trusted -- so the session is discarded.
|
||||
*/
|
||||
private fun suffix(): List<Chunk> {
|
||||
val all = chunks()
|
||||
var index = all.size - 1
|
||||
val newest = all.lastOrNull() ?: return emptyList()
|
||||
if (newest.rows) throw Damaged(newest.file)
|
||||
val run = ArrayDeque<Chunk>()
|
||||
run.addFirst(newest)
|
||||
while (index > 0 && all[index - 1].end == run.first().first) {
|
||||
index--
|
||||
run.addFirst(all[index])
|
||||
}
|
||||
return run.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Each line of [chunk], newest first, until [take] says stop.
|
||||
*
|
||||
* Backwards and lazily, because every question this cache is asked is about the newest end and
|
||||
* a live run grows to the size of the conversation. Reading the file whole to answer with
|
||||
* eighty lines of it is the cost the server's own reader was rewritten to stop paying.
|
||||
*
|
||||
* Damage anywhere but at the tail of the open chunk was not written by this code, and there is
|
||||
* no honest way to say what a chunk covers with a line of it unreadable -- so it discards the
|
||||
* session rather than serving what it can read.
|
||||
*/
|
||||
private fun eachLine(chunk: Chunk, take: (String) -> Boolean) {
|
||||
eachLineBackwards(chunk.file) { _, line ->
|
||||
if (seqOf(line) == null) throw Damaged(chunk.file)
|
||||
take(line)
|
||||
}
|
||||
}
|
||||
|
||||
// -- writing -----------------------------------------------------------------------------
|
||||
|
||||
/** The writer for the chunk [seq] belongs in, opening or rotating one as it has to. */
|
||||
private fun writerFor(seq: Long): BufferedWriter? {
|
||||
writer?.let { held ->
|
||||
if (seq == openEnd) return held
|
||||
if (seq < openEnd) return null
|
||||
// A gap: what this instance has written covers up to `openEnd`, and that is the name
|
||||
// the chunk gets before a new one starts at the arriving seq.
|
||||
closeOpenChunk(openEnd)
|
||||
}
|
||||
dir.mkdirs()
|
||||
// An open chunk left by an earlier instance, or by an earlier screen.
|
||||
chunks()
|
||||
.lastOrNull { it.open }
|
||||
?.let { existing ->
|
||||
if (seq < existing.end) return null
|
||||
if (seq == existing.end) {
|
||||
openFile = existing.file
|
||||
openEnd = existing.end
|
||||
return FileWriter(existing.file, true).buffered().also { writer = it }
|
||||
}
|
||||
rename(existing.file, existing.first, existing.end)
|
||||
}
|
||||
// A chunk that was created and never written to would otherwise be left behind under a name
|
||||
// a second one is about to want; it covers nothing, so nothing is lost with it.
|
||||
dir.listFiles().orEmpty().forEach {
|
||||
if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L)
|
||||
it.delete()
|
||||
}
|
||||
val file = File(dir, "$seq-open.raw.jsonl")
|
||||
openFile = file
|
||||
openEnd = seq
|
||||
return FileWriter(file, false).buffered().also { writer = it }
|
||||
}
|
||||
|
||||
/** Renames the open chunk to the range it turned out to cover, so it stops being open. */
|
||||
private fun closeOpenChunk(end: Long) {
|
||||
val file = openFile
|
||||
closeWriter()
|
||||
if (file == null) return
|
||||
val first = CHUNK_NAME.matchEntire(file.name)?.groupValues?.get(1)?.toLongOrNull()
|
||||
if (first != null) rename(file, first, end)
|
||||
}
|
||||
|
||||
private fun rename(file: File, first: Long, end: Long) {
|
||||
file.renameTo(File(dir, "$first-$end.raw.jsonl"))
|
||||
}
|
||||
|
||||
private fun closeWriter() {
|
||||
try {
|
||||
writer?.close()
|
||||
} catch (_: IOException) {
|
||||
// Nothing left to do about it: the file is what it is, and the read path repairs a
|
||||
// half-written tail.
|
||||
}
|
||||
writer = null
|
||||
openFile = null
|
||||
openEnd = 0
|
||||
}
|
||||
|
||||
// -- failure -----------------------------------------------------------------------------
|
||||
|
||||
/** A chunk that cannot be read as what its name claims. */
|
||||
private class Damaged(val file: File) : RuntimeException()
|
||||
|
||||
/**
|
||||
* Runs [body], answering [ifBroken] when the directory cannot give a real answer.
|
||||
*
|
||||
* None of this is reported on screen: none of it changes what the screen shows -- every read
|
||||
* here has a network path beside it producing the same result -- and the reader has nothing to
|
||||
* do about it. Damage discards this session's cache, which makes the next open an ordinary cold
|
||||
* one.
|
||||
*/
|
||||
private fun <T> guard(ifBroken: T, body: () -> T): T =
|
||||
// A disk that refused once will refuse again, once per delta, so the first refusal is also
|
||||
// the last: this instance stops writing rather than logging a line a token.
|
||||
guardIo(
|
||||
ifBroken,
|
||||
warn,
|
||||
onFailure = {
|
||||
disabled = true
|
||||
closeWriter()
|
||||
},
|
||||
) {
|
||||
try {
|
||||
body()
|
||||
} catch (e: Damaged) {
|
||||
warn("transcript cache damaged at ${e.file}; discarding ${dir.name}")
|
||||
closeWriter()
|
||||
dir.deleteRecursively()
|
||||
ifBroken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is not ours. */
|
||||
private val CHUNK_NAME = Regex("""^(\d+)-(\d+|open)\.(rows|raw)\.jsonl$""")
|
||||
|
||||
private val SEQ_IN_LINE = Regex(""""seq"\s*:\s*(\d+)""")
|
||||
private val TYPE_IN_LINE = Regex(""""type"\s*:\s*"([^"]*)"""")
|
||||
|
||||
/**
|
||||
* One line's sequence number, or null when the line is not one of ours.
|
||||
*
|
||||
* A regex rather than a JSON parse, so that this file carries no parser and runs under the JVM
|
||||
* tests: the seq is the first field the server writes, so the first match is the top-level one.
|
||||
*/
|
||||
private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull()
|
||||
|
||||
/** Whether a line is one streamed piece of a reply, which is what makes a run of them one row. */
|
||||
private fun isDelta(line: String): Boolean =
|
||||
TYPE_IN_LINE.find(line)?.groupValues?.get(1) == "assistantText"
|
||||
|
||||
/**
|
||||
* How much of a file is read at a time when walking it backwards. One block covers a page of a
|
||||
* transcript comfortably, and the walk stops as soon as the caller has what it asked for.
|
||||
*/
|
||||
private const val READ_BLOCK = 64 * 1024
|
||||
|
||||
/**
|
||||
* Calls [onLine] with each non-blank line of [file], **newest first**, along with the byte offset
|
||||
* it starts at, until [onLine] answers false.
|
||||
*
|
||||
* Every question the cache is asked is about the newest end of a chunk, and a live run reaches the
|
||||
* size of the conversation, so reading forwards means reading a transcript to answer with the last
|
||||
* eighty lines of it.
|
||||
*
|
||||
* Splitting on bytes is safe because the separator is `\n`, which cannot occur inside a multi-byte
|
||||
* UTF-8 sequence; each line is decoded whole. A missing file yields nothing.
|
||||
*/
|
||||
private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) {
|
||||
if (!file.isFile) return
|
||||
RandomAccessFile(file, "r").use { handle ->
|
||||
// Bytes below `unread` have not been looked at; `pending` is the oldest line so far, which
|
||||
// is incomplete until a newline is found before it in an older block.
|
||||
var unread = handle.length()
|
||||
var pending = ByteArray(0)
|
||||
while (unread > 0) {
|
||||
val take = minOf(READ_BLOCK.toLong(), unread).toInt()
|
||||
val start = unread - take
|
||||
val block = ByteArray(take)
|
||||
handle.seek(start)
|
||||
handle.readFully(block)
|
||||
val buffer = if (pending.isEmpty()) block else block + pending
|
||||
var lineEnd = buffer.size
|
||||
var at = buffer.size - 1
|
||||
while (at >= 0) {
|
||||
if (buffer[at] == NEWLINE) {
|
||||
val line = String(buffer, at + 1, lineEnd - at - 1, Charsets.UTF_8)
|
||||
if (line.isNotBlank() && !onLine(start + at + 1, line)) return
|
||||
lineEnd = at
|
||||
}
|
||||
at--
|
||||
}
|
||||
pending = buffer.copyOfRange(0, lineEnd)
|
||||
unread = start
|
||||
}
|
||||
// The first line of a file has no newline before it to be found.
|
||||
val first = String(pending, Charsets.UTF_8)
|
||||
if (first.isNotBlank()) onLine(0, first)
|
||||
}
|
||||
}
|
||||
|
||||
private const val NEWLINE = '\n'.code.toByte()
|
||||
|
||||
/**
|
||||
* Drops a final line that is not one of ours, by truncating the file to where it starts.
|
||||
*
|
||||
* This app having died mid-write is the one kind of damage that is repaired rather than discarded:
|
||||
* the tail of an append-only file is the only place a partial line can be. A second bad line is not
|
||||
* this, and is left for the read path to notice.
|
||||
*/
|
||||
private fun repairTail(file: File) {
|
||||
var truncateTo = -1L
|
||||
eachLineBackwards(file) { offset, line ->
|
||||
if (seqOf(line) == null) truncateTo = offset
|
||||
false
|
||||
}
|
||||
if (truncateTo >= 0) RandomAccessFile(file, "rw").use { it.setLength(truncateTo) }
|
||||
}
|
||||
|
||||
private fun sizeOf(file: File): Long =
|
||||
if (file.isDirectory) file.listFiles().orEmpty().sumOf { sizeOf(it) } else file.length()
|
||||
|
||||
/**
|
||||
* The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance.
|
||||
* [onFailure] is what the caller does about it beyond answering [ifBroken].
|
||||
*/
|
||||
private fun <T> guardIo(
|
||||
ifBroken: T,
|
||||
warn: (String) -> Unit,
|
||||
onFailure: () -> Unit = {},
|
||||
body: () -> T,
|
||||
): T =
|
||||
try {
|
||||
body()
|
||||
} catch (e: IOException) {
|
||||
warn("transcript cache unusable: ${e.message}")
|
||||
onFailure()
|
||||
ifBroken
|
||||
} catch (e: SecurityException) {
|
||||
warn("transcript cache unreadable: ${e.message}")
|
||||
onFailure()
|
||||
ifBroken
|
||||
}
|
||||
@@ -5,9 +5,13 @@ 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.
|
||||
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]).
|
||||
*
|
||||
* Events are the only data source, and there is deliberately no second shape for history to drift
|
||||
* from: a page fetched backwards, a live frame, and a line read out of this phone's own cache are
|
||||
* all the same events through the same parser. [TranscriptCache] stores the server's lines rather
|
||||
* than these rows for exactly that reason -- a row is a rendering, and its shape changes whenever
|
||||
* this file does.
|
||||
*/
|
||||
@Immutable
|
||||
sealed class TranscriptItem {
|
||||
@@ -19,10 +23,8 @@ sealed class TranscriptItem {
|
||||
* 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.
|
||||
* A row built from several events keeps the seq of the first, so it holds still while the rest
|
||||
* of it arrives.
|
||||
*/
|
||||
abstract val seq: Long
|
||||
|
||||
@@ -30,10 +32,8 @@ sealed class TranscriptItem {
|
||||
* 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.
|
||||
* the two that know why. Asking each item what it is called is also what stops the next such
|
||||
* item being missed -- a `when` over concrete types would have to gain a case, silently.
|
||||
*/
|
||||
open val key: Any
|
||||
get() = seq
|
||||
@@ -54,15 +54,13 @@ sealed class TranscriptItem {
|
||||
* 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.
|
||||
* drawing it as one item indefinitely. 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 framework bookkeeping.
|
||||
*
|
||||
* Folded from the status event that ended the turn, rather than read off the screen's
|
||||
* 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.
|
||||
* inside that reply would step the list under them.
|
||||
*/
|
||||
val settled: Boolean = false,
|
||||
) : TranscriptItem()
|
||||
@@ -74,11 +72,10 @@ sealed class TranscriptItem {
|
||||
* 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.
|
||||
* Carried rather than derived because a run can gain members at *either* end, 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.
|
||||
*/
|
||||
val runId: String,
|
||||
val tool: String,
|
||||
@@ -89,19 +86,16 @@ sealed class TranscriptItem {
|
||||
* 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.
|
||||
* repeating the input verbatim, so the reader saw the same command twice. 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.
|
||||
* A list because AskUserQuestion asks up to four at once, and a permission is the case of
|
||||
* exactly one rather than 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.
|
||||
* Images this call's result carried, drawn under it. Beside it they had to be paired by
|
||||
* position, and position is what a page boundary breaks.
|
||||
*/
|
||||
val images: List<String> = emptyList(),
|
||||
) : TranscriptItem() {
|
||||
@@ -129,9 +123,8 @@ sealed class TranscriptItem {
|
||||
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.
|
||||
* 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,
|
||||
@@ -140,11 +133,9 @@ sealed class TranscriptItem {
|
||||
/**
|
||||
* 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.
|
||||
* [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began
|
||||
* at. Two messages that arrive during one turn therefore share a seq -- and sharing an
|
||||
* identity as well killed the app, because the list refuses two items with one key.
|
||||
*/
|
||||
val arrived: Long = seq,
|
||||
) : TranscriptItem() {
|
||||
@@ -153,10 +144,9 @@ sealed class TranscriptItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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()
|
||||
|
||||
@@ -165,7 +155,6 @@ sealed class 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()
|
||||
@@ -174,12 +163,12 @@ sealed class 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.
|
||||
* it finishes and this is the part worth keeping: the explanation for a gap in the
|
||||
* conversation.
|
||||
*
|
||||
* 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.
|
||||
* The wire also says what triggered it, and this deliberately does not carry that -- the row
|
||||
* says the two sizes and nothing else, so keeping the trigger would be a field nothing can
|
||||
* read.
|
||||
*/
|
||||
data class CompactedNote(
|
||||
override val seq: Long,
|
||||
@@ -192,16 +181,13 @@ sealed class 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.
|
||||
* keeps whatever it was called when it started, however many calls arrive at either end 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.
|
||||
* rather than inside a collapsed "Called 6 tools" card. Two things follow: it is always visible,
|
||||
* since a run of one is drawn as itself; 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.
|
||||
*/
|
||||
private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String {
|
||||
val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id
|
||||
@@ -214,31 +200,23 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
|
||||
* 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.
|
||||
* from the rest of itself. Both were one thing before the transcript was cut into pages.
|
||||
*
|
||||
* 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.
|
||||
* concatenating the two lists left *both*: the same call twice.
|
||||
*
|
||||
* 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.
|
||||
* boundary destroys. The older row wins on what a start knows and the newer on what an end knows,
|
||||
* 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.
|
||||
* falls cleanly between two finished calls, which is most of them, 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.
|
||||
*/
|
||||
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
|
||||
val (older, newer) = healSplitMessage(earlier, later)
|
||||
@@ -271,15 +249,13 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
|
||||
/**
|
||||
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
|
||||
*
|
||||
* [foldEvent] never leaves two assistant messages next to each other inside one page -- 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.
|
||||
* [foldEvent] never leaves two assistant messages next to each other inside one page, so two
|
||||
* meeting at a join are always the two halves of one reply, and leaving them apart drew a single
|
||||
* answer as two with a paragraph break through the middle of a sentence.
|
||||
*
|
||||
* 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.
|
||||
* The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older
|
||||
* half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
|
||||
* loaded, so the growth extends off the top of the screen.
|
||||
*/
|
||||
private fun healSplitMessage(
|
||||
earlier: List<TranscriptItem>,
|
||||
@@ -297,19 +273,18 @@ private fun healSplitMessage(
|
||||
* 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.
|
||||
* two names. Naming the joined run after the *older* half would be the obvious way round and is
|
||||
* wrong: 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.
|
||||
*/
|
||||
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.
|
||||
// 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. 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 {
|
||||
@@ -324,10 +299,8 @@ private fun adoptRun(
|
||||
* 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.
|
||||
* until the turn's `result`, so the event lands below the whole reply it caused. The server stamps
|
||||
* it with where that turn began and the note takes that seq.
|
||||
*
|
||||
* 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
|
||||
@@ -335,8 +308,7 @@ private fun adoptRun(
|
||||
* 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.
|
||||
* Without a stamp -- a message replayed out of a session file -- it stays where it arrived.
|
||||
*/
|
||||
private fun placePeerNote(
|
||||
items: List<TranscriptItem>,
|
||||
@@ -355,15 +327,14 @@ private fun placePeerNote(
|
||||
* 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.
|
||||
* A run is named from what a call landed next to, 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.
|
||||
* for the opposite reason: there the two halves were always one run, here they were never one
|
||||
* turn's work.
|
||||
*/
|
||||
private fun splitRun(tail: List<TranscriptItem>, behind: String?): List<TranscriptItem> {
|
||||
val first = tail.firstOrNull() as? TranscriptItem.ToolRun ?: return tail
|
||||
@@ -401,12 +372,10 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
)
|
||||
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.
|
||||
// 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.
|
||||
// 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 {
|
||||
@@ -414,9 +383,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
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.
|
||||
// The name is not known from an end alone, so a call that was an ask cannot
|
||||
// be recognised as one here; the page before this replaces the row.
|
||||
runIdFor(items, event.id, "tool"),
|
||||
"tool",
|
||||
"",
|
||||
@@ -435,9 +403,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
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.
|
||||
// A question with no tool behind it -- AskUserQuestion, or an ask whose call fell
|
||||
// outside the loaded window -- is a card of its own.
|
||||
if (
|
||||
event.about != null &&
|
||||
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
|
||||
@@ -448,9 +415,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
}
|
||||
}
|
||||
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.
|
||||
// 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 ->
|
||||
@@ -470,20 +437,19 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
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.
|
||||
// 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.
|
||||
// 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.
|
||||
// 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 }
|
||||
@@ -503,9 +469,8 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
/**
|
||||
* 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.
|
||||
* See [TranscriptItem.AssistantMsg.settled]. 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
|
||||
@@ -528,8 +493,7 @@ private fun updateTool(
|
||||
* 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.
|
||||
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile.
|
||||
*/
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
|
||||
@@ -538,37 +502,33 @@ 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].
|
||||
* whole point: the work happens seconds before the reader reaches the rows it was done for.
|
||||
*
|
||||
* 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
|
||||
* message -- 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.
|
||||
* hand it back through here.
|
||||
*
|
||||
* 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.
|
||||
* had thought about paid its whole parse in the frame it appeared in.
|
||||
*/
|
||||
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.
|
||||
// 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.
|
||||
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.
|
||||
// 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) }
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -24,37 +25,36 @@ import androidx.compose.ui.unit.dp
|
||||
* 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.
|
||||
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge.
|
||||
*
|
||||
* 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.
|
||||
* of which was a way to flicker.
|
||||
*
|
||||
* 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.
|
||||
* reply, and its parse is already made by [warm] before the fold that introduces it.
|
||||
*
|
||||
* 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.
|
||||
* The whole list sits in a [SelectionContainer], which is what makes every word 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. Rows keep their tap handlers: selection is a long press.
|
||||
*
|
||||
* [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.
|
||||
*/
|
||||
@Composable
|
||||
fun TranscriptList(
|
||||
units: List<TranscriptUnit>,
|
||||
state: LazyListState,
|
||||
moreHistory: Boolean,
|
||||
selection: SelectionState,
|
||||
modifier: Modifier = Modifier,
|
||||
below: @Composable () -> Unit,
|
||||
unit: @Composable (TranscriptUnit) -> Unit,
|
||||
) {
|
||||
SelectionContainer {
|
||||
SelectionContainer(selection) {
|
||||
LazyColumn(
|
||||
state = state,
|
||||
reverseLayout = true,
|
||||
@@ -62,8 +62,7 @@ fun TranscriptList(
|
||||
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.
|
||||
// half. Measure includes composing the items that scrolled in.
|
||||
modifier
|
||||
.layout { measurable, constraints ->
|
||||
val started = System.nanoTime()
|
||||
@@ -97,8 +96,7 @@ fun TranscriptList(
|
||||
}
|
||||
// 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.
|
||||
// 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)) {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Where the session screen gets a transcript from: this phone's copy first, the server for the
|
||||
* rest.
|
||||
*
|
||||
* One seam rather than a cache the screen has to remember to consult. Everything it fetched before
|
||||
* is asked of this, and everything the server sends is written into the cache on the way past, so
|
||||
* the screen never learns which side answered. What it does learn, through [DebugStats], is how
|
||||
* often each one did.
|
||||
*
|
||||
* See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind: the cache is never load-bearing.
|
||||
* Every read has a network path beside it producing the same result.
|
||||
*/
|
||||
class TranscriptSource(
|
||||
private val settings: ServerSettings,
|
||||
private val sessionId: String,
|
||||
val cache: SessionCache,
|
||||
) {
|
||||
private val stream = AtomicReference<EventStream?>(null)
|
||||
|
||||
/**
|
||||
* The cached opening window, or null when there is nothing usable to draw.
|
||||
*
|
||||
* Drawn *before* [probe] returns, which is the whole point of the feature: the rows are on
|
||||
* screen while the check that they are still the server's rows is in flight, and a failed check
|
||||
* replaces them exactly as a `reset` does.
|
||||
*/
|
||||
fun cachedOpening(limit: Int = OPENING_WINDOW): List<SeqEvent>? {
|
||||
if (cache.tail() == null) return null
|
||||
val lines = cache.newest(limit)
|
||||
if (lines.isEmpty()) return null
|
||||
return try {
|
||||
lines.map { parseSeqEvent(it) }
|
||||
} catch (e: org.json.JSONException) {
|
||||
// Lines this build cannot read at all, which the cache's own checks cannot see: it
|
||||
// reads a seq off a line, not an event. Nothing to serve, so a cold open.
|
||||
cache.purge()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the server's event at the cached cursor is still the cached one.
|
||||
*
|
||||
* The screen must not resume a stream from a cached seq unless it is the same conversation. A
|
||||
* transcript is append-only in ordinary use, but the file can be replaced or truncated -- a
|
||||
* sandbox re-seeded with the same ids, a backup restored, a session re-imported -- and the
|
||||
* server's catch-up on such a file would hand this phone a continuation of a *different*
|
||||
* conversation, spliced onto the cached one with no seam. Caught with one request of a few
|
||||
* hundred bytes, in the slot the opening page's request used to be in.
|
||||
*
|
||||
* False purges the cache and means "open cold". A throw is the server not being askable, which
|
||||
* is neither: the cached rows stay on screen and the caller tries again on the reconnect
|
||||
* schedule.
|
||||
*
|
||||
* What this cannot see is a line changed in the middle of the file with the tail intact. That
|
||||
* is what the Reload button in session settings is for.
|
||||
*/
|
||||
suspend fun probe(): Boolean {
|
||||
val tail = cache.tail() ?: return false
|
||||
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
|
||||
// the cursor when the server still has one there.
|
||||
val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1)
|
||||
val matches =
|
||||
answer.size == 1 &&
|
||||
try {
|
||||
answer[0].second == parseSeqEvent(tail.line)
|
||||
} catch (e: org.json.JSONException) {
|
||||
false
|
||||
}
|
||||
if (!matches) cache.purge()
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's opening fetch, kept as the start of the live run. Only called when the cache has
|
||||
* nothing to open with, or when [probe] said what it had was not the server's.
|
||||
*/
|
||||
suspend fun fetchOpening(): List<SeqEvent> {
|
||||
DebugStats.count("transcript page from server")
|
||||
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW)
|
||||
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
|
||||
cache.flush()
|
||||
return page.map { it.second }
|
||||
}
|
||||
|
||||
/**
|
||||
* The page before [before]: from the cache when it holds it, otherwise from the server bounded
|
||||
* by what the cache already has.
|
||||
*
|
||||
* The bound is what keeps the cache worth having. A coalesced page reaches back as far as its
|
||||
* row count takes it -- a single reply is hundreds of lines -- so a page fetched after the
|
||||
* reader has been away would run straight past the cached run and overlap it, and an
|
||||
* overlapping page cannot be stored. Told where this phone's copy starts, the server stops
|
||||
* there instead.
|
||||
*/
|
||||
suspend fun page(before: Long, limit: Int, coalesce: Boolean): List<SeqEvent> {
|
||||
cache.page(before, limit, rows = coalesce)?.let { lines ->
|
||||
DebugStats.count("transcript page from cache")
|
||||
return lines.map { parseSeqEvent(it) }
|
||||
}
|
||||
DebugStats.count("transcript page from server")
|
||||
val page =
|
||||
fetchTranscript(
|
||||
settings,
|
||||
sessionId,
|
||||
before = before,
|
||||
limit = limit,
|
||||
coalesce = coalesce,
|
||||
after = cache.coveredUpTo(before)?.minus(1),
|
||||
)
|
||||
if (page.isNotEmpty()) {
|
||||
// `before` rather than the newest line's seq: a coalesced page covers everything up to
|
||||
// the cursor it was asked with, and nothing in its lines says so.
|
||||
cache.storePage(page.map { it.first }, page.first().second.seq, before, rows = coalesce)
|
||||
}
|
||||
return page.map { it.second }
|
||||
}
|
||||
|
||||
/**
|
||||
* [EventStream.run], with every frame written to the cache before [onEvent] sees it.
|
||||
*
|
||||
* Before, so that an event held back for a reader who is scrolled away is already on disk --
|
||||
* what the cache holds is what the server sent, not what the screen has got round to drawing.
|
||||
* Flushed on each status change, which is a turn's boundary and the granularity a crash may as
|
||||
* well lose.
|
||||
*/
|
||||
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
|
||||
val opened = EventStream(settings, sessionId)
|
||||
stream.getAndSet(opened)?.close()
|
||||
try {
|
||||
opened.run(after, onOpen, onReset) { raw, entry ->
|
||||
cache.append(raw, entry.seq)
|
||||
if (entry.event is SessionEvent.Status) cache.flush()
|
||||
onEvent(entry)
|
||||
}
|
||||
} finally {
|
||||
cache.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends the stream, from any thread, and leaves the cache with everything it was given. */
|
||||
fun close() {
|
||||
stream.getAndSet(null)?.close()
|
||||
cache.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many events the screen opens with, cached or fetched.
|
||||
*
|
||||
* The server's own default for a page, named here because the cached opening has to be the same
|
||||
* size as the fetched one -- a reader must not get a shorter first screen for having been here
|
||||
* before.
|
||||
*/
|
||||
private const val OPENING_WINDOW = 80
|
||||
|
||||
/**
|
||||
* Where this server's cached transcripts live.
|
||||
*
|
||||
* Under `cacheDir` because that is exactly what it is for: bytes the phone can regenerate from the
|
||||
* server, which Android may delete under storage pressure without asking. Keyed by host and port
|
||||
* because two servers can hold a session with the same id, and a line from one shown against the
|
||||
* other is the whole invariant broken. `v1` is the layout's version.
|
||||
*/
|
||||
fun cacheRoot(context: Context, settings: ServerSettings): File {
|
||||
val transcripts = File(context.cacheDir, "transcripts")
|
||||
transcripts.listFiles()?.forEach { if (it.name != CACHE_VERSION) it.deleteRecursively() }
|
||||
return File(transcripts, "$CACHE_VERSION/${settings.host}_${settings.port}")
|
||||
}
|
||||
|
||||
private const val CACHE_VERSION = "v1"
|
||||
@@ -12,8 +12,7 @@ import androidx.compose.ui.unit.dp
|
||||
* 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.
|
||||
* frame is bounded. This is the piece that was missing when a lazy list was last tried here.
|
||||
*
|
||||
* 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.
|
||||
@@ -27,10 +26,9 @@ sealed class TranscriptUnit {
|
||||
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.
|
||||
* 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
|
||||
|
||||
@@ -66,15 +64,13 @@ sealed class TranscriptUnit {
|
||||
*
|
||||
* 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.
|
||||
* same reason. 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 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].
|
||||
* owns.
|
||||
*/
|
||||
data class PeerHead(
|
||||
override val seq: Long,
|
||||
@@ -84,8 +80,7 @@ sealed class TranscriptUnit {
|
||||
) : 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].
|
||||
* -- and so two notes stamped with one turn's seq are still two items.
|
||||
*/
|
||||
override val key: Any
|
||||
get() = item.key
|
||||
@@ -119,8 +114,7 @@ sealed class TranscriptUnit {
|
||||
*
|
||||
* 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.
|
||||
* pixels of `Text` whose layout lands in the frame the row scrolls into.
|
||||
*/
|
||||
data class UserChunk(
|
||||
override val seq: Long,
|
||||
@@ -152,14 +146,11 @@ sealed class TranscriptUnit {
|
||||
* 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
|
||||
* Every settled reply is cut into its 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. A shut one
|
||||
* is a single heading and cannot be worth splitting. The reply still arriving stays whole: its text
|
||||
* changes with every delta, and splitting it here would parse the whole message per delta on
|
||||
* whichever thread is composing. 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
|
||||
@@ -200,8 +191,8 @@ fun transcriptUnits(
|
||||
}
|
||||
}
|
||||
} 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.
|
||||
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like the
|
||||
// markdown splits so the scan happens once per message rather than once per fold.
|
||||
val chunks = replies.chunksOf(item.text)
|
||||
chunks.forEachIndexed { at, chunk ->
|
||||
units +=
|
||||
@@ -252,8 +243,8 @@ fun transcriptUnits(
|
||||
}
|
||||
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.
|
||||
// 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
|
||||
}
|
||||
@@ -261,9 +252,8 @@ fun transcriptUnits(
|
||||
/**
|
||||
* 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.
|
||||
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two are
|
||||
* answered by different things: this one by the fold, the other by whether [warm] has run.
|
||||
*/
|
||||
private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) =
|
||||
item.settled || index != lastIndex
|
||||
@@ -272,8 +262,7 @@ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, 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
|
||||
* is the reply that just finished streaming -- nothing warms live deltas. The session screen warms
|
||||
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
|
||||
* ready parses.
|
||||
*/
|
||||
|
||||
@@ -12,19 +12,15 @@ import androidx.compose.runtime.Composable
|
||||
* 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.
|
||||
* recorded under two project directories, which is an ordinary state of a machine.
|
||||
*
|
||||
* 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.
|
||||
* the hazard and none could rule it out locally. Hence one function they all go through.
|
||||
*
|
||||
* 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.
|
||||
* Dropping the repeat is right here because the key is the whole identity: two rows with one id are
|
||||
* two rows every action would treat as the same thing. Where the duplicate means something, 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.
|
||||
|
||||
@@ -15,20 +15,11 @@ import androidx.compose.material3.Surface
|
||||
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 androidx.compose.ui.window.Dialog
|
||||
import java.time.OffsetDateTime
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Window bars for the account's rate limits, with reset times.
|
||||
@@ -36,33 +27,14 @@ import kotlinx.coroutines.withContext
|
||||
* 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.
|
||||
* own, so the only thing its Back could ever have meant was "put this away".
|
||||
*/
|
||||
@Composable
|
||||
fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
|
||||
|
||||
fun refresh() {
|
||||
state = LoadState.Loading
|
||||
scope.launch {
|
||||
state =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchUsage(settings)) }
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { refresh() }
|
||||
|
||||
// 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.
|
||||
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
||||
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
|
||||
// between its title, content and buttons at sizes meant for a sentence of prose and a decision;
|
||||
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was
|
||||
// taller than a bar.
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
@@ -74,25 +46,29 @@ fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) {
|
||||
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.
|
||||
// numbers belong to an account on a particular machine -- naming the session's
|
||||
// provider here made an echo session's screen read "echo" above a line reading
|
||||
// "claude". Each machine names itself and the service it came from.
|
||||
Text(
|
||||
"Usage",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GlyphButton(REFRESH_GLYPH, "Refresh usage", { refresh() })
|
||||
// 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.
|
||||
// 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.
|
||||
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
|
||||
UsageBody(state)
|
||||
UsageBody(feed.snapshots)
|
||||
}
|
||||
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
||||
Text("Close")
|
||||
@@ -111,8 +87,8 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
||||
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.
|
||||
// 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,
|
||||
@@ -121,17 +97,16 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
||||
} 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.
|
||||
// costing 16dp on every side. What separates one machine from the next is the
|
||||
// line naming it.
|
||||
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.
|
||||
// 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. 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,
|
||||
@@ -139,8 +114,8 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
||||
)
|
||||
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.
|
||||
// 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))
|
||||
}
|
||||
@@ -157,8 +132,7 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
||||
*
|
||||
* 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.
|
||||
* the interface nagging about a decision already made. Only the two faults are coloured as faults.
|
||||
*/
|
||||
@Composable
|
||||
private fun SnapshotState(snapshot: UsageSnapshot) {
|
||||
@@ -170,8 +144,8 @@ private fun SnapshotState(snapshot: UsageSnapshot) {
|
||||
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".
|
||||
// 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.",
|
||||
@@ -218,10 +192,9 @@ private fun WindowBar(window: UsageWindow) {
|
||||
/**
|
||||
* "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
|
||||
* Null for a window that is not running: there is no end to report. What this used to get wrong is
|
||||
* the other missing case, a timestamp that arrived and could not be read -- printed raw, so a parse
|
||||
* failure appeared as an ISO string in a sentence written for a person. Both are named in
|
||||
* [WindowEnd], and the session bar words them the same way.
|
||||
*/
|
||||
private fun resetLine(window: UsageWindow): String? =
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,87 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The line arithmetic behind the file viewer.
|
||||
*
|
||||
* Worth a test rather than an eye: a line number that is one out is invisible in a short file and
|
||||
* obvious in a long one, and a colour that stops at a line break is invisible until the file has a
|
||||
* block comment in it.
|
||||
*/
|
||||
class FileLinesTest {
|
||||
@Test
|
||||
fun `a file that ends with a newline has the number of lines its author would count`() {
|
||||
assertEquals(listOf("one", "two"), FileLines.of("one\ntwo\n", null).lines)
|
||||
assertEquals(listOf("one", "two"), FileLines.of("one\ntwo", null).lines)
|
||||
// Only one is dropped: a blank line at the end of a file is a line somebody typed.
|
||||
assertEquals(listOf("one", "two", ""), FileLines.of("one\ntwo\n\n", null).lines)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty file is one empty line`() {
|
||||
val lines = FileLines.of("", null)
|
||||
assertEquals(1, lines.size)
|
||||
assertEquals("", lines.line(0).text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a comment that spans lines is coloured on every line it covers`() {
|
||||
val text = "fn a() {}\n/* still\n a comment */\nfn b() {}\n"
|
||||
val lines = FileLines.of(text, Language.RUST)
|
||||
assertEquals(4, lines.size)
|
||||
val comment = catppuccinSyntax().of(Kind.COMMENT)
|
||||
// The whole of the middle line, and the part of the third up to the closer.
|
||||
assertTrue(lines.line(1).spanStyles.any { it.item.color == comment && it.start == 0 })
|
||||
val third = lines.line(2)
|
||||
assertTrue(third.spanStyles.any { it.item.color == comment && it.end == third.length })
|
||||
// And the code around it is not commented.
|
||||
assertTrue(lines.line(0).spanStyles.none { it.item.color == comment })
|
||||
assertTrue(lines.line(3).spanStyles.none { it.item.color == comment })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a span never runs past the line it was cut into`() {
|
||||
val lines = FileLines.of("val x = \"a\nb\"\nval y = 1\n", Language.KOTLIN)
|
||||
for (index in 0 until lines.size) {
|
||||
val line = lines.line(index)
|
||||
assertTrue(
|
||||
line.spanStyles.all { it.start >= 0 && it.end <= line.length },
|
||||
"line $index",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The number every row in the viewer is sized to. It has to be the widest line, because rows of
|
||||
* their natural widths scroll sideways by different amounts -- see [FileViewer].
|
||||
*/
|
||||
@Test
|
||||
fun `the column count is the widest line, counting a tab as eight`() {
|
||||
assertEquals(5, FileLines.of("one\nthree\nx\n", null).columns)
|
||||
// A tab counts up to eight, and upwards on purpose: over-estimating leaves empty space
|
||||
// past the longest line, under-estimating puts its end out of reach.
|
||||
assertEquals(9, FileLines.of("\tx\nshort\n", null).columns)
|
||||
// An empty file is one empty line, which is no columns at all rather than an error.
|
||||
assertEquals(0, FileLines.of("", null).columns)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file with no language is plain`() {
|
||||
val lines = FileLines.of("fn main() {}\n", null)
|
||||
assertTrue(lines.line(0).spanStyles.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a language comes from the extension, and only from a real one`() {
|
||||
assertEquals(Language.KOTLIN, fileLanguage("Main.kt"))
|
||||
assertEquals(Language.KOTLIN, fileLanguage("build.gradle.kts"))
|
||||
assertEquals(Language.RUST, fileLanguage("files.rs"))
|
||||
assertEquals(Language.TOML, fileLanguage("Cargo.toml"))
|
||||
assertEquals(null, fileLanguage("Makefile"))
|
||||
assertEquals(null, fileLanguage(".bashrc"))
|
||||
assertEquals(null, fileLanguage("notes.txt"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
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> =
|
||||
spansOf(code, 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")
|
||||
}
|
||||
|
||||
// Markdown, which has a scanner of its own: what a character means there is decided by where
|
||||
// it sits rather than by what it is, so most of these are about the cases where it means
|
||||
// nothing at all.
|
||||
|
||||
@Test
|
||||
fun `a heading is coloured whole and a hash inside a word is not one`() {
|
||||
val code = "## Layout\nissue #12 is fixed\n#hashtag"
|
||||
assertSpans(code, Language.MARKDOWN, Kind.KEYWORD, "## Layout")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seven hashes are not a heading`() {
|
||||
assertSpans("####### deep", Language.MARKDOWN, Kind.KEYWORD)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fence carries its language as metadata and its body as one string`() {
|
||||
val code = "text\n```kotlin\nval x = 1\n```\nmore"
|
||||
assertSpans(code, Language.MARKDOWN, Kind.METADATA, "kotlin")
|
||||
assertSpans(code, Language.MARKDOWN, Kind.STRING, "```", "val x = 1", "```")
|
||||
}
|
||||
|
||||
/** The state that crosses a line, so the one worth asking about at both ends. */
|
||||
@Test
|
||||
fun `a longer fence is not closed by a shorter one, and a heading inside it is not a heading`() {
|
||||
val code = "````\n```\n# not a heading\n````\nafter"
|
||||
assertSpans(code, Language.MARKDOWN, Kind.KEYWORD)
|
||||
assertSpans(code, Language.MARKDOWN, Kind.STRING, "````", "```", "# not a heading", "````")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unclosed fence runs to the end rather than throwing`() {
|
||||
assertSpans("```\nstill going", Language.MARKDOWN, Kind.STRING, "```", "still going")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list markers and quote markers colour without their text`() {
|
||||
val code = "- one\n2. two\n> quoted"
|
||||
assertSpans(code, Language.MARKDOWN, Kind.MARK, "-", "2.", ">")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule and a setext underline are the same mark`() {
|
||||
assertSpans("Title\n=====\n\n---", Language.MARKDOWN, Kind.MARK, "=====", "---")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `emphasis needs something on both sides of it`() {
|
||||
assertSpans("**bold** and *thin*", Language.MARKDOWN, Kind.LITERAL, "**bold**", "*thin*")
|
||||
// The case the guards exist for: a C fragment written in a paragraph.
|
||||
assertSpans("a * b * c and *p = *q", Language.MARKDOWN, Kind.LITERAL)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an underscore inside a word emphasises nothing`() {
|
||||
assertSpans("snake_case_name and _real_", Language.MARKDOWN, Kind.LITERAL, "_real_")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a code span holds a backtick when opened with two`() {
|
||||
assertSpans("``a ` b`` and `c`", Language.MARKDOWN, Kind.STRING, "``a ` b``", "`c`")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unclosed code span is ordinary text`() {
|
||||
assertSpans("a ` b", Language.MARKDOWN, Kind.STRING)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a link marks its brackets and colours its destination`() {
|
||||
val code = "see [the plan](PLAN.md) now"
|
||||
assertSpans(code, Language.MARKDOWN, Kind.MARK, "[", "]")
|
||||
assertSpans(code, Language.MARKDOWN, Kind.METADATA, "(PLAN.md)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a table is found by its delimiter row, and pipes elsewhere are plain`() {
|
||||
val code = "| a | b |\n|---|---|\n| 1 | 2 |\n\nrun a | b in a paragraph"
|
||||
assertSpans(
|
||||
code,
|
||||
Language.MARKDOWN,
|
||||
Kind.MARK,
|
||||
"|",
|
||||
"|",
|
||||
"|",
|
||||
"|---|---|",
|
||||
"|",
|
||||
"|",
|
||||
"|",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a table without outer pipes still colours, and the table ends with the rows`() {
|
||||
val code = "a | b\n--- | ---\nnot a row"
|
||||
assertSpans(code, Language.MARKDOWN, Kind.MARK, "|", "--- | ---")
|
||||
}
|
||||
|
||||
/**
|
||||
* The tag in the last case is not an autolink and is left plain, but the address inside it is
|
||||
* still an address and the bare-URL pass finds it. That is the intended reading: raw HTML is
|
||||
* not something this scanner knows, and a URL is a URL wherever it was written.
|
||||
*/
|
||||
@Test
|
||||
fun `an autolink colours and an HTML tag does not`() {
|
||||
val code = "<https://example.com> and <a@b.com> and <div> and <img src=\"http://x\">"
|
||||
assertSpans(
|
||||
code,
|
||||
Language.MARKDOWN,
|
||||
Kind.METADATA,
|
||||
"<https://example.com>",
|
||||
"<a@b.com>",
|
||||
"http://x",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bare URL gives back the sentence's punctuation`() {
|
||||
assertSpans(
|
||||
"see https://example.com/a., and ssh://host/x)",
|
||||
Language.MARKDOWN,
|
||||
Kind.METADATA,
|
||||
"https://example.com/a",
|
||||
"ssh://host/x",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bracket a URL opened itself stays in it`() {
|
||||
assertSpans(
|
||||
"https://en.wikipedia.org/wiki/A_(b) here",
|
||||
Language.MARKDOWN,
|
||||
Kind.METADATA,
|
||||
"https://en.wikipedia.org/wiki/A_(b)",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a URL inside a link destination is not coloured twice`() {
|
||||
assertSpans(
|
||||
"[x](https://example.com)",
|
||||
Language.MARKDOWN,
|
||||
Kind.METADATA,
|
||||
"(https://example.com)",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bracket with no destination after it is left plain`() {
|
||||
assertSpans("an [aside] here", Language.MARKDOWN, Kind.MARK)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown fence language is drawn plain`() {
|
||||
assertEquals(null, fenceLanguage("brainfuck"))
|
||||
assertEquals("+[-]", highlight("+[-]", fenceLanguage("brainfuck")).text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every language the fence table knows has a scanner`() {
|
||||
Language.entries.forEach { spansOf("x", 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",
|
||||
"###",
|
||||
"#",
|
||||
"#
|
||||
for (language in Language.entries) {
|
||||
for (code in nasty) {
|
||||
val spans = spansOf(code, 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,310 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
|
||||
/**
|
||||
* The cache's file logic, which is the half of the transcript cache that can be wrong without
|
||||
* anything on screen saying so: a page served short, a chunk served across a gap, or a run of lines
|
||||
* whose recorded coverage does not match what is in it.
|
||||
*
|
||||
* Lines here are the shape the server writes -- `{"seq":N,"ts":T,"type":...}` -- because that is
|
||||
* what the cache reads its two facts off. Nothing parses JSON on either side.
|
||||
*/
|
||||
class TranscriptCacheTest {
|
||||
@field:TempDir lateinit var temp: File
|
||||
|
||||
private val said = mutableListOf<String>()
|
||||
|
||||
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
|
||||
|
||||
private fun session(id: String = "s") = cache().session(id)
|
||||
|
||||
private fun line(seq: Long, type: String = "toolStart") =
|
||||
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
|
||||
|
||||
private fun delta(seq: Long) = line(seq, "assistantText")
|
||||
|
||||
private fun dirOf(id: String = "s") = File(temp, "v1/host_8443/$id")
|
||||
|
||||
private fun names(id: String = "s") = dirOf(id).list().orEmpty().sorted()
|
||||
|
||||
private fun write(name: String, lines: List<String>, id: String = "s") {
|
||||
dirOf(id).mkdirs()
|
||||
File(dirOf(id), name).writeText(lines.joinToString("\n", postfix = "\n"))
|
||||
}
|
||||
|
||||
private fun seqs(lines: List<String>?) = lines?.map {
|
||||
Regex("\"seq\":(\\d+)").find(it)!!.groupValues[1].toLong()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun an_appended_run_is_one_open_chunk_and_its_newest_line_is_the_tail() {
|
||||
val cache = session()
|
||||
(1L..3L).forEach { cache.append(line(it), it) }
|
||||
cache.flush()
|
||||
|
||||
assertEquals(listOf("1-open.raw.jsonl"), names())
|
||||
assertEquals(CachedTail(3, line(3)), cache.tail())
|
||||
assertEquals(listOf(line(2), line(3)), cache.newest(2))
|
||||
// More than there is is what there is, which is a short opening window and not a failure.
|
||||
assertEquals(3, cache.newest(80).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_gap_in_the_stream_closes_the_open_chunk_under_the_end_it_turned_out_to_have() {
|
||||
val cache = session()
|
||||
(1L..3L).forEach { cache.append(line(it), it) }
|
||||
// What a `reset` looks like from here: the next event is not the one after the last.
|
||||
cache.append(line(90), 90)
|
||||
cache.flush()
|
||||
|
||||
assertEquals(listOf("1-4.raw.jsonl", "90-open.raw.jsonl"), names())
|
||||
// Nothing is served across the gap: the suffix is the newest chunk alone.
|
||||
assertEquals(listOf(line(90)), cache.newest(80))
|
||||
assertEquals(CachedTail(90, line(90)), cache.tail())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun an_event_already_covered_is_not_written_again() {
|
||||
val cache = session()
|
||||
(1L..3L).forEach { cache.append(line(it), it) }
|
||||
cache.append(line(2), 2)
|
||||
cache.flush()
|
||||
|
||||
assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun an_adjacent_page_extends_the_suffix_and_a_gap_stops_it() {
|
||||
val cache = session()
|
||||
(100L..102L).forEach { cache.append(line(it), it) }
|
||||
cache.flush()
|
||||
|
||||
// Adjacent: its end is the open chunk's first.
|
||||
assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true))
|
||||
assertEquals(listOf(98L, 99L), seqs(cache.page(before = 100, limit = 2, rows = false)))
|
||||
assertEquals(60L, seqs(cache.newest(80))?.first())
|
||||
|
||||
// Behind a gap: kept on disk, because paging usually closes the gap, but never served
|
||||
// across it.
|
||||
assertTrue(cache.storePage((1L..9L).map { line(it) }, 1, 10, rows = true))
|
||||
assertNull(cache.page(before = 10, limit = 5, rows = false))
|
||||
assertEquals(60L, seqs(cache.newest(200))?.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_page_that_overlaps_what_is_here_is_not_stored() {
|
||||
val cache = session()
|
||||
cache.append(line(100), 100)
|
||||
cache.flush()
|
||||
assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true))
|
||||
|
||||
assertFalse(cache.storePage((50L..79L).map { line(it) }, 50, 80, rows = true))
|
||||
assertFalse(cache.storePage(emptyList(), 40, 60, rows = true))
|
||||
assertEquals(listOf("100-open.raw.jsonl", "60-100.rows.jsonl"), names())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_miss_is_null_and_never_an_empty_page() {
|
||||
val cache = session()
|
||||
(100L..102L).forEach { cache.append(line(it), it) }
|
||||
cache.flush()
|
||||
|
||||
// At or below where the run starts, so what the reader is scrolling into is the server's.
|
||||
// An empty list here would be read as the start of the conversation and would stop the
|
||||
// transcript scrolling back at all.
|
||||
assertNull(cache.page(before = 100, limit = 40, rows = true))
|
||||
assertNull(cache.page(before = 40, limit = 40, rows = true))
|
||||
assertNull(session("never-visited").page(before = 100, limit = 40, rows = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_page_starts_from_anywhere_inside_the_run_not_only_at_a_boundary() {
|
||||
val cache = session()
|
||||
(1L..10L).forEach { cache.append(line(it), it) }
|
||||
cache.flush()
|
||||
|
||||
// Where a warm open leaves the cursor: in the middle of the live run, because the screen
|
||||
// drew the newest lines of it. A cache that could only answer at a chunk boundary would
|
||||
// send this to the server -- and the page that came back would overlap the run and be
|
||||
// thrown away, so the whole of the scroll back would be fetched again on every visit.
|
||||
assertEquals(listOf(5L, 6L, 7L), seqs(cache.page(before = 8, limit = 3, rows = false)))
|
||||
assertEquals((1L..7L).toList(), seqs(cache.page(before = 8, limit = 99, rows = false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_page_counted_in_rows_folds_each_delta_run_into_one_and_cuts_only_between_rows() {
|
||||
val cache = session()
|
||||
// Two replies of three deltas each, split by a tool call: the same fixture as the
|
||||
// server's `coalescing_counts_rows_and_joins_delta_runs`.
|
||||
val lines =
|
||||
listOf(delta(1), delta(2), delta(3), line(4), delta(5), delta(6), delta(7), line(8))
|
||||
write("1-9.raw.jsonl", lines)
|
||||
cache.append(line(9), 9)
|
||||
cache.flush()
|
||||
|
||||
// Three rows: the tool call at 8, the run 5..7, and the tool call at 4. The cut lands
|
||||
// between rows, so the older run is not started.
|
||||
assertEquals(
|
||||
listOf(4L, 5L, 6L, 7L, 8L),
|
||||
seqs(cache.page(before = 9, limit = 3, rows = true)),
|
||||
)
|
||||
// One row is one whole run, however many deltas it is made of.
|
||||
assertEquals(listOf(8L), seqs(cache.page(before = 9, limit = 1, rows = true)))
|
||||
// A page of lines counts lines, which is what the anchor restore asks for.
|
||||
assertEquals(listOf(7L, 8L), seqs(cache.page(before = 9, limit = 2, rows = false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_row_page_crosses_a_chunk_boundary_and_stops_short_at_the_oldest_chunk() {
|
||||
val cache = session()
|
||||
write("5-9.raw.jsonl", listOf(delta(5), delta(6), line(7), delta(8)))
|
||||
cache.append(delta(9), 9)
|
||||
cache.append(line(10), 10)
|
||||
cache.flush()
|
||||
|
||||
// A run straddling the boundary is one row, as it will be once folded.
|
||||
assertEquals(listOf(8L, 9L, 10L), seqs(cache.page(before = 11, limit = 2, rows = true)))
|
||||
// Asking for more rows than the suffix holds is a short page, not a failure and not a
|
||||
// claim that the conversation starts here.
|
||||
assertEquals((5L..10L).toList(), seqs(cache.page(before = 11, limit = 40, rows = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun the_floor_for_a_fetch_is_the_nearest_chunk_at_or_below_it() {
|
||||
val cache = session()
|
||||
write("1-10.rows.jsonl", (1L..9L).map { line(it) })
|
||||
write("10-40.rows.jsonl", (10L..39L).map { line(it) })
|
||||
cache.append(line(90), 90)
|
||||
cache.flush()
|
||||
|
||||
// The run behind the gap, which is what makes the fetched page adjacent to it: a page
|
||||
// fetched before 90 with a floor of 39 stops at 40 and closes the gap exactly.
|
||||
assertEquals(40L, cache.coveredUpTo(90))
|
||||
assertEquals(40L, cache.coveredUpTo(41))
|
||||
assertEquals(10L, cache.coveredUpTo(10))
|
||||
// Nothing at or below the oldest chunk's start, so the page is bounded only by its limit.
|
||||
assertNull(cache.coveredUpTo(9))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_newest_chunk_that_is_not_raw_discards_the_session() {
|
||||
val cache = session()
|
||||
write("1-10.rows.jsonl", (1L..9L).map { line(it) })
|
||||
|
||||
// Only reachable by dying between closing one live run and opening the next, and there is
|
||||
// no cursor to be read off a coalesced line -- so the open is a cold one.
|
||||
assertNull(cache.tail())
|
||||
assertFalse(dirOf().exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_half_written_last_line_is_dropped_and_the_file_repaired() {
|
||||
val cache = session()
|
||||
dirOf().mkdirs()
|
||||
File(dirOf(), "1-open.raw.jsonl").writeText(line(1) + "\n" + line(2) + "\n" + """{"se""")
|
||||
|
||||
assertEquals(CachedTail(2, line(2)), cache.tail())
|
||||
assertEquals(line(1) + "\n" + line(2) + "\n", File(dirOf(), "1-open.raw.jsonl").readText())
|
||||
// And the run continues from where the good tail left off.
|
||||
cache.append(line(3), 3)
|
||||
cache.flush()
|
||||
assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun damage_anywhere_else_discards_the_session_when_a_read_reaches_it() {
|
||||
val cache = session()
|
||||
write("1-open.raw.jsonl", listOf(line(1), "not ours", line(3)))
|
||||
|
||||
// Not seen by the tail, which reads the newest line and stops -- reading a chunk from its
|
||||
// end is exactly not reading the rest of it, and that is what keeps a warm open cheap on
|
||||
// a conversation of tens of megabytes.
|
||||
assertEquals(CachedTail(3, line(3)), cache.tail())
|
||||
// Reached by a read that walks past it, and there is no honest way to say what a chunk
|
||||
// covers with a line of it unreadable -- so what is served is nothing, and the session
|
||||
// opens cold from here on.
|
||||
assertEquals(emptyList(), cache.newest(80))
|
||||
assertFalse(dirOf().exists())
|
||||
assertTrue(said.any { it.contains("damaged") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_name_this_does_not_recognise_is_ignored() {
|
||||
val cache = session()
|
||||
write("notes.txt", listOf("hello"))
|
||||
write("1-open.raw.jsonl", listOf(line(1)))
|
||||
|
||||
assertEquals(CachedTail(1, line(1)), cache.tail())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_chunk_larger_than_one_read_block_is_walked_across_the_boundaries() {
|
||||
val cache = session()
|
||||
// Well past the 64 kB block the backwards reader takes at a time, so a page has to be
|
||||
// stitched across several of them -- including a line that straddles a boundary, which
|
||||
// is the case nothing else here would notice going wrong.
|
||||
val padding = "x".repeat(300)
|
||||
val lines = (1L..500L).map { """{"seq":$it,"ts":1.5,"type":"toolStart","id":"$padding"}""" }
|
||||
write("1-open.raw.jsonl", lines)
|
||||
|
||||
assertEquals(500L, cache.tail()!!.seq)
|
||||
assertEquals(lines.takeLast(80), cache.newest(80))
|
||||
assertEquals(lines.subList(0, 400), cache.page(before = 401, limit = 999, rows = false))
|
||||
// And a non-ASCII line, whose bytes a naive split could cut through a character.
|
||||
val accented = """{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"""
|
||||
cache.append(accented, 501)
|
||||
cache.flush()
|
||||
assertEquals(accented, cache.tail()!!.line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eviction_takes_the_least_recently_touched_and_never_the_one_on_screen() {
|
||||
val cache = cache()
|
||||
listOf("old", "middle", "open").forEachIndexed { at, id ->
|
||||
write("1-open.raw.jsonl", List(50) { line(it + 1L) }, id = id)
|
||||
dirOf(id).setLastModified(1_000_000L + at * 1000L)
|
||||
}
|
||||
val each = dirOf("old").walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
||||
|
||||
// Room for two of the three, so the oldest goes -- and the session being read never does,
|
||||
// however long ago it was last touched.
|
||||
cache.evictToBudget(keep = "open", budget = each * 2)
|
||||
assertEquals(listOf("middle", "open"), File(temp, "v1/host_8443").list()!!.sorted())
|
||||
|
||||
cache.evictToBudget(keep = "open", budget = 0)
|
||||
assertEquals(listOf("open"), File(temp, "v1/host_8443").list()!!.sorted())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retaining_deletes_exactly_the_sessions_the_server_no_longer_lists() {
|
||||
val cache = cache()
|
||||
listOf("a", "b", "c").forEach { write("1-open.raw.jsonl", listOf(line(1)), id = it) }
|
||||
|
||||
cache.retainOnly(setOf("a", "c"))
|
||||
assertEquals(listOf("a", "c"), File(temp, "v1/host_8443").list()!!.sorted())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun size_and_purge_are_the_two_halves_of_the_reload_button() {
|
||||
val cache = session()
|
||||
assertEquals(0L, cache.bytes())
|
||||
(1L..5L).forEach { cache.append(line(it), it) }
|
||||
cache.flush()
|
||||
|
||||
assertTrue(cache.bytes() > 0)
|
||||
cache.purge()
|
||||
assertEquals(0L, cache.bytes())
|
||||
assertNull(cache.tail())
|
||||
// And the session is usable again straight afterwards, which is what a reload does next.
|
||||
cache.append(line(9), 9)
|
||||
cache.flush()
|
||||
assertEquals(listOf(9L), seqs(cache.newest(80)))
|
||||
}
|
||||
}
|
||||
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user