diff --git a/AGENTS.md b/AGENTS.md index e7f3962..47d818f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,11 +5,6 @@ replacing the Claude app for daily use. Rust/Axum backend on the desktop, Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token between them. -**`TRANSCRIPT_RENDERING.md` is the record of the transcript work** -- -measurements, techniques, the harness, and the ordered list of what is -next. Read it before touching anything under `Markdown*.kt`, -`Transcript*.kt` or `SessionScreen.kt`'s list. - **`PLAN.md` is the design source of truth.** Read it before building or changing anything structural. It records every decision with its date, its rationale, and the alternatives that were rejected and why — keep that habit @@ -129,6 +124,35 @@ repo is in PLAN.md's "Backend layout" section. what makes two icon buttons the same width without either being given one, and it is why `GLYPH_SIZE` is smaller than it looks like it should be. +- **The file explorer** — `FilesScreen.kt` (the navigation stack, the + per-directory cache, the create dialog), `FileViewer.kt` (a `LazyColumn` + of lines, each with its own colours from `FileLines.kt`, sharing one + horizontal scroll so nothing wraps), `FileEditor.kt` (a + `BasicTextField` with a `VisualTransformation` carrying the scanner's + spans, which is the one Compose API that colours a field's own text). + It draws **over** the session in `AppRoot`'s `Screen.Session`, so the + session under it stays composed and coming back from a file costs + nothing; back steps editor → viewer → directory → parent and only closes + from where it opened. `EXPLORER.md` is the design and `server/src/files.rs` + is the other half. + To exercise it, `./ui-sandbox.sh` builds a fixture tree at the sandbox + home's `~/files` holding the states that are otherwise only reachable by + finding a real machine in one: an empty directory, a name with a tab in + it and one with an apostrophe, a binary file, one over `FILE_LIMIT`, one + `chmod 000`, a symlink to a directory and a broken one, a source file per + language, and the three sizes the limits were measured against + (`edit-32k.rs`, `edit-128k.rs`, `big-source.rs`), so those figures can be + taken again rather than re-derived. Point a session at it with + `./ui-sandbox.sh api /sessions//cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`. + The 409 is produced by editing the file on the machine (`printf … > file`) + between pressing the pencil and pressing save. + **Reading is cheap and editing is not**, and the sizes are measured + rather than guessed -- see EXPLORER.md's "What the measurements said". + The viewer handles a 1 MiB, 28,000-line file because it draws one row per + line; the editor is one `BasicTextField`, which costs two seconds a frame + at 128 kB and stops the app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB + with the reason said on screen. If you make the editor faster, that + number is what to move. - `.dev-updater.ron` — what Dev Updater is asked to do with this checkout: the server (built in `server/`, run as `service: Managed(...)`) and the APK (built in `app/`), built in parallel. The project it serves is the @@ -212,6 +236,23 @@ IQ2_XXS of that model produces fluent nonsense, which reads exactly like a broken driver — `llama-cli` produces the same from the file directly, which is how to tell the two apart in a hurry. +**No script that drives this app's UI presses a coordinate.** Every control +is found by the name it already carries for assistive technology -- +`ui-trace record --do "tap 'Session settings'"`, which resolves the label +against the screen at the moment of the gesture and fails the whole run +when it is not there. `app/bench-lib.sh` is what `transcript-bench.sh` and +`stream-bench.sh` share for it. A coordinate is a position measured once by +hand, and anything that moves the control makes the tap land on whatever +now sits there -- the bench then reports a number that was never measured, +which reads exactly like a result. Both scripts pressed the render report +at `tap 723 205` until that button moved into the session settings dialog +on 2026-09-03. The check that none has crept back: + + grep -n "tap [0-9]" app/*.sh + +Swipes are still coordinates, deliberately: a gesture across a scrolling +area is a distance rather than a control. + **How to test SSH here, since there is no second machine:** ssh this VM to itself. Generate a throwaway key, append the public half to `~/.ssh/authorized_keys`, and configure a host of `bob@127.0.0.1` with @@ -324,7 +365,7 @@ first if a remote spawn ever mangles an argument. **An inline code chip is drawn behind the text** rather than as the renderer's span background, because a span background is part of the text's own drawing and hid the selection under it -- see - `appendCodeChip` in `MarkdownLinks.kt` and TRANSCRIPT_RENDERING.md. + `appendCodeChip` in `MarkdownLinks.kt`. - **A session can be moved to another directory** from the settings dialog (`POST /sessions/{id}/cwd`). It stops the process, because a working directory is settled at spawn; the next message starts it in the new one. @@ -477,7 +518,48 @@ first if a remote spawn ever mangles an argument. the in-app copy button produces, whose `on screen:` line names what the viewport was actually holding. Compare two runs of it with the same gestures; the emulator's absolute frame times transfer nothing, the - report's accounting does. + report's accounting does. Run it either side of any change under + `Markdown*.kt`, `Transcript*.kt` or `SessionScreen.kt`'s list, and put the + report in the commit; the numbers that move first are the worst + `record: one block`, the reparse mean while streaming, and the draw + phase's accounting line. +- **`app/stream-bench.sh [-k] FILE` is that measurement for a reply still + arriving.** It 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, and prints. Both of those are corrections to a + first version that measured nothing: a transcript parked further back never + redraws while a reply streams into it, and a session is idle at *both* ends + of a turn, so polling for idle answers before the turn has started. +- **`app/trace-draw.sh` names what a scrolling frame spends inside the + framework**, from `atrace` text output with no trace processor needed. It + is how the cost of a layout node per link was attributed to the framework + rather than guessed at. +- **Two traps in the emulator bench loop**, each of which cost a run. + `adb shell pm clear` removes the enrolment and the notification permission + along with the saved anchors, so the next run measures a permission dialog + -- re-enrol with the command `ui-sandbox.sh` prints, and + `pm grant ... POST_NOTIFICATIONS`. And a saved scroll anchor is per session + id, so the only way two builds start a scroll from the same place is a + *fresh session for each*. +- **A phone that falls behind the stream is answered with `reset`, and + `RUST_LOG=ai_server=debug` says when.** Every SSE subscriber logs the + cursor it arrived with and whether it was continued or reset + (`stream backlog:` in `send_backlog`), which is the only place that + question is answerable: the app sees a window arrive and cannot tell how + far it had fallen, and a reset is the one thing that makes its screen jump + to the newest end. Measured 2026-09-04 against a session streaming at 20 + events a second: reopening one with an anchor 1,800 events back connects + **87-119 events behind**, well under `CATCH_UP_LIMIT`'s 200, because the + restore is two requests -- the opening page, then one span covering the + whole distance to the anchor. So the reset path is not reachable by + reopening a session, and **to exercise it at all you have to lower + `CATCH_UP_LIMIT`** in a throwaway server build; at 5 the app takes the + reset on a live connection, clears, refills and carries on without + reconnecting. Worth knowing alongside it: **the session screen's stream + survives backgrounding here** -- 20 seconds at the launcher while 415 + events were produced brought no reconnect at all -- which is not what the + comment above that loop expects, and is most likely this emulator being + headless rather than the phone's behaviour. - **`ai-server --delay MS` holds every response back.** Over the tunnel a phone's requests take tens to hundreds of milliseconds, and several faults live entirely in what the app does *while* one is outstanding. On diff --git a/EXPLORER.md b/EXPLORER.md index 75b0ca6..d6c6e45 100644 --- a/EXPLORER.md +++ b/EXPLORER.md @@ -7,10 +7,11 @@ existing syntax highlighting, line numbers, no wrapping; edit a file behind a pencil icon; create files through a modal like the ones the app already has; work over ssh; open at the session's working directory. -This is the plan. Like PLAN.md it records each decision with the reason and -what was rejected, so that when one changes it is changed here rather than -re-argued. Once built, the operational notes (how to test it, what bit) -move to AGENTS.md and this file keeps only the design. +Built on 2026-09-03. 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, what to press, +what to produce on purpose -- is in AGENTS.md, where the rest of this +project's working notes are. ## What it is, in one paragraph @@ -163,6 +164,49 @@ draw a screenful. Lines are drawn with `softWrap = false` inside one shared `horizontalScroll` state, so the whole file scrolls sideways as a block and a line never wraps. +**Sharing that state is not enough on its own, and this is where it was +wrong.** `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. Both go +away once **every row is given the same width**: 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 and the +rest of the file sat still beside it. That is the same complaint one layer +further out, and it is only fixable now that every row agrees where the +end is. It cannot be seen from this VM: the emulator's screenshots come +back with no stretch in them at all, for any scrollable, so this 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 from 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 of them with its number at exactly its own top. + +A consequence worth having: the numbers are no longer inside 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. + Line numbers are a gutter in each row, right-aligned, with the gutter width taken from the digit count of the line count in the same monospace style -- so a 9-line file and a 12,000-line file each get exactly the @@ -258,12 +302,18 @@ rerun and its output committed (it needs network): codepoint dev-updater uses, and it must not drift from it, as the cog and the refresh arrow already must not. - `md-plus` U+F0415 -- create. Also dev-updater's. -- `md-pencil` -- edit. -- `md-content_save` -- save. -- `md-file_outline` -- file rows. +- `md-pencil` U+F03EB -- edit. +- `md-content_save` U+F0193 -- save. +- `md-file_outline` U+F0224 -- file rows. -The last three are verified against the Nerd Fonts cheat sheet when they -are added, not copied from memory. +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. + +**Where 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 in this app keeps it. Asked for in that order by +Iris on 2026-09-03. ### 13. The render report moves, and the benches move with it @@ -386,57 +436,51 @@ pure functions with tests. `~/repos/emulator-tools`, `ui-trace`'s tap-by-label action; then the two bench scripts onto it, with no coordinate tap left in `app/*.sh`. -## Testing +## What the measurements said (2026-09-04) -- **Server**: `./run-tests.sh`, `cargo clippy --all-targets`, `cargo fmt`. -- **Local transport, by hand**: `./ui-sandbox.sh api - "/setups//dir?path=~"` against the sandbox, whose `$HOME` is a - throwaway tree it is fine to write into. The sandbox gets a small - fixture directory with the states worth seeing: an empty directory, a - file with a tab in its name, a binary file, one over `FILE_LIMIT`, an - unreadable one (`chmod 000`), a symlink to a directory, and a source - file in each of a few languages. -- **Remote transport**: the ssh-to-this-VM recipe in AGENTS.md ("How to - test SSH here"). The point of the exercise is the quoting and the - stdin path: write a file whose name has a `'` in it, and read it back. -- **Phone**: `ui-trace`, not screenshots, for the things this feature is - made of -- that the gutter's number and its line share a baseline at - the first and the last row, that a long line's row is wider than the - viewport and does not grow the row height, that the editor's gutter - stays put while the text scrolls sideways. Screenshots for colour and - contrast on `rawSurface`. -- **States to produce on purpose**, since the default state is the one - everybody looks at: a directory that fails to list (permission), - an unreachable machine (a setup pointing at a dead address), `binary`, - `tooBig`, the 409 conflict (edit the file with `sed -i` on the machine - between opening and saving), creating a name that exists, back with - unsaved edits, and the keyboard up over the editor. +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. -## Numbers to measure, before deciding +| 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 | -- | -- | -- Scan time for a 1 MiB source file on the emulator, and on the phone - through the render report. That decides whether `FILE_LIMIT` is right - and whether edit mode highlights every keystroke or only below a size. -- Time to first line for a 1 MiB file over the tunnel: the read, the - transfer, the scan, the first composition. If the transfer dominates, - the route gains nothing from streaming; if the scan does, it moves to - a worker with the plain text drawn first. -- The `BasicTextField` at 20,000 lines: whether typing stays responsive. - If not, edit mode gets a lower cap than the viewer, stated in the - editor rather than discovered by a stuck keyboard. +Three things followed. -## Order of work +**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. It now runs on `Dispatchers.Default` with a spinner where the file +will be. -Each step leaves the app working and is one commit. +**`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. -1. Server: `files.rs` with `list` and `read`, routes, tests. Half a day. -2. App: icons, `Api.kt`, `FilesScreen` listing, `FileViewer`, the root - and session wiring, the render-report move with the benches. A day. -3. Server: `write`, `create_file`, `create_dir`, the stdin helper and - `ship_attachment` onto it. Half a day. -4. App: `FileEditor`, the create dialog, the conflict dialog. Half a day. -5. Measurements above, the sandbox fixture, PLAN.md and AGENTS.md. Half a - day. +**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 @@ -451,3 +495,10 @@ Each step leaves the app working and is one commit. - Uploading from the phone into a directory. Attachments already do the upload half; this would be the same route with a chosen destination. - Search within a file, and find-in-files. +- **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. diff --git a/TRANSCRIPT_CACHE.md b/TRANSCRIPT_CACHE.md index 5ab6cb9..34fcda4 100644 --- a/TRANSCRIPT_CACHE.md +++ b/TRANSCRIPT_CACHE.md @@ -500,10 +500,12 @@ what a reader waits on. today's behaviour and not made worse, but the server-side check it rejects would close it. Decide after measuring how often the probe's round trip is what the reader waits on. -- **TRANSCRIPT_RENDERING.md's item 1** (a reset arriving during an anchor - restore left the screen reconnecting). The cache makes the restore - cheaper and therefore shorter, which narrows the window without closing - it; the fix there is still owed and is unrelated to this. +- **A reset arriving during an anchor restore** was an open worry when this + was written, and was measured and closed on 2026-09-04 (see "The reconnect + loop does not reproduce") before this landed. The cache makes the restore + cheaper again -- a warm one is now the probe and nothing else -- so it can + only have narrowed the window further. Worth re-measuring here only if a + reader reports the screen reconnecting on reopen. - **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 diff --git a/TRANSCRIPT_RENDERING.md b/TRANSCRIPT_RENDERING.md deleted file mode 100644 index 72ac5a9..0000000 --- a/TRANSCRIPT_RENDERING.md +++ /dev/null @@ -1,285 +0,0 @@ -# Transcript rendering: what was learned, and what is next - -Written 2026-09-03 at the end of a week of work on the session screen's -transcript, so the next session can start from here rather than from a -compacted context. Work that is finished lives in "the architecture, as -built"; the running log of how each piece got there has been dropped. `AGENTS.md` holds the one-paragraph conventions; this is -the longer record: the measurements that drove each decision, the -techniques that worked, the ones that did not, and the order to do the rest -in. `PLAN.md` remains the design source of truth; nothing here contradicts -it. - -## The goal, and where it stands - -A reply of any length must scroll at the phone's 120Hz without a bump, and -must keep doing so while the reply is still streaming in. Measured on the -Pixel 9 Pro XL by Bryan, the transcript went from visible stalls at long -replies and at lists of links to "I have to actually try to feel any -bumps". The remaining work is finish and extensibility rather than -performance. - -## The architecture, as built - -Everything below lives under `app/androidApp/src/main/kotlin/com/example/aiapp/`. - -**Rows become units, and units are bounded.** `TranscriptUnits.kt` turns a -transcript row into the things the lazy list actually holds. An assistant -reply is not one unit: it is one unit per piece of its markdown, so the -list composes and draws a paragraph, a fence, a table or one bullet at a -time. The reason is the draw phase: a row's display list holds every glyph -of it and is re-recorded whenever drawing is invalidated, and the lazy list -composes an item whole in the frame it scrolls into. The tallest single -row still being drawn before this was 36,982px, twenty-five screens in one -message. Long user messages are sliced the same way (`UserChunk`), through -the shared `cardPiece` modifier that draws one card in lazy-list pieces. - -**One parse per message, addressed by piece.** `MarkdownPieces.kt`'s -`Piece(block, item)` is an address into the message's single parse tree, -not a substring: `block` indexes the root's children and `item` one -`LIST_ITEM` of a top-level list. Cutting was originally done by -re-parsing substrings, which cost a parse per piece and broke reference -links defined at the foot of a message. `ParsedReplies` caches the parse -and the piece list per text (`of`, `piecesOf`), warmed off the composing -thread by `TranscriptItems.warm`. The parser is still intellij-markdown via -the mikepenz renderer, but its `Markdown()` composable is not called at all: -`MarkdownRoot` in `Markdown.kt` provides the `Local*` environment itself -- -reference links from the parse, padding, dimens, colours, typography, a -no-op image transformer, animations, components -- and `MarkdownElement` -dispatches a whole block through our component table. Nothing between a -piece and the screen is the library's now except the leaf composables that -table names. - -**Lists are drawn an item at a time, by us.** The renderer has no element -for a single list item, so `MarkdownListItem` draws one: marker, then the -item's children, nested lists recursing through `MarkdownList`. The -marker is drawn in one place on purpose; styled bullets per depth go -there. - -**Links are spans, not nodes.** `MarkdownLinks.kt`. Compose turns every -`LinkAnnotation` into a layout node (clipped, focusable, hoverable, -clickable, outline recomputed from the text layout). A paragraph of eight -links was nine nodes, and measured against the same paragraphs with each -link replaced by plain words it cost 26.3ms worst measure against 5.2ms, -1.7x the place time. That was the bump at a reply's list of sources. -`LinkedText` builds the annotated string with the renderer's own inline -builder but answers links itself: colour, underline, a string annotation -carrying the URL, and one tap detector for the whole text that asks the -layout which glyph is under the finger. Hit-testing must check the glyph -on either side of the returned caret, because `getOffsetForPosition` -returns the nearest boundary; taps on the right half of a glyph otherwise -open nothing. Headings need the `ATX_CONTENT`/`SETEXT_CONTENT` child, since -the inline builder draws nothing for a node type it does not know (a week -of blank headings). Tables go through `LinkedTable`/`LinkedTableRow` so -cells get the same treatment. - -**An inline code chip is drawn behind the text, not as a span -background.** A `SpanStyle` background is part of the text's own drawing -and the text node draws the selection *under* the glyphs, so an opaque -chip hid the selection: selecting a sentence highlighted every word of it -except the ones in backticks, and there is no way to reorder that -- the -order is the node's. `appendCodeChip` therefore takes the code span from -the renderer's builder, keeps its style and its space of padding either -side but drops the background, and marks the range; `LinkedText` draws -those ranges in a `drawBehind`, which is under both the selection and the -glyphs -- the same place a fenced block's box already was, which is why -one of those always looked right. Geometry is one box per line, from the -bounding boxes of the run's first and last characters, taken as far as the -line's `visibleEnd`: `getPathForRange` is a *selection* shape and runs to -the right edge of every line but the last, which left a full-width empty -chip behind whenever the code wrapped, and `visibleEnd` is what makes the -chip and the selection rectangle stop in the same place. Measured against -the same build without it, streaming 60 paragraphs of three chips each: -measure 755ms against 776ms, record 327ms against 321ms, transcript draw -0.22ms in both -- noise. - -**Text draws on the platform directly.** A paragraph without an image -skips the renderer's `MarkdownText`, which charges every paragraph for the -possibility of inline images (placement callback, derived inline-content -map, semantics group, size animation). Paragraphs that contain an image -still take the renderer's path. - -**Tables spread or scroll without subcomposition.** The renderer used -`BoxWithConstraints` to decide; `LinkedTable` uses -`fillMaxWidth().horizontalScroll().layout { }` -- `horizontalScroll` -passes `minWidth` through and lifts `maxWidth` to infinity, so the inner -layout reads `minWidth` as the room available and takes -`max(minWidth, columns * cellWidth)`. - -**A streaming reply is reparsed one block at a time.** `LiveParse` in -`Markdown.kt` freezes every finished top-level block with its parse and -reparses only the tail block per delta. Markdown's block rules make later -text unable to alter an earlier block, with the single exception of a -late reference definition, which is accepted. Measured on a 58-word stream -of list, fence, table and quote: 47 tail reparses at 1.7ms mean. A -single-list stream would reparse the whole list per delta, since it is one -tail block; that is what the rule below cuts. - -**A streaming list becomes a unit per item.** `LiveParse.advanceTo` cuts at -the last item of a multi-item list (`openPiece`), provided that item has -content beyond its marker -- a bare `-` is an empty item now and the first -character of a paragraph line once `-x` arrives, so cutting on it would draw -that line as a new item. The cut is at the start of the item's line, so the -indentation the reparse reads its nesting from survives. `Segment.continues` -marks a tail that carries on a list, and `MarkdownPiece`'s -`continuesList`/`listContinues` keep an inner item's padding at the seam, so -nothing moves when the seam does. Forty linked bullets streamed a word at a -time went from 2412ms of reparsing to 674ms, and `record: one block` from -1.8ms worst to 0.7ms. - -**Fences are highlighted off the drawing thread, and a fence still being -written is drawn plain.** `Highlighter.kt` holds `highlight` and the scanner -behind it (shared with a tool call's input, so the same code is the same -colours wherever it appears); `CodeFence.kt` holds the `fenceLanguage` alias -table and `fenceContent`. A word not in the table stays plain, because a -fence coloured by the wrong language's rules looks highlighted and is wrong -in a way the reader cannot see. Highlighting is warmed and cached exactly as -parsing is (`ParsedReplies.highlighted`, filled by `warm` from -`fences(parse)`), and `highlight` takes no colour from the theme, which is -what lets it run off the drawing thread: a two-hundred-line Kotlin fence -costs 15ms to scan on the emulator's debug build -- it cost 102ms through -the library that used to do this -- and a `remember` inside the fence was -charged that again every time the block scrolled back into composition. Because the warming has to ask for the same -string the drawing does, `fenceContent` extracts the code and the language -word itself -- two extractions would be two keys, and the warmed answer -would be missed at every fence with nothing saying so. A fence still -arriving is the same stall in a second place, and warming cannot reach it: -the tail was re-lexed at every delta, on the composing thread, for colours -on text being replaced as fast as they were computed -- 211 lexes and 13.7 -seconds across one turn. So `MarkdownRoot`'s `streaming`, true only for a -live reply's last segment, draws the block plain until it freezes; a -finished fence colours as soon as the next block starts, and the settling -lex happens once, in `warm`. - -**Markers, and images.** `MarkdownListItem`'s `Marker` draws the bullet by -depth, cycling past the third, in `listMarkerColor` (Theme.kt). The colour -is the same at every depth on purpose: depth is said by the glyph and the -indent, and a colour per depth would make a difference in degree look like -one in kind. The app has no image loader and the renderer's transformer was -the no-op one, so an image in a reply drew as *nothing at all*; an `IMAGE` -node is now appended by `appendPlainLink` as a link carrying its alt text -(the address when there is none), which says what was there and opens it. - -**Expansion anchors the edge that was tapped, and the list never moves -under the reader** except when pinned to the bottom with new content -arriving. Those two rules are in `ScrollAnchor.kt` and `TranscriptList.kt` -and are the reason several tempting simplifications were rejected. - -## Techniques and harness - -- **`app/ui-sandbox.sh`** starts a second `ai-server` against a sandbox - home with the echo driver, so nothing touches real sessions. - `spawn [title]` makes an echo session and prints its id; `send SID text` - or `send SID @file` sends into it; `api /path [curl args]` is an - authenticated request. Restarting it regenerates the config but keeps - enrolled tokens. -- **The echo driver is the test rig** (`server/src/session/echo.rs`, the - list at the top of the file). `/stream N`, `/mixed N`, `/table N`, - `/tools N gap`, `/ask`, `/peer`, `/compact`, `/slow`, `/bash command` - each produce a shape the real CLI produces only when it feels like it. - Build what a UI test needs into it rather than spending model turns. -- **`app/transcript-bench.sh`** is the standard measurement: restart, open - the first session, scroll, print the render report. The report is what - the "Copy render timings" button copies and also logs - (`adb logcat -d -s ai-app:I`), and it includes the last crash's stack - (`CrashLog.kt`), which is how a crash on the phone reaches a session - here. -- **`app/stream-bench.sh [-k] FILE`** is `transcript-bench.sh` for a reply - still arriving: opens the first session, taps "Jump to latest" so the list - is pinned to the newest end, resets the report, sends FILE, waits for the - transcript to stop growing, prints the report. Both of those last two are - corrections to a first version that measured nothing -- a transcript parked - further back never redraws while a reply streams into it, and a session is - idle at *both* ends of a turn, so polling for idle answers before the turn - has started. Fixtures live in `/tmp` and are regenerated from the shapes - named here: `fixture.md` (lists four deep, ordered and nested, fences in - kotlin/rust/sh/none, a table with a link, a quote with a list, an inline - and a standalone image, a reference link), `longfence.md` (200-line Kotlin - fence), `longlist.md` (40 linked items). -- **Two traps in the emulator loop**, each of which cost a bench run. - `adb shell pm clear` removes the enrolment and the notification permission - along with the saved anchors, so the next run measures a permission - dialog; re-enrol with the command `ui-sandbox.sh` prints and - `pm grant ... POST_NOTIFICATIONS`. And a saved anchor is per session id, - so the only way two builds start a scroll from the same place is a *fresh - session for each*. -- **`DebugStats`/`FrameStats`** time our own phases (`record: one block`, - `measure: the app root`) and count events (`markdown reparsed while - streaming`, `markdown cut into pieces`). Add a counter before guessing. -- **`app/trace-draw.sh`** names what a scrolling frame spends inside the - framework, via `atrace` text output, no trace processor needed. It is - how the link-node cost was attributed. -- **`app/debug-transcript.sh`** loads a real Claude Code conversation onto - the emulator; two faults were invisible on fixtures and obvious on it. - Real transcripts are private: fixtures stay in `/tmp`, never in the repo. -- **`ui-trace`** reads the screen as text. Bounds print as - `x1,y1..x2,y2`; unanchored `-m` patterns match labels, anchored ones do - not. A row taller than the viewport reports clipped bounds, so compare - screenshots for that case. -- **Emulator frame times are not app measurements.** Software rendering - puts the stock Settings app at 60ms of UI-thread traversal per frame. - Costs of operations in milliseconds rank correctly; smoothness itself is - judged on the phone. -- **System Tracing on the phone does not work on GrapheneOS.** Its - Categories list is empty because the tracing daemon builds it by running - `atrace --list_categories`, which returns nothing there, and a recorded - trace contains zero ftrace events: no app sections, no frames, no - scheduling. Callstack sampling records, but the app's profiler config - unwinds one process shard in four. GrapheneOS issues 2206 and 6094 are - open on exactly this. Until they close, phone numbers come from the - render report and from Bryan noticing. -- **Compose `DropdownMenu` in an edge-to-edge activity** needs - `PopupProperties(clippingEnabled = false)` or it opens a status bar's - height away from its anchor (`~/.claude/TOOLCHAIN.md`). -- **The syntax highlighter is ours: `Highlighter.kt` and `Languages.kt`.** - One left-to-right scanner with a small state -- in a line comment, in a - block comment, in a string, or in ordinary code -- and a `Rules` row per - language, so a new language is a table entry rather than code. Every span - is emitted by advancing an index, so spans cannot overlap, arrive out of - order or run backwards, and an unterminated string or comment simply runs - to the end of the code. `HighlighterTest.kt` is the JVM unit test - (`./gradlew :androidApp:testDebugUnitTest`); the cases in it are the - library's mistakes, kept as regressions. - It replaced dev.snipme:highlights 1.1.0 on 2026-09-03, which found - comments before it knew the language and paired `/*` with `*/` by - ordinal. That library used one set of delimiters for every language, so - `//` in any URL commented out the rest of its line (in `curl - https://example.com/x && echo done` the comment ran to the end and took - `echo` with it, and in Kotlin `val url = "https://..."` the string - disappeared inside it), every Rust `#[derive(...)]` greyed out as a - comment, a `#` inside a Kotlin string swallowed the line, and `x '*/a/*'` - in shell yielded `start=6, end=5` -- a range `AnnotatedString` rejects, - which crashed a card holding `-path '*/.git/*'`. Comments were located - before strings and won over them, so post-processing could not recover - what a wrong comment range had already suppressed. The scanner is also - about seven times faster on the same fixture, and it colours RON, TOML, - fish and JSON, which the library did not know at all. - -## Rejected, and why - -- **Writing our own markdown renderer.** Rejected in favour of keeping - the intellij-markdown parser and the library's inline builder while - owning block dispatch and the leaf composables. The parser is the hard - part and is not the slow part; everything that was slow lived in the - composables, which are now ours. -- **Re-parsing substrings per piece.** Cost a parse per piece and broke - foot-of-message reference links. Replaced by addressed pieces of one - parse. -- **Animated or timing-dependent corrections.** Anything the reader could - catch at 120Hz is a bug; corrections must be structurally impossible to - see. - -## What is next, in order - -1. **The reconnect loop.** Restarting the app onto a session with a saved - anchor while a long reply was streaming left it reconnecting every 1.5s - (`RECONNECT_DELAY_MS`), spinner up, until the server was restarted. - `events?after=N` more than `CATCH_UP_LIMIT` (200) behind answers `reset` - plus the newest 200 *raw* deltas -- a window starting mid-message -- and - the reset clears `items`, which is the state the restore loop then pages - against. The restore's one-event-per-request bug was part of what made it - so visible and has been fixed; whether this survives that fix is the - first thing to find out. -2. **Regression runs.** `transcript-bench.sh` and `stream-bench.sh` before - and after any change to the files above, with the report in the commit. - The numbers to watch are the worst `record: one block`, the reparse mean - while streaming, and the draw phase's accounting line. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index c3e39b6..a5024b0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -17,7 +17,18 @@ import org.json.JSONObject 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 -- those messages are written to be read on the screen that made the call. + * + * [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", where every + * other refusal is a message to show. 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 @@ -68,7 +79,8 @@ fun requestFromServer( detail.isNullOrEmpty() -> "Server returned HTTP ${connection.responseCode} for $path" else -> detail - } + }, + status = connection.responseCode, ) } return readBody(connection) @@ -82,13 +94,13 @@ fun requestFromServer( "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() @@ -589,6 +601,175 @@ fun uploadAttachment( } } +/** + * 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, because it is the + * only thing that can. + */ +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) + +/** + * What reading a file produced. + * + * Four cases, because they are four different things to draw and none of them is an error the + * screen can shrug off: content, something that is not text, something too big to have sent, and + * (as [ApiException], not a case here) the machine's own refusal. A file with nothing in it is + * [FileContent.Text] with an empty string -- which is what it is, and not the same as any of these. + */ +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 -- a timeout is for a + // server that has stopped answering. + 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 this app has + // 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 and arrives as an [ApiException] 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, not the exotic one. + */ +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, + ) {} +} + /** Fetches an image the transcript references (produced or uploaded). */ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray = requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index 28a6d77..ae3a9c4 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -38,7 +38,17 @@ import kotlinx.coroutines.withContext 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 -- which is exactly the + * flip between "what did it change" and "what is it saying" that this feature exists for. The + * image viewer already made the same choice for the same reason. + */ + data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen() data object Spawn : Screen() @@ -198,14 +208,28 @@ fun AppRoot( // row key. Only reachable since a notification can move straight from one session to // another; every other way here passes through [Screen.Main], which disposes it anyway. key(here.summary.id) { - // No imePadding here, for the reason above. - 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; the session stays composed underneath. No imePadding here, for the reason + // above -- the explorer adds its own, since it has a text field. + 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()) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt index 6c659c7..f37f3f5 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt @@ -64,7 +64,7 @@ suspend fun uploadPicked( } 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) + throw ApiException("couldn't send $name: ${e.message}", cause = e) } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt index 0e9cfe9..5bcf9ad 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt @@ -137,6 +137,25 @@ private fun CodeBlockText( fun fenceLanguage(name: String?): Language? = FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null] +/** + * The highlighter's language for a *file*, from its name. + * + * The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people + * write after the backticks -- `kt`, `rs`, `py` -- because the extension is as often what gets + * written there as the language's name. 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 and + * `Cargo.toml` TOML. A leading dot is not one: `.bashrc` has no extension, it has a name that + * starts with a dot, and reading `bashrc` as an extension would look up a word no table has. A name + * with no dot at all -- `Makefile`, `LICENSE` -- 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 = mapOf( "kotlin" to Language.KOTLIN, @@ -182,6 +201,8 @@ private val FENCE_LANGUAGES: Map = "toml" to Language.TOML, "fish" to Language.FISH, "json" to Language.JSON, + "markdown" to Language.MARKDOWN, + "md" to Language.MARKDOWN, ) /** diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt new file mode 100644 index 0000000..ddbc11f --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt @@ -0,0 +1,167 @@ +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 on 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 noticeable and survivable, while laying the same text out in one + * `BasicTextField` costs two seconds. So switching highlighting off above a size -- which is what + * EXPLORER.md expected to have to decide -- would not have saved it; the cost is Compose laying out + * one enormous text, and every arrangement of a single text field pays it. A line-by-line editor is + * the way past this and is a good deal more than this feature needed. + * + * 32 kB rather than something between it and 128 kB, because 32 kB is the largest size that was + * 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 it is only editing one that 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 -- no character moves, so no offset does. The + * newer `TextFieldState` API has no hook for styles at all, which is why this is the older one. + * + * The cost is that the whole file is re-scanned on every keystroke. For a file under the server's + * limit that is expected to be a few milliseconds; see EXPLORER.md's "Numbers to measure", which is + * where a size below which highlighting is switched off would be decided if it turns out to be + * needed. + * + * 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 stays put while the text scrolls + * sideways, and 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") } }, + ) +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt new file mode 100644 index 0000000..f5fa96c --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt @@ -0,0 +1,131 @@ +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 and a twenty-thousand-line file in one `Text` measures all of it to + * draw a screenful. 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 over an already-ordered + * list, rather than each row searching the whole list for the part that is its. + */ +class FileLines +private constructor( + /** The text of each line, without its newline. */ + val lines: List, + /** Per line, the spans that fall in it, with offsets relative to that line's start. */ + private val spans: List>, + /** + * 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, and so does every editor. Without that, every well-formed file gained a phantom + * empty last line, which is a wrong line number on every file in the repository. An empty + * file is one empty line numbered 1, which is what it is: a file with nothing in it still + * has somewhere for a cursor to go. + */ + fun of(text: String, language: Language?): FileLines = + // Timed, and always, for the same 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. It + // lands in the render report beside the transcript's own figures. + 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 as 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. Compose draws a tab + * as a single advance, so eight is the generous reading rather than the accurate one. + */ + 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, so a span can only belong to the line the + * walk has reached or to ones after it. A span crossing a line break -- a block comment, a + * multi-line string -- 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, spans: List): List> { + val out = ArrayList>(lines.size) + var lineStart = 0 + var next = 0 + for (line in lines) { + val lineEnd = lineStart + line.length + var here: ArrayList? = 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().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 + } + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt new file mode 100644 index 0000000..58d2501 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt @@ -0,0 +1,260 @@ +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 on 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 + * from outside. So it runs on [Dispatchers.Default] and the spinner is what the reader sees + * meanwhile, in the place the file will appear. + * + * Keyed on the text and the language, so re-reading the same file does not rescan it and a file + * that changed does. + */ +@Composable +fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) { + var lines by remember(content, language) { mutableStateOf(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, and the + * scroll never recovers. The cost of the choice 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, the one thing a numbered listing must never do. Because nothing wraps, a + * logical line is one visual line and the two cannot drift. + * + * **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 at all while a long one beside it does. Each row + * also writes `maxValue` on the shared state as it measures, so how far the file could be dragged + * was decided by whichever row happened to measure last and changed as the list scrolled. Both + * disappear once every row is [FileLines.columns] wide: one range, one maximum, and the file moves + * as the block this comment always claimed it was. Reported by Iris on 2026-09-04 as "it seems to + * affect different rows differently", which is exactly 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 and the rest of the file sat still beside it -- the same + * complaint as the offsets above, one layer further out. Handing every row the same effect and + * rendering it once is what makes the file bend as the block it scrolls as. Only possible because + * every row now has the same range: rows that disagreed about where the end was would disagree + * about when to stretch. + * + * The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the + * numbers out of both effects: they do not travel with the text and they do not bend with it. The + * rows leave a spacer where the numbers will go and [LineGutter] draws them there. Its width is + * measured from the digit count of the line count in the very style it is drawn in, so a nine-line + * file and a twelve-thousand-line file each get exactly what they need and nothing is nudged by + * hand. + * + * 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, which is the one thing a numbered listing + * may never get wrong. Those two pull in opposite directions -- out of the list, but pinned to it. + * + * 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 is not composing from a value it read a frame ago, it is composing from the + * answer the list has just produced. A `Column` translated by the scroll position could not do + * that: the translation would be a layout read and current while the set of numbers would be a + * composition behind it, 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 -- + * and a scroll that remeasures the list on its own does so synchronously, ahead of the layout pass, + * which is the same reason a lazy list does not lag its own content. + * + * `onSurfaceVariant`, because a number is not part of the file: it is this app numbering it, and + * the text's own colour would put it in the same voice as the code. 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 and the count's own digits + * would measure the same -- 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 -- every + * advance is the same -- 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 asking to lay 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 of the line are not touching. + */ +val GUTTER_GAP = 8.dp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt new file mode 100644 index 0000000..e2ebac4 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt @@ -0,0 +1,692 @@ +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, say -- 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, its draft and scroll position stay where they were, 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 it -- and only closes from where it opened. + * + * Every directory that has been visited is kept for as long as this is open, so stepping back is + * instant; the refresh glyph is how a directory gets asked again on purpose, and creating something + * refetches the directory it was created in, since that is the one thing that changed. + */ +@Composable +fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) { + val scope = rememberCoroutineScope() + var stack by remember { mutableStateOf(listOf(Spot.Dir(target.start))) } + val listings = remember { mutableStateMapOf>() } + 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 (see SessionScreen's + // layout note), 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, 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 entirely. `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, + * and the word is there because a colour cannot say "this is a different kind of thing". + */ +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, and back + * belongs to the screen. + */ +@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.Loading) } + var draft by remember(path) { mutableStateOf(TextFieldValue()) } + var saving by remember(path) { mutableStateOf(false) } + var saveError by remember(path) { mutableStateOf(null) } + var conflict by remember(path) { mutableStateOf(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 it 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 that a + // disabled button never receives. + 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(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): List = + 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('/') +} + +/** A resolved directory and a name in it, as one path. */ +internal fun join(directory: String, name: String): String = + if (directory.endsWith("/")) "$directory$name" else "$directory/$name" diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt index e5122bb..9dc2597 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt @@ -60,7 +60,7 @@ data class SyntaxPalette( */ fun highlight(code: String, language: Language?): AnnotatedString { if (language == null) return AnnotatedString(code) - val spans = DebugStats.timed("code highlighted") { scan(code, rulesOf(language)) } + val spans = DebugStats.timed("code highlighted") { spansOf(code, language) } val palette = catppuccinSyntax() return buildAnnotatedString { append(code) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt index c03c715..2c8d109 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt @@ -1,11 +1,14 @@ package com.example.aiapp /** - * A language the highlighter has rules for. + * 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, @@ -19,6 +22,7 @@ enum class Language { JAVASCRIPT, JSON, KOTLIN, + MARKDOWN, PERL, PHP, PYTHON, @@ -83,8 +87,23 @@ enum class Attributes { LINE_BRACKET, } -/** The rules for [language]. */ -fun rulesOf(language: Language): Rules = RULES.getValue(language) +/** + * 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: keywords, strings and comments, which is a row of [RULES] + * and the one shared scanner in [scan]. 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 ([scanMarkdown]). + * 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 = SCANNERS.getValue(language)(code) + +// Lazy for the same reason [RULES] is, since it reads it. +private val SCANNERS: Map List> 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) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt new file mode 100644 index 0000000..470438a --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt @@ -0,0 +1,463 @@ +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, and answering them with its rules is how + * a highlighter comes to grey out the second half of a paragraph. + * + * Structure is read a line at a time and each line's prose is then read left to right, so every + * decision is made inside one line -- except the two things that are not one line. A fenced block + * is state carried forward, so an unclosed fence colours the rest of the text, which is also what + * it looks like while somebody is still writing it. A table is found by its delimiter row + * (`|---|---|`), which is 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, and four spaces after a bullet is a list item's second paragraph, and the two are told apart + * by what came before rather than by the line itself. Colouring the wrong one of those as code is a + * mistake the reader cannot see, so both are left plain, which is the safe answer. + * + * Like [scan], the spans come out ordered, non-overlapping and inside the text by construction: + * every one is emitted by a pass that only moves forward, and nothing here throws. + */ +fun scanMarkdown(code: String): List = 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 in it, 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() + + fun run(): List { + 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 (`|---|---|`), which is 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 the + * alternative of 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 -- the same reading as a Rust attribute above a struct. + 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 -- a + * rule drawn across the page -- so they get one appearance rather than a lookback to tell them + * apart. One `=` is enough because a setext underline may be a single character; a break needs + * three, which is what 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 (``` ``a ` b`` ```), and it is 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: the place the link goes rather than anything + * said to the reader. A `[text]` with no destination after it is left plain, because that is + * what a reference link and a bracketed aside look like, and neither is worth guessing at. + */ + 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 + } + + /** + * `` and ``, 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: `
` has neither, and + * `` has the colon but also a space. + */ + 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, and + * the pair of colons is what makes the match unambiguous enough to draw without a closer. + * + * 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 `)` to the address. 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. + * + * Markers and all because that is how the token scanner draws a string: the quotes are part of + * the thing. The two guards are what 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 additionally may not start or end inside a word, or every `snake_case_name` in a + * document 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 == '_' diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt index 9482384..4ba88c0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt @@ -29,10 +29,10 @@ import androidx.compose.ui.unit.sp * 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. + * `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols + * font and committed -- so the codepoints below are resolved by an asset in the APK and cannot come + * back as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the + * script did not subset is a glyph that silently isn't there. * * The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall. * That is what makes two icons the same size without either of them being given a size: the @@ -117,6 +117,33 @@ 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: a folder that meant something else in one of the two apps is exactly + * the confusion sharing them prevents. 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. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 9722eb8..40086f5 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -235,6 +235,8 @@ fun SessionScreen( settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit, + /** Opens the file explorer on this session's machine, starting where this session works. */ + onFiles: (FilesTarget) -> Unit, /** What another app shared in while this session is the one open; see [ShareRequest]. */ share: ShareRequest? = null, /** Said once [share] has been attached here, so it is not attached again. */ @@ -1347,6 +1349,60 @@ fun SessionScreen( // interpolated, so there is nothing for a dropped frame to interrupt), so it is what both // places below fall back to. val imeVisible = WindowInsets.isImeVisible + + // What this session is costing to draw, copied out to somewhere it can be read. + // + // Written here rather than beside the control that runs it, because everything it measures -- + // the events, the rows, the units, what the list has on screen, which cards are open -- is this + // composable's own state, and a control in a dialog cannot reach it. The control is a row in + // [SessionSettingsDialog]: that is where the session's other about-the-session controls are, + // and the header is for what a reader presses while reading. It copies rather than opens, + // because what it produces is for somewhere else -- a message to whoever is looking at the + // code -- and a screenful of timings read on the phone is a screenful nobody can act on. + // + // Whatever presses this, it is found by its **name**: `ui-trace`'s tap-by-label action resolves + // "Session settings" and then "Copy render timings" from what is on screen at that moment, so + // `transcript-bench.sh` and `stream-bench.sh` keep working when this moves again. They pressed + // it at a coordinate measured once by hand until 2026-09-03, and anything that moved the header + // made that tap land on whatever now sat there -- reporting a number that was never measured. + val copyRenderReport = { + val report = + debugReport( + device = + "device: ${Build.MODEL} (${Build.MANUFACTURER})," + + " Android ${Build.VERSION.RELEASE}\n" + + // A debuggable build runs Compose at a fraction of release speed, so a + // report that did not say which it came from was read as the app's own + // cost. + "build: ${if (debuggable(context)) "debug" else "release"}", + transcript = + listOf( + " ${items.size} events, ${rows.size} rows, ${units.size} units loaded", + " viewport ${listState.layoutInfo.viewportSize.height}px," + + " ${listState.layoutInfo.visibleItemsInfo.size} units visible", + visibleUnits(units, listState.layoutInfo.visibleItemsInfo, UNITS_START), + " ${expandedTools.size} tool calls and ${expandedGroups.size} groups open", + ), + frames = FrameStats.lines(context.refreshHz()), + accounting = + FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) }, + crash = lastCrash(context), + ) + context.copyToClipboard("ai-app render report", report) + // Also to the log, so a session driving the app over adb can read the same report the + // button copies. The clipboard is not reachable from a shell, and a counter nobody can + // check from here is a counter that only gets checked by asking Iris to press a button + // and paste. + Log.i("ai-app", report) + // Only once it is somewhere it can be read from, so a copy that never happened does not + // throw the stack away with it. + clearCrash(context) + // Emptied by the copy, so pressing it twice measures two separate stretches of scrolling + // rather than one and then the same one again. + FrameStats.reset() + DebugStats.reset() + Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show() + } Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { Row( @@ -1388,74 +1444,36 @@ fun SessionScreen( // yellow or red near a limit -- and the theme's plain control colour whenever there // is no measurement, since blue is the low end of the scale here and would read as // "checked, and fine" about a machine nobody could reach. + // Usage, files, settings -- widest scope first, narrowing to the right, so the + // cog stays at the end where every other screen keeps it. Asked for in this order + // by Iris on 2026-09-03. Row { - // Left of the numbers about the *conversation*, because it is the same kind of - // thing about the *app*: what this session is costing to draw. It copies rather - // than opens, because what it produces is for somewhere else -- a message to - // whoever is looking at the code -- and a screenful of timings read on the - // phone - // is a screenful nobody can act on. - GlyphButton( - SPEED_GLYPH, - "Copy render timings", - onClick = { - val report = - debugReport( - device = - "device: ${Build.MODEL} (${Build.MANUFACTURER})," + - " Android ${Build.VERSION.RELEASE}\n" + - // A debuggable build runs Compose at a fraction of - // release speed, so a report that did not say which - // it came from was read as the app's own cost. - "build: ${if (debuggable(context)) "debug" else "release"}", - transcript = - listOf( - " ${items.size} events, ${rows.size} rows," + - " ${units.size} units loaded", - " viewport" + - " ${listState.layoutInfo.viewportSize.height}px," + - " ${listState.layoutInfo.visibleItemsInfo.size}" + - " units visible", - visibleUnits( - units, - listState.layoutInfo.visibleItemsInfo, - UNITS_START, - ), - " ${expandedTools.size} tool calls and" + - " ${expandedGroups.size} groups open", - ), - frames = FrameStats.lines(context.refreshHz()), - accounting = - FrameStats.drawPhase().let { (nanos, count) -> - drawAccounting(nanos, count) - }, - crash = lastCrash(context), - ) - context.copyToClipboard("ai-app render report", report) - // Also to the log, so a session driving the app over adb can read the - // same report the button copies. The clipboard is not reachable from a - // shell, and a counter nobody can check from here is a counter that - // only - // gets checked by asking Iris to press a button and paste. - Log.i("ai-app", report) - // Only once it is somewhere it can be read from, so a copy that never - // happened does not throw the stack away with it. - clearCrash(context) - // Emptied by the copy, so pressing it twice measures two separate - // stretches - // of scrolling rather than one and then the same one again. - FrameStats.reset() - DebugStats.reset() - Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT) - .show() - }, - ) GlyphButton( USAGE_GLYPH, "Usage", { usageOpen = true }, colour = usageGlyphColour(usage), ) + // The machine's files, which is where the answer to "what did it actually + // change" is. It opens *over* this screen rather than replacing it -- see + // [Screen.Session]. + GlyphButton( + FOLDER_GLYPH, + "Files", + onClick = { + onFiles( + FilesTarget( + setup = summary.setup, + setupName = summary.setupName, + // Where this session works, and the machine's own home when it + // was never given a directory -- resolved there rather than + // guessed at here, since this app does not know that machine's + // home and must not invent one. + start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~", + ) + ) + }, + ) // What it opens is about this session, so it sits at the end of the session's // own row. The name is the whole of what it holds today, which is why it is a // cog @@ -2092,6 +2110,7 @@ fun SessionScreen( settingsOpen = false }, onDismiss = { settingsOpen = false }, + onCopyRenderReport = copyRenderReport, ) } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt index bccbe09..2ffcdb0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt @@ -64,6 +64,11 @@ fun SessionSettingsDialog( 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 -- see `copyRenderReport` there. + */ + onCopyRenderReport: () -> Unit, ) { val scope = rememberCoroutineScope() var name by remember(sessionId) { mutableStateOf(title) } @@ -304,6 +309,20 @@ 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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt index 1255f1b..2e5af76 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt @@ -1,14 +1,17 @@ package com.example.aiapp /** - * A byte count at the coarsest unit that still says something, so two of them stay comparable. + * A byte count at the coarsest unit that still says something, so rows stay comparable. * - * Null for nothing at all, which is a different answer from a small number and is drawn with words - * rather than a figure: an import row with no size says nothing about size, and a transcript cache - * holding nothing says "nothing cached". + * 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, and is left + * off the row; a file of no bytes is a file with nothing in it, and the explorer says `0 B` rather + * than leaving a gap the reader would have to interpret; a session with no cached transcript says + * "nothing cached", because a figure of none would read as a measurement. * - * Here rather than beside either caller because a second copy of it would drift, and there is - * already one variant too many -- `ModelsScreen`'s `gigabytes` writes a download's size to two + * 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. + * `ModelsScreen`'s `gigabytes` is deliberately not folded in -- it writes a download's size to two * decimal places, which is a different question about a much larger number. */ fun humanSize(bytes: Long): String? = diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt index bbfbd16..23c49c3 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt @@ -93,7 +93,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 { diff --git a/app/androidApp/src/main/res/font/nerd_icons.ttf b/app/androidApp/src/main/res/font/nerd_icons.ttf index 172bdc4..2be19a1 100644 Binary files a/app/androidApp/src/main/res/font/nerd_icons.ttf and b/app/androidApp/src/main/res/font/nerd_icons.ttf differ diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt new file mode 100644 index 0000000..0b054a5 --- /dev/null +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt @@ -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")) + } +} diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt index 4e5a43d..ab4f529 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt @@ -16,9 +16,7 @@ import kotlin.test.assertTrue class HighlighterTest { /** Every span of [kind] in [code], as the text it covers. */ private fun spans(code: String, language: Language, kind: Kind): List = - scan(code, rulesOf(language)) - .filter { it.kind == kind } - .map { code.substring(it.start, it.end) } + spansOf(code, language).filter { it.kind == kind }.map { code.substring(it.start, it.end) } private fun assertSpans( code: String, @@ -182,6 +180,158 @@ class HighlighterTest { 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 = " and and
and " + assertSpans( + code, + Language.MARKDOWN, + Kind.METADATA, + "", + "", + "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")) @@ -189,8 +339,8 @@ class HighlighterTest { } @Test - fun `every alias the fence table knows has rules`() { - Language.entries.forEach { rulesOf(it) } + fun `every language the fence table knows has a scanner`() { + Language.entries.forEach { spansOf("x", it) } } /** @@ -218,11 +368,26 @@ class HighlighterTest { "0x", "1.2.3", "a#b//c/*d*/'e\"f", + "```", + "*", + "**", + "~~", + "> ", + "- ", + "1.", + "[x](", + "#######", + "|", + "|---|", + "<", + "<>", + "http://", + "a://", "\n\n \n", ) for (language in Language.entries) { for (code in nasty) { - val spans = scan(code, rulesOf(language)) + val spans = spansOf(code, language) spans.forEach { assertTrue( it.start in 0..it.end && it.end <= code.length, diff --git a/app/bench-lib.sh b/app/bench-lib.sh new file mode 100644 index 0000000..4b8acdd --- /dev/null +++ b/app/bench-lib.sh @@ -0,0 +1,57 @@ +# Shared by transcript-bench.sh and stream-bench.sh: the parts of driving +# this app that both need and that neither should describe twice. +# +# Sourced, not run. It assumes android-env.sh has already been sourced, so +# `ui-trace` and `adb` are this checkout's. +# +# **Nothing here presses a coordinate.** Every control is found by the name +# it already carries for assistive technology, resolved from the screen at +# the moment of the gesture (`ui-trace record --do "tap 'Save'"`). A +# coordinate is a position measured once by hand, and anything that moves +# the control -- a button added to the row, a font size, another device -- +# makes the tap land on whatever now sits there; the bench then reports a +# number that was never measured, which reads exactly like a result. That +# is not hypothetical: both scripts pressed the render-report button at +# `tap 723 205` until 2026-09-03, when it moved into the session settings +# dialog. The check that none has crept back: +# +# grep -n "tap [0-9]" app/*.sh + +# The title of the session these benches open by default: whichever one the +# server lists first, which is the most recently active. +# +# Asked of the server rather than taken from the screen, because a row has +# no stable name of its own and "the first one" is a fact about the list +# rather than about a position on it. Passing -s overrides it, and that is +# the honest way to measure a particular session: the list is ordered by +# last activity, so "the first row" is a different session at different +# times of day. +first_session_title() { + ./ui-sandbox.sh api /sessions | + python3 -c 'import json,sys; d=json.load(sys.stdin); print(d[0]["title"] if d else "")' +} + +# Restarts the app and opens the session called $1. +open_session() { + adb shell am force-stop com.example.aiapp + adb shell am start -n com.example.aiapp/.MainActivity >/dev/null + sleep 5 + ui-trace record -d 6000 --do "tap '$1'" --do 'wait 3000' \ + -o /tmp/bench-open.txt >/dev/null + sleep 2 +} + +# Copies the app's render report to the clipboard and the log, through the +# session settings dialog it lives in. +# +# Pressed twice per run: once to empty the counters, and once at the end, +# so what comes back covers exactly the work between the two. Closing the +# dialog is part of it -- it covers the transcript, which is the thing the +# gestures in between are meant to reach. +copy_render_report() { + ui-trace record -d 7000 \ + --do "tap 'Session settings'" --do 'wait 1500' \ + --do "tap 'Copy'" --do 'wait 800' \ + --do "tap 'Close'" --do 'wait 800' \ + -o "${1:-/tmp/bench-report.txt}" >/dev/null +} diff --git a/app/build-icon-font.sh b/app/build-icon-font.sh index 8785455..7e9cd55 100755 --- a/app/build-icon-font.sh +++ b/app/build-icon-font.sh @@ -39,6 +39,11 @@ GLYPHS=( U+F004D # md-arrow_left U+F009A # md-bell U+F04C5 # md-speedometer + U+F024B # md-folder -- dev-updater's too; a folder means the same in both + U+F0415 # md-plus -- likewise + U+F03EB # md-pencil + U+F0193 # md-content_save + U+F0224 # md-file_outline U+F201 # fa-line_chart -- Font Awesome's, asked for by name ) diff --git a/app/stream-bench.sh b/app/stream-bench.sh index 22d3a75..5535609 100755 --- a/app/stream-bench.sh +++ b/app/stream-bench.sh @@ -4,8 +4,9 @@ # reply that is still arriving cheap", the way transcript-bench.sh is for # scrolling one that has settled. # -# ./stream-bench.sh FILE restart the app, open the first -# session, stream FILE into it, report +# ./stream-bench.sh FILE restart the app, open the session the +# sandbox lists first, stream FILE into +# it, report # ./stream-bench.sh -k FILE keep whatever session is open now # # The session is the first one the sandbox lists (ui-sandbox.sh spawn makes @@ -17,6 +18,7 @@ set -eu cd "$(dirname "$0")" . ./android-env.sh >/dev/null 2>&1 +. ./bench-lib.sh keep="" while getopts k flag; do @@ -31,11 +33,9 @@ file=${1:?usage: stream-bench.sh [-k] FILE} sid=$(./ui-sandbox.sh api /sessions | python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["id"])') if [ -z "$keep" ]; then - adb shell am force-stop com.example.aiapp - adb shell am start -n com.example.aiapp/.MainActivity >/dev/null - sleep 5 - ui-trace record -d 3000 --do 'tap 500 545' -o /tmp/bench-open.txt >/dev/null 2>&1 - sleep 3 + title=$(first_session_title) + [ -n "$title" ] || { echo "stream-bench: no sessions to open" >&2; exit 1; } + open_session "$title" fi # Pinned to the newest end before anything is sent. The transcript never @@ -46,18 +46,17 @@ fi # reads exactly like a build where the work vanished. The control is the # app's own "Jump to latest", which is only there while the newest message # is off screen -- so when it is absent the list is already where it needs -# to be. -jump=$(ui-trace record -d 1200 -o /tmp/bench-jump.txt >/dev/null 2>&1 - ui-trace show /tmp/bench-jump.txt -m 'Jump to latest' --field box | - grep -o '[0-9]*,[0-9]*\.\.[0-9]*,[0-9]*' | tail -1) -if [ -n "$jump" ]; then - x=$(echo "$jump" | awk -F'[,.]' '{print int(($1 + $4) / 2)}') - y=$(echo "$jump" | awk -F'[,.]' '{print int(($2 + $5) / 2)}') - ui-trace record -d 1500 --do "tap $x $y" -o /tmp/bench-tolatest.txt >/dev/null 2>&1 +# to be. Looked for before it is pressed rather than pressed blindly: a tap +# by name fails the whole run when its label is missing, and missing is the +# ordinary case here. +ui-trace record -d 1200 -o /tmp/bench-jump.txt >/dev/null 2>&1 +if ui-trace show /tmp/bench-jump.txt -m 'Jump to latest' --field box | grep -q '[0-9],[0-9]'; then + ui-trace record -d 2500 --do "tap 'Jump to latest'" --do 'wait 1000' \ + -o /tmp/bench-tolatest.txt >/dev/null fi -# The first tap resets the report's window; see transcript-bench.sh. -ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-reset.txt >/dev/null 2>&1 +# The first copy resets the report's window; see transcript-bench.sh. +copy_render_report /tmp/bench-reset.txt adb logcat -c ./ui-sandbox.sh send "$sid" "@$file" >/dev/null @@ -90,6 +89,6 @@ while [ "$i" -lt 180 ]; do done sleep 1 -ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-report.txt >/dev/null 2>&1 +copy_render_report /tmp/bench-report.txt sleep 1 adb logcat -d -s ai-app:I | sed -n '/ai-app render report/,$p' | sed 's/^.*ai-app : //' diff --git a/app/transcript-bench.sh b/app/transcript-bench.sh index 3f2ea78..a72fdd7 100755 --- a/app/transcript-bench.sh +++ b/app/transcript-bench.sh @@ -3,8 +3,9 @@ # standard measurement for "is the transcript smooth", so a perf session # starts from a number instead of re-inventing this loop in /tmp. # -# ./transcript-bench.sh restart the app, open the first -# session, scroll, report +# ./transcript-bench.sh restart the app, open the session the +# server lists first, scroll, report +# ./transcript-bench.sh -s TITLE open that session instead # ./transcript-bench.sh -k keep whatever screen is open now # ./transcript-bench.sh -n 4 swipe cycles (default 6; one cycle is # two swipes back and two forward) @@ -16,33 +17,35 @@ # went. Compare two runs of this with the same gestures, not one run # against a phone. # -# Coordinates are the ai-app AVD's 1080x2400 screen: the report button in -# the session top bar, and the first row of the session list. +# The swipes are still coordinates, because a gesture across a scrolling +# area is a distance rather than a control. Every *control* is pressed by +# name -- see bench-lib.sh. set -eu cd "$(dirname "$0")" . ./android-env.sh >/dev/null 2>&1 +. ./bench-lib.sh keep="" cycles=6 -while getopts kn: flag; do +session="" +while getopts kn:s: flag; do case $flag in k) keep=1 ;; n) cycles=$OPTARG ;; + s) session=$OPTARG ;; *) exit 2 ;; esac done if [ -z "$keep" ]; then - adb shell am force-stop com.example.aiapp - adb shell am start -n com.example.aiapp/.MainActivity >/dev/null - sleep 5 - ui-trace record -d 3000 --do 'tap 500 545' -o /tmp/bench-open.txt >/dev/null 2>&1 - sleep 3 + [ -n "$session" ] || session=$(first_session_title) + [ -n "$session" ] || { echo "transcript-bench: no sessions to open" >&2; exit 1; } + open_session "$session" fi -# First tap resets the report's "work since last copied" window, so the -# numbers cover exactly the swipes between the two taps. -ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-reset.txt >/dev/null 2>&1 +# The first copy resets the report's "work since last copied" window, so the +# numbers cover exactly the swipes between the two. +copy_render_report /tmp/bench-reset.txt adb logcat -c DO="" @@ -56,7 +59,7 @@ while [ "$i" -lt "$cycles" ]; do done eval ui-trace record -d $((cycles * 4400 + 2000)) $DO -o /tmp/bench-scroll.txt >/dev/null 2>&1 -ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-report.txt >/dev/null 2>&1 +copy_render_report /tmp/bench-report.txt sleep 1 adb logcat -d -s ai-app:I | sed -n '/ai-app render report/,$p' | sed 's/^.*ai-app : //' adb logcat -d 2>/dev/null | grep -i "duplicate unit key" | head -3 || true diff --git a/app/ui-sandbox.sh b/app/ui-sandbox.sh index 1bd3044..8da69d0 100755 --- a/app/ui-sandbox.sh +++ b/app/ui-sandbox.sh @@ -270,6 +270,34 @@ printf 'def main():\n # a comment\n print("hello")\n' >"$FILES/main.py" printf '#!/bin/sh\n# a comment\necho hello\n' >"$FILES/run.sh" chmod +x "$FILES/run.sh" printf '{"a": 1, "b": [true, null]}\n' >"$FILES/data.json" +# Markdown's scanner is line-structured rather than tokens, so the fixture holds one of each +# thing it decides by position: a heading, a fence, a list, a quote, a link and a rule. +cat >"$FILES/notes.md" <<'MARKDOWN' +# Notes + +A paragraph with `code`, **bold** and a [link](PLAN.md). +Not emphasis: a * b * c, and snake_case_name. + +## A list + +- one +- two + +> quoted + +| column | what it holds | +|--------|---------------| +| one | a value | + +A link and a bare https://example.com/a., but run a | b +in a paragraph has no table in it. + +```rust +fn main() { println!("hello"); } +``` + +--- +MARKDOWN # Not UTF-8, so it reads as binary rather than as mojibake. printf '\377\376\000\001binary\n' >"$FILES/picture.bin" # Over FILE_LIMIT (1 MiB), so the read refuses before anything transfers. @@ -281,6 +309,33 @@ chmod 000 "$FILES/unreadable.txt" printf 'in a subdirectory\n' >"$FILES/sub/inside.txt" ln -sfn sub "$FILES/link-to-sub" ln -sfn nowhere "$FILES/broken-link" +# The sizes the explorer's limits were measured against, so the numbers in +# EXPLORER.md can be taken again rather than re-derived. 32k is the largest +# the editor handles (EDIT_LIMIT); 128k is where typing loses characters; +# 1M is FILE_LIMIT, which the viewer reads fine and the editor refuses. +python3 - "$FILES" <<'FIXTURE' +import pathlib, sys +out = pathlib.Path(sys.argv[1]) +block = """/// A doc comment on function number {i}, long enough that the scanner +/// has real comment spans to find rather than a token few. +fn generated_{i}(input: &str, count: u32) -> String {{ + // an ordinary line comment + let mut out = String::from("prefix {i}: "); + for index in 0..count {{ + out.push_str(&format!("{{}}-{{}}", index, input)); + }} + out +}} +""" +for name, size in (("edit-32k.rs", 32 * 1024), ("edit-128k.rs", 128 * 1024), + ("big-source.rs", 1024 * 1024)): + parts, written, i = [], 0, 0 + while written < size - 400: + parts.append(block.format(i=i)) + written += len(parts[-1]) + i += 1 + (out / name).write_text("".join(parts)) +FIXTURE fi if [ -n "$regen_config" ]; then diff --git a/server/src/routes.rs b/server/src/routes.rs index 22e7dde..0aabcf2 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -1798,9 +1798,20 @@ async fn stream_session( /// and local; revisit if daily use produces transcripts where this shows /// (phase 6 territory). async fn send_backlog(transcript: &Path, last: &mut u64, tx: &mpsc::Sender) -> bool { + let cursor = *last; let entries = match catch_up(transcript, *last, CATCH_UP_LIMIT) { - Ok(CatchUp::Continue(entries)) => entries, + Ok(CatchUp::Continue(entries)) => { + // The pair of them at debug, because "was this subscriber reset, + // and how far behind was it" is a question about a phone that + // nothing else here can answer -- the app sees a window arrive + // and cannot tell how far it had fallen, and a reset is the one + // thing that makes its screen jump. `RUST_LOG=ai_server=debug`, + // beside the transcript pages. + tracing::debug!(cursor, sent = entries.len(), "stream backlog: continue"); + entries + } Ok(CatchUp::Restart(entries)) => { + tracing::debug!(cursor, sent = entries.len(), "stream backlog: reset"); if tx.send(SseEvent::default().event("reset")).await.is_err() { return false; }