From db55ed4a8fb6bb9de8a1d1921b04bbcbd9328e03 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Thu, 3 Sep 2026 23:58:59 -0400 Subject: [PATCH 01/12] The file explorer on the phone The other half of EXPLORER.md: a folder button on the session header opens the machine's filesystem, starting where the session works. It draws **over** the session in the same `Box`, so the session under it stays composed -- 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 it (editor, viewer, directory, parent) and only closes from where it opened; the platform gesture, the button and the swipe all go through the one function, so they cannot mean different things. 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 a single `Text` measures all of it to draw a screenful. Lines do not wrap and share one horizontal scroll, so a logical line is a visual line and the gutter cannot come to number the wrong text; the gutter's width is measured from the digit count of the line count in the style it is drawn in. The editor is a `BasicTextField` with a `VisualTransformation` carrying the scanner's spans, which is the one Compose API that colours a field's own text rather than replacing the field. `fileLanguage` reads the same table `fenceLanguage` does, so a language added for fences is a language added for files. A file that changed on the machine while it was open here refuses to be overwritten and asks, with what each of the three answers costs. That is the ordinary case, not the exotic one: an agent editing the file somebody is reading is what this whole feature is for. The speedometer moves off the header into the session settings dialog, where the session's other about-the-session controls are, and the folder takes a place between the usage chart and the cog -- widest scope to narrowest, cog at the end, as Iris asked. Both benchmark scripts move onto `ui-trace`'s new tap-by-label action in the same change, so the render report is never unavailable and never pressed at a coordinate that has stopped meaning anything; `app/bench-lib.sh` is what they share, and `grep -n "tap [0-9]" app/*.sh` is the check. Exercised on the emulator against the sandbox's new fixture tree, with a screenshot or a ui-trace for each: the listing (dotfiles, directories first, a symlink to a directory sorted with them, a name with a tab in it), a highlighted file, binary, too big, a permission error, editing and saving, the 409 and its Overwrite, back with unsaved edits, creating a name that exists, creating one that does not and landing in the editor, an empty directory, and `..` above the directory the session opened in. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 37 + EXPLORER.md | 64 +- .../src/main/kotlin/com/example/aiapp/Api.kt | 189 ++++- .../main/kotlin/com/example/aiapp/AppRoot.kt | 23 +- .../kotlin/com/example/aiapp/Attachments.kt | 2 +- .../kotlin/com/example/aiapp/CodeFence.kt | 19 + .../kotlin/com/example/aiapp/FileEditor.kt | 143 ++++ .../kotlin/com/example/aiapp/FileLines.kt | 104 +++ .../kotlin/com/example/aiapp/FileViewer.kt | 119 +++ .../kotlin/com/example/aiapp/FilesScreen.kt | 681 ++++++++++++++++++ .../kotlin/com/example/aiapp/ImportScreen.kt | 9 - .../kotlin/com/example/aiapp/NerdIcons.kt | 35 +- .../kotlin/com/example/aiapp/SessionScreen.kt | 141 ++-- .../example/aiapp/SessionSettingsDialog.kt | 19 + .../main/kotlin/com/example/aiapp/Sizes.kt | 20 + .../src/main/kotlin/com/example/aiapp/Sse.kt | 2 +- .../src/main/res/font/nerd_icons.ttf | Bin 2400 -> 2860 bytes .../kotlin/com/example/aiapp/FileLinesTest.kt | 73 ++ app/bench-lib.sh | 57 ++ app/build-icon-font.sh | 5 + app/stream-bench.sh | 35 +- app/transcript-bench.sh | 31 +- 22 files changed, 1647 insertions(+), 161 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt create mode 100644 app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt create mode 100644 app/bench-lib.sh diff --git a/AGENTS.md b/AGENTS.md index b2409e2..8b5b51a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,6 +129,26 @@ 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, and a source file + per language. 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. - `.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 +232,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 diff --git a/EXPLORER.md b/EXPLORER.md index 7deb312..d4a9ec8 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 @@ -258,12 +259,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,32 +393,6 @@ 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 - -- **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. - ## Numbers to measure, before deciding - Scan time for a 1 MiB source file on the emulator, and on the phone @@ -425,19 +406,6 @@ pure functions with tests. If not, edit mode gets a lower cap than the viewer, stated in the editor rather than discovered by a stuck keyboard. -## Order of work - -Each step leaves the app working and is one commit. - -1. Server: `files.rs` with `list` and `read`, routes, tests. Half a day. -2. App: icons, `Api.kt`, `FilesScreen` listing, `FileViewer`, the root - and session wiring, the render-report move with the benches. A day. -3. Server: `write`, `create_file`, `create_dir`, the stdin helper and - `ship_attachment` onto it. Half a day. -4. App: `FileEditor`, the create dialog, the conflict dialog. Half a day. -5. Measurements above, the sandbox fixture, PLAN.md and AGENTS.md. Half a - day. - ## Later, deliberately not now - Delete, rename and move. Destructive controls belong here eventually, 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 051cc71..c47e2c3 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 ad1b81c..bca0936 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() @@ -206,9 +216,20 @@ fun AppRoot( settings = current, summary = here.summary, onBack = goToMain, + onFiles = { screen = here.copy(files = it) }, share = share, onShareTaken = { share = null }, ) + // Over the session, in the same Box, with the session still composed beneath + // it. 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 -> 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..f325c05 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, 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..be9d365 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt @@ -0,0 +1,143 @@ +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 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..6d6ac63 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt @@ -0,0 +1,104 @@ +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>, +) { + 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 { + val body = text.removeSuffix("\n") + val lines = body.split('\n') + val rules = language?.let { rulesOf(it) } + val scanned = if (rules == null) emptyList() else scan(body, rules) + return FileLines(lines, bucket(lines, scanned)) + } + + /** + * 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..7b4601a --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt @@ -0,0 +1,119 @@ +package com.example.aiapp + +import androidx.compose.foundation.horizontalScroll +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.Dp +import androidx.compose.ui.unit.dp + +/** 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) + +/** + * 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. + * + * The gutter stays put while the text scrolls, so a line number is still there to read at the right + * hand end of a long line. 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. + */ +@Composable +fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) { + val style = codeStyle() + val scroll = rememberScrollState() + val gutter = gutterWidth(lines.size, style) + // 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(modifier) { + LazyColumn(Modifier.fillMaxWidth()) { + items(lines.size) { index -> + Row(verticalAlignment = Alignment.Top) { + LineNumber(index + 1, gutter, style) + Text( + lines.line(index), + style = style, + softWrap = false, + modifier = Modifier.horizontalScroll(scroll), + ) + } + } + } + } +} + +/** + * One line's number, right-aligned in the gutter. + * + * `onSurfaceVariant`, because it is not part of the file: it is this app numbering it, and giving + * it the text's own colour would put it in the same voice as the code. + */ +@Composable +fun LineNumber(number: Int, width: Dp, style: TextStyle) { + Text( + number.toString(), + style = style, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.End, + maxLines = 1, + modifier = Modifier.width(width), + ) + Spacer(Modifier.width(GUTTER_GAP)) +} + +/** + * 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() + } + } +} + +/** + * 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..e56fcf7 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt @@ -0,0 +1,681 @@ +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 three places (the + // button, the platform gesture, the swipe) which must all 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() + // Innermost wins, so this takes the gesture before the session's own swipe-to-list + // does, and the reader steps back through the explorer rather than out of it. + .swipeBack(::back) + ) { + 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 + + 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 = loaded != null) + } + } + + saveError?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + 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 { + val lines = + remember(file.content, language) { + FileLines.of(file.content, language) + } + FileViewer(lines) + } + // 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/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index d1cfd65..6f817e9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -667,15 +667,6 @@ private fun ImportableList( } } -/** A byte count at the coarsest unit that still says something, so rows stay comparable. */ -private fun humanSize(bytes: Long): String? = - when { - bytes <= 0L -> null - bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB" - bytes >= 1_000L -> "${bytes / 1_000L} kB" - else -> "$bytes B" - } - /** What this session is: the measurements, in the order they are worth knowing. */ private fun statsOf(session: Importable): String = listOfNotNull( 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 78834f6..b108c10 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -236,6 +236,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. */ @@ -1225,6 +1227,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( @@ -1266,74 +1322,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 @@ -1948,6 +1966,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 d04f52b..97998ab 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt @@ -56,6 +56,11 @@ fun SessionSettingsDialog( title: String, onRenamed: (String) -> 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) } @@ -258,6 +263,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 new file mode 100644 index 0000000..ffb055d --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt @@ -0,0 +1,20 @@ +package com.example.aiapp + +/** + * A byte count at the coarsest unit that still says something, so rows stay comparable. + * + * Null at zero and below, because the two 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. + * + * Its own file rather than the import screen's, where it started: two screens now say a size, and a + * second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B. + */ +fun humanSize(bytes: Long): String? = + when { + bytes <= 0L -> null + bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB" + bytes >= 1_000L -> "${bytes / 1_000L} kB" + else -> "$bytes B" + } 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 172bdc4e77dcfbd30c40b92a17c2d0f70cb59374..2be19a12dcbadda9a8dde59cb73fca1259934f6f 100644 GIT binary patch delta 829 zcmYk4PiWI{6vy8$|7*9U8Lr#dkffGY55m^Y4xt5|V;3*ma4jBWO}lkfTNl{Sh(ggr z1y9-HVc-zYWeBpPR~-_@+RH#uWQYhl(H%rKo29-)6cavqc`uLmzIng5)43Kjf)$}3 z0J8bAsuAbX7JzUEfXMk9i!zL|5`b?NzcX{{g4SvQ4d4%>FU*y074G&t{ekbB0NlOe ztU41KsGkDhS1^_+VxZ#+`wjhl^uxvSqAn~`%jh@IkCf)~YJ0OS3~}H!H&9k}4PN{2 zW5PK4@^!U5+xs{^hytFVpqw_pu-IZ?4h1mJUR@&8gkzYaND2u>BY{Lb z)*TFRLu!JdVVC!rhx*>DewvGB|?|mDa2RY+TCt(tW9HUNnz@?l}@Ho$uzT- z(d*3^wMkK2A(@&ucsP+t>XS^pImnd`9jpM*8W4b81)T7ILb29RzC@~Zzk&sAyL{fZ&-;rhKr<|1=q_)3D z)|fBOMdlIu#4pPYXUZOPrkGt)GcQw-lYUo+`xk$10c^wBu?<=yGO}$i(ep&1HQQL% zDV63i8&9dze9uOspNT{U$QZdwZjvSPhJ2-NQy-{3`W$AsSxB+}d#@>TXK;8HMhxT~ z;$A%fW{%tT{q-gDEFY^76GQ}n1(6DS;F_Vm;DK8LBK^?=w-N2G2W|_9%n=U&*LHc} s)}S5t0BA$|zyqKiE%rhHM<_BcJ^BDoT4dgO00cmk@*V)vdV@E}Z#0y`!~g&Q delta 373 zcmZ1@_CRQYSUp=00|P@sa&BS)V}`9A0|VO|Apc2nS&0IJ3#%Xl1N#LapCvu9xZwYP z24)5Z&Ill%BR!`wt>Nm*|3LN`1_oB1jMT&wC4=+2K>Z>>F|!Py0AD8SJ0L#;$XCh8 zEvewNVpIU~eSmzKoc!cOwk_<(7>pTMe}DvX6DtZBE_1g74Z8v4E952Srb^B;HUv80 z3IhXUQbB%k$$u7xbf5z)HlE04oUF^NGkGDi%I2TUVa%+IKrOtJ!`RiOPcmF%lw&kx zJiw&RWXjaW0FnXXa`C{#$xGNRIVUjO0g5m%NKF33ewA|pn9abzGx-9CQ5^FV27Vw0 zI)Q= 0 && it.end <= line.length }, + "line $index", + ) + } + } + + @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/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 From 2c122742851cfc3c25dde2420f993f842be1bedf Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 00:16:57 -0400 Subject: [PATCH 02/12] Measure the explorer, and cap edit mode at what it can carry Three numbers, taken on the emulator through the app's own render report and written into EXPLORER.md; the fixture tree the sandbox now builds is what they were taken against. The viewer's scan was on the main thread. Decision 8 said off it, and the first version did it in a `remember` inside the composition, which is not that -- 460ms of frozen screen on a 1 MiB file, long enough that the accessibility tree cannot be read, which is exactly what "the app has stopped" looks like from outside. It runs on Dispatchers.Default now, with a spinner where the file will be. Reading a megabyte is otherwise fine: the viewer is a row per line, and it opens and scrolls 28,660 of them. Edit mode needed a cap, and not the one the plan expected. The cost that matters is not the highlighting -- 40ms a keystroke at 128 kB, which is survivable -- it is Compose laying out one enormous text in the field: 2,027ms per frame at 128 kB, with typed characters dropped, and no response at all at 1 MiB. Switching highlighting off would have saved nothing, since every arrangement of a single text field pays it. So EDIT_LIMIT is 32 kB, the largest size actually measured as usable, and above it the pencil is disabled with the reason 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. `FileLines.of` is timed like everything else here, so the figure lands in the render report rather than needing a harness to ask for it. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 7 +++ EXPLORER.md | 62 +++++++++++++++---- .../kotlin/com/example/aiapp/FileEditor.kt | 24 +++++++ .../kotlin/com/example/aiapp/FileLines.kt | 19 +++--- .../kotlin/com/example/aiapp/FileViewer.kt | 33 ++++++++++ .../kotlin/com/example/aiapp/FilesScreen.kt | 26 ++++++-- 6 files changed, 147 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3248ba3..fbcebbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,6 +149,13 @@ repo is in PLAN.md's "Backend layout" section. `./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 diff --git a/EXPLORER.md b/EXPLORER.md index 8f7bb2d..85ece9e 100644 --- a/EXPLORER.md +++ b/EXPLORER.md @@ -393,18 +393,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`. -## Numbers to measure, before deciding +## What the measurements said (2026-09-04) -- 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. +Taken on the emulator in a **debug** build, which runs Compose at a +fraction of release speed and renders in software -- so these rank +correctly against each other and are pessimistic in absolute terms. +Generated Rust, through the app's own render report. + +| file | lines | scan + cut | scan per keystroke | worst frame record | +|--------|--------|------------|--------------------|--------------------| +| 32 kB | 917 | 11ms | 10ms | 183ms | +| 128 kB | 3,633 | -- | 40ms | 2,027ms | +| 1 MB | 28,660 | 460ms | -- | -- | + +Three things followed. + +**The viewer's scan had to leave the main thread.** Decision 8 said "off +the main thread" and the first version did it in a `remember` inside the +composition, which is not that: 460ms of frozen screen at the size the +server is willing to send, long enough that the accessibility tree cannot +be read -- which is exactly what "the app has stopped" looks like from +outside. It now runs on `Dispatchers.Default` with a spinner where the file +will be. + +**`FILE_LIMIT` at 1 MiB is right for reading.** Time to first line for a +1 MiB file, tap to text on screen, was **2.4s** against the sandbox -- +1.2s of which is that server's deliberate `--delay`, and 460ms the scan. +The transfer is not what dominates, so the route gains nothing from +streaming. + +**Edit mode needed a cap, and not the one that was expected.** The plan +expected to be deciding a size below which highlighting stays on. That is +not the cost that matters: highlighting 128 kB costs 40ms a keystroke, +which is survivable, while laying the same text out in one +`BasicTextField` costs two seconds -- characters typed into it were +dropped, and a 1 MiB file stopped the app responding altogether. Since +every arrangement of a single text field pays that, switching highlighting +off would have saved nothing. So `EDIT_LIMIT` is **32 kB**, the largest +size measured as usable, and above it the pencil is disabled with the +reason said in words beside it -- a disabled control teaches what the thing +can do but cannot say why it is off, and a reader who cannot edit a file +they can plainly read would otherwise conclude the app is broken. + +Reading is unaffected: the viewer opens and scrolls the 1 MiB file fine, +because it is a `LazyColumn` of lines rather than one text object. That +difference is the whole of decision 8. ## Later, deliberately not now @@ -419,3 +452,10 @@ pure functions with tests. - 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/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt index be9d365..ddbc11f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt @@ -23,6 +23,30 @@ 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. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt index 6d6ac63..27aa622 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt @@ -56,13 +56,18 @@ private constructor( * 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 { - val body = text.removeSuffix("\n") - val lines = body.split('\n') - val rules = language?.let { rulesOf(it) } - val scanned = if (rules == null) emptyList() else scan(body, rules) - return FileLines(lines, bucket(lines, scanned)) - } + 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 rules = language?.let { rulesOf(it) } + val scanned = if (rules == null) emptyList() else scan(body, rules) + FileLines(lines, bucket(lines, scanned)) + } /** * The scanner's spans, in file offsets, as spans per line in line offsets. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt index 7b4601a..808c36d 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt @@ -4,15 +4,21 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items 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.platform.LocalDensity @@ -23,12 +29,39 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign 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. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt index d629988..e2ebac4 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt @@ -375,6 +375,10 @@ private fun ColumnScope.DocPane( 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 @@ -450,7 +454,7 @@ private fun ColumnScope.DocPane( { scope.launch { fetch() } }, enabled = state !is LoadState.Loading, ) - GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = loaded != null) + GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = editable) } } @@ -463,6 +467,20 @@ private fun ColumnScope.DocPane( ) } + // 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)) @@ -484,11 +502,7 @@ private fun ColumnScope.DocPane( Modifier.verticalScroll(editScroll), ) } else { - val lines = - remember(file.content, language) { - FileLines.of(file.content, language) - } - FileViewer(lines) + 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. From 121a47da6e9f1eeb15813a439e49f5badc757237 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 00:17:46 -0400 Subject: [PATCH 03/12] Keep the explorer's measurement fixture in the sandbox The three file sizes the viewer's and the editor's limits were measured against are built by ui-sandbox.sh now, beside the rest of the explorer's fixture tree, so the figures in EXPLORER.md can be taken again instead of each session inventing its own generated file and getting a slightly different one. Co-Authored-By: Claude Opus 5 --- app/ui-sandbox.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/ui-sandbox.sh b/app/ui-sandbox.sh index 1bd3044..bde9a43 100755 --- a/app/ui-sandbox.sh +++ b/app/ui-sandbox.sh @@ -281,6 +281,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 From ffc266bf3eb7e52b4c809dffa76cb6143c5ef8d9 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 00:18:00 -0400 Subject: [PATCH 04/12] Name the measurement files in the fixture's description AGENTS.md lists what the explorer's sandbox tree holds and had not caught up with the three sized source files added beside it. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fbcebbe..40c58a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,8 +144,10 @@ repo is in PLAN.md's "Backend layout" section. 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, and a source file - per language. Point a session at it with + `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. From a074975d6f59f3ed2bc5fc0269b08dbc0e7f9795 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 00:39:37 -0400 Subject: [PATCH 05/12] Scroll the whole file sideways, not each row by its own amount Sharing one `ScrollState` across the viewer's rows was not enough to make them move together. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset into *its own* range -- its content width less its viewport -- so a short line's range is zero and it stayed put while the long line beside it moved. Each row also writes `maxValue` on the shared state as it measures, so how far the file could be dragged at all was decided by whichever row happened to measure last, and changed as the list scrolled. Both go away once every row is the same width. `FileLines` now carries the longest line in columns, and the viewer turns that into one content width from a single character's advance -- arithmetic rather than twenty thousand measurements, because the face is monospace -- and gives it to every row. 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. 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. Checked on the emulator against the generated 1 MB file, whose lines run from one character to sixty-eight: the file now moves as a block, the offset survives scrolling vertically and newly composed rows arrive at it, and the far end of the longest line is reachable. Also checked on the two cases the change had no reason to touch -- a file narrower than the screen, which still does not scroll at all, and an empty one, whose zero content width draws its one numbered line. Co-Authored-By: Claude Opus 5 --- .../kotlin/com/example/aiapp/FileLines.kt | 25 ++++++++++- .../kotlin/com/example/aiapp/FileViewer.kt | 44 ++++++++++++++++++- .../kotlin/com/example/aiapp/FileLinesTest.kt | 14 ++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt index 27aa622..cdff8c3 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt @@ -23,6 +23,15 @@ private constructor( 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 @@ -66,9 +75,23 @@ private constructor( val lines = body.split('\n') val rules = language?.let { rulesOf(it) } val scanned = if (rules == null) emptyList() else scan(body, rules) - FileLines(lines, bucket(lines, scanned)) + 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. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt index 808c36d..32201de 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt @@ -75,6 +75,16 @@ fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modif * 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 gutter stays put while the text scrolls, so a line number is still there to read at the right * hand end of a long line. 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 @@ -85,6 +95,7 @@ fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) { val style = codeStyle() val scroll = rememberScrollState() val gutter = gutterWidth(lines.size, style) + val content = contentWidth(lines.columns, style) // 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(modifier) { @@ -96,7 +107,9 @@ fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) { lines.line(index), style = style, softWrap = false, - modifier = Modifier.horizontalScroll(scroll), + // 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. + modifier = Modifier.horizontalScroll(scroll).width(content), ) } } @@ -143,6 +156,35 @@ fun gutterWidth(lineCount: Int, style: TextStyle): Dp { } } +/** + * 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. * diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt index ebe6652..0b054a5 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt @@ -54,6 +54,20 @@ class FileLinesTest { } } + /** + * 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) From 457907087c5ce41bacc78ca235d027110556f6a7 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 00:40:02 -0400 Subject: [PATCH 06/12] Record why the viewer's rows all share one width EXPLORER.md's decision 8 said "one shared horizontalScroll state", which is what was built and is not sufficient on its own -- the reason is worth having beside the decision rather than only in the code that now works. Co-Authored-By: Claude Opus 5 --- EXPLORER.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/EXPLORER.md b/EXPLORER.md index 85ece9e..b5b18fe 100644 --- a/EXPLORER.md +++ b/EXPLORER.md @@ -164,6 +164,23 @@ 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. + 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 From 7b08a71e644c9736663a205e3c633a2e33e7cdd5 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 01:38:21 -0400 Subject: [PATCH 07/12] Stretch the whole file at the ends, not one line `Modifier.horizontalScroll` builds its own overscroll effect per node, so with a node per row only the line under the finger bent when the file was dragged past either end and the rest sat still beside it -- the same complaint as the offsets, one layer out. There is an overload that takes the effect instead of making one, and it leaves the rendering to the caller. So the viewer makes one effect, hands it to every row, and renders it once on a box around the list: the file bends as the block it scrolls as. This only works now that every row is the same width -- rows that disagreed about where the end was would disagree about when to stretch. **Not seen working.** Measured on the emulator here: over-dragging well past the end and capturing mid-gesture produces a frame with no stretch in it at all, and the list's own vertical overscroll does not appear either, so this VM cannot show the effect for any scrollable. Noted in ~/.claude/MACHINE.md so the next session does not spend the same half hour on it. What was checked here is everything either side: the scroll still reaches both ends, the position survives scrolling vertically, and nothing else moved. The stretch itself wants a look on the phone. Co-Authored-By: Claude Opus 5 --- EXPLORER.md | 9 ++++ .../kotlin/com/example/aiapp/FileViewer.kt | 45 +++++++++++++------ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/EXPLORER.md b/EXPLORER.md index b5b18fe..0775389 100644 --- a/EXPLORER.md +++ b/EXPLORER.md @@ -181,6 +181,15 @@ 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. + 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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt index 32201de..745db7f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt @@ -1,13 +1,16 @@ 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.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +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 @@ -21,6 +24,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle @@ -85,6 +89,14 @@ fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modif * 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 stays put while the text scrolls, so a line number is still there to read at the right * hand end of a long line. 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 @@ -94,23 +106,30 @@ fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modif fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) { val style = codeStyle() val scroll = rememberScrollState() + val overscroll = rememberOverscrollEffect() val gutter = gutterWidth(lines.size, style) val content = contentWidth(lines.columns, style) // 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(modifier) { - LazyColumn(Modifier.fillMaxWidth()) { - items(lines.size) { index -> - Row(verticalAlignment = Alignment.Top) { - LineNumber(index + 1, gutter, style) - 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. - modifier = Modifier.horizontalScroll(scroll).width(content), - ) + // The stretch is drawn here, once, over everything the viewport 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(Modifier.fillMaxSize()) { + items(lines.size) { index -> + Row(verticalAlignment = Alignment.Top) { + LineNumber(index + 1, gutter, style) + 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), + ) + } } } } From a401e6a7e348f9234924ef82a8316d0c7562e5fb Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 01:51:47 -0400 Subject: [PATCH 08/12] Draw the line numbers beside the file, not inside it The stretch is rendered on the box around the list, so everything in that box bent with it -- the numbers included. `Modifier.overscroll` wraps the effect's single node, so one effect cannot be rendered in two places and the numbers cannot be excluded while they live in the rows. They had to leave. They now sit beside the scrolling box: the rows leave a spacer where the numbers go and a `SubcomposeLayout` draws them there. That is the one arrangement that keeps them level with their lines, which is the thing a numbered listing may never get wrong. Which numbers exist *and* where each one 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 do that: the translation would be a layout read and current while the set of numbers was a composition behind it, and during a fling the numbers would slide against their lines. Checked by sampling the screen at about 1kHz through a fling: 23,520 row observations over 552 frames, every one with its number at exactly its own top, no drift at any point. Also that the gutter holds its x while the text scrolls sideways, and that a short file and an empty one still draw. A consequence worth having: the numbers are no longer inside the `SelectionContainer`, so selecting part of a file and copying gives the code rather than the code with a number in front of every line. That follows from where they are now rather than from anything asked for, and a copy was not exercised. The stretch itself still cannot be seen from this VM, so whether the numbers now stay flat while the text bends is the thing to look at on the phone. Co-Authored-By: Claude Opus 5 --- EXPLORER.md | 17 +++ .../kotlin/com/example/aiapp/FileViewer.kt | 121 ++++++++++++------ 2 files changed, 101 insertions(+), 37 deletions(-) diff --git a/EXPLORER.md b/EXPLORER.md index 0775389..d6c6e45 100644 --- a/EXPLORER.md +++ b/EXPLORER.md @@ -190,6 +190,23 @@ 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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt index 745db7f..58d2501 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt @@ -1,14 +1,18 @@ 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 @@ -25,12 +29,14 @@ 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 @@ -97,62 +103,103 @@ fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modif * every row now has the same range: rows that disagreed about where the end was would disagree * about when to stretch. * - * The gutter stays put while the text scrolls, so a line number is still there to read at the right - * hand end of a long line. 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. + * 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) - // 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(modifier) { - // The stretch is drawn here, once, over everything the viewport 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(Modifier.fillMaxSize()) { - items(lines.size) { index -> - Row(verticalAlignment = Alignment.Top) { - LineNumber(index + 1, gutter, style) - 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), - ) + 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) } } /** - * One line's number, right-aligned in the gutter. + * The line numbers, drawn beside the file rather than in it. * - * `onSurfaceVariant`, because it is not part of the file: it is this app numbering it, and giving - * it the text's own colour would put it in the same voice as the code. + * 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 -fun LineNumber(number: Int, width: Dp, style: TextStyle) { - Text( - number.toString(), - style = style, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.End, - maxLines = 1, - modifier = Modifier.width(width), - ) - Spacer(Modifier.width(GUTTER_GAP)) +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) } + } + } } /** From 68c518026030fbca18955e8a3939e7085ca45824 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 02:37:32 -0400 Subject: [PATCH 09/12] Colour markdown, which is the one language that is not tokens The token scanner asks what a character is; markdown's meaning is where it sits, so a `#` opens a heading at the start of a line and is an ordinary character three words in. `MarkdownSyntax.kt` reads structure a line at a time and then each line's prose left to right, and `spansOf` is the one entry point that hides which of the two scanners a language got. Conservative wherever a guess would be invisible: emphasis needs a closer on the same line with no space beside either marker, so the `*p = *q` of a C fragment opens nothing; an underscore may not start or end inside a word; and an indented code block is left plain, since four spaces after a blank line and four after a bullet are the same line. Co-Authored-By: Claude Opus 5 --- TRANSCRIPT_RENDERING.md | 15 + .../kotlin/com/example/aiapp/CodeFence.kt | 2 + .../kotlin/com/example/aiapp/FileLines.kt | 3 +- .../kotlin/com/example/aiapp/Highlighter.kt | 2 +- .../kotlin/com/example/aiapp/Languages.kt | 25 +- .../com/example/aiapp/MarkdownSyntax.kt | 321 ++++++++++++++++++ .../com/example/aiapp/HighlighterTest.kt | 99 +++++- app/ui-sandbox.sh | 21 ++ 8 files changed, 476 insertions(+), 12 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt diff --git a/TRANSCRIPT_RENDERING.md b/TRANSCRIPT_RENDERING.md index 72ac5a9..68bdccc 100644 --- a/TRANSCRIPT_RENDERING.md +++ b/TRANSCRIPT_RENDERING.md @@ -239,6 +239,21 @@ and are the reason several tempting simplifications were rejected. 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. + **Markdown is the one language that is not a `Rules` row** + (`MarkdownSyntax.kt`, added 2026-09-04). It has no keywords and no + strings, and what a character means there depends on where on the line it + sits -- a `#` opens a heading at the start of a line and is an ordinary + character three words in -- which is a question the token scanner cannot + ask. So it carries a scanner of its own, structure a line at a time and + then the inline forms left to right, and `spansOf` in `Languages.kt` is + the one entry point that hides which of the two a language got. It is + deliberately conservative where a guess would be invisible: emphasis needs + a closer on the same line with no space beside either marker (so the + `*p = *q` of a C fragment in a paragraph opens nothing), an underscore may + not start or end inside a word (`snake_case_name`), and an indented code + block is left plain, because four spaces after a blank line and four + spaces after a bullet are the same line and only what came before tells + them apart. 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 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 f325c05..5bcf9ad 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt @@ -201,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/FileLines.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt index cdff8c3..f5fa96c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt @@ -73,8 +73,7 @@ private constructor( DebugStats.timed("file scanned and cut into lines") { val body = text.removeSuffix("\n") val lines = body.split('\n') - val rules = language?.let { rulesOf(it) } - val scanned = if (rules == null) emptyList() else scan(body, rules) + val scanned = if (language == null) emptyList() else spansOf(body, language) FileLines(lines, bucket(lines, scanned), lines.maxOf(::columnsOf)) } 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..b1f0032 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt @@ -0,0 +1,321 @@ +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 a fenced block, which is the one piece of state + * carried across them. An unclosed fence therefore colours the rest of the text, which is also what + * it looks like while somebody is still writing it. + * + * 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 = "*_~" + +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 + while (at <= code.length) { + val newline = code.indexOf('\n', at) + val end = if (newline < 0) code.length else newline + 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) + if (opened == null) structure(at, end) + fence = opened + } + if (end == code.length) break + at = end + 1 + } + return spans + } + + /** + * 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 in EMPHASIS -> emphasis(at, end) + else -> 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 + } + + /** + * `*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/test/kotlin/com/example/aiapp/HighlighterTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt index 4e5a43d..108c633 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,86 @@ 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 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 +267,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 +296,20 @@ class HighlighterTest { "0x", "1.2.3", "a#b//c/*d*/'e\"f", + "```", + "*", + "**", + "~~", + "> ", + "- ", + "1.", + "[x](", + "#######", "\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/ui-sandbox.sh b/app/ui-sandbox.sh index bde9a43..a735549 100755 --- a/app/ui-sandbox.sh +++ b/app/ui-sandbox.sh @@ -270,6 +270,27 @@ 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 + +```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. From 7997eeb7f865d8a3eebc429a80d348d153b215c7 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 10:47:18 -0400 Subject: [PATCH 10/12] Colour markdown's tables and the addresses written in it A table is recognised by its delimiter row, the only line of one that cannot be anything else, and its header is the line above -- the single place the scanner looks ahead. Colouring every `|` instead would have marked the pipes of a shell command written in a paragraph. Addresses come in two shapes: `<...>` needs a scheme's colon or an at sign inside it and no whitespace, which leaves `
` alone; a bare `scheme://` needs no closer, so where it ends is the decision -- the sentence's trailing punctuation is given back, and so is a closing bracket unless one opened inside the URL. Co-Authored-By: Claude Opus 5 --- TRANSCRIPT_RENDERING.md | 5 +- .../com/example/aiapp/MarkdownSyntax.kt | 156 +++++++++++++++++- .../com/example/aiapp/HighlighterTest.kt | 78 +++++++++ app/ui-sandbox.sh | 7 + 4 files changed, 238 insertions(+), 8 deletions(-) diff --git a/TRANSCRIPT_RENDERING.md b/TRANSCRIPT_RENDERING.md index 68bdccc..ce54604 100644 --- a/TRANSCRIPT_RENDERING.md +++ b/TRANSCRIPT_RENDERING.md @@ -253,7 +253,10 @@ and are the reason several tempting simplifications were rejected. not start or end inside a word (`snake_case_name`), and an indented code block is left plain, because four spaces after a blank line and four spaces after a bullet are the same line and only what came before tells - them apart. + them apart. A table is the one thing it looks ahead for: the delimiter row + (`|---|---|`) is the only line of one that cannot be anything else, and the + header belongs to the line before it -- which is what keeps the pipes of a + shell command written in a paragraph plain. 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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt index b1f0032..470438a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt @@ -11,9 +11,11 @@ package com.example.aiapp * 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 a fenced block, which is the one piece of state - * carried across them. An unclosed fence therefore colours the rest of the text, which is also what - * it looks like while somebody is still writing it. + * 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 @@ -34,6 +36,10 @@ 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() @@ -41,9 +47,10 @@ private class MarkdownScanner(private val code: String) { 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 newline = code.indexOf('\n', at) - val end = if (newline < 0) code.length else newline + 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 @@ -52,8 +59,8 @@ private class MarkdownScanner(private val code: String) { if (closesFence(at, end, open)) fence = null } else { val opened = opensFence(at, end) - if (opened == null) structure(at, end) fence = opened + if (opened != null) table = false else table = row(at, end, table) } if (end == code.length) break at = end + 1 @@ -61,6 +68,79 @@ private class MarkdownScanner(private val code: String) { 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. * @@ -204,8 +284,9 @@ private class MarkdownScanner(private val code: String) { 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 -> at + 1 + else -> url(at, end) ?: (at + 1) } } } @@ -272,6 +353,67 @@ private class MarkdownScanner(private val code: String) { 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. * 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 108c633..ab4f529 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt @@ -255,6 +255,78 @@ class HighlighterTest { 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) @@ -305,6 +377,12 @@ class HighlighterTest { "1.", "[x](", "#######", + "|", + "|---|", + "<", + "<>", + "http://", + "a://", "\n\n \n", ) for (language in Language.entries) { diff --git a/app/ui-sandbox.sh b/app/ui-sandbox.sh index a735549..8da69d0 100755 --- a/app/ui-sandbox.sh +++ b/app/ui-sandbox.sh @@ -285,6 +285,13 @@ Not emphasis: a * b * c, and snake_case_name. > 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"); } ``` From 45e631ab966214afd507d3c148555cf9d8f2d87a Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 13:38:40 -0400 Subject: [PATCH 11/12] Delete TRANSCRIPT_RENDERING.md, which was all finished work The file was a week's record of the transcript work, and everything in its architecture, techniques and rejected sections has landed -- so it was a log of how the code got here, which the code's own comments and AGENTS.md already carry. Kept only what existed nowhere else: stream-bench.sh, trace-draw.sh and the two emulator-loop traps go to AGENTS.md beside transcript-bench.sh, along with the standing rule to run the benches either side of a transcript change; GrapheneOS's broken System Tracing goes to ~/.claude/MACHINE.md, since it is about the phone rather than this project. Its one unfinished item -- the reconnect loop after a restart onto a streaming session, which may already have been fixed by the restore's one-event-per-request fix -- moves to TODO.md. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 31 +++- TODO.md | 13 ++ TRANSCRIPT_RENDERING.md | 303 ---------------------------------------- 3 files changed, 37 insertions(+), 310 deletions(-) delete mode 100644 TRANSCRIPT_RENDERING.md diff --git a/AGENTS.md b/AGENTS.md index 40c58a6..103ada8 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 @@ -370,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. @@ -523,7 +518,29 @@ 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*. - **`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/TODO.md b/TODO.md index 1f9dcde..5155c84 100644 --- a/TODO.md +++ b/TODO.md @@ -16,6 +16,19 @@ one in place when it turns out to need a decision. would show exactly as "sometimes". Confirming it means driving a real stream-json session and sending it messages in both states. +## App — reconnect + +- [ ] 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`) with the 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. **May already be fixed:** the + restore's one-event-per-request bug was part of what made it so + visible and has since been fixed, so the first thing to find out is + whether this survives that. Found 2026-09-03. + ## Session settings - [ ] Autocompact belongs in session settings; empty disables it, which is the diff --git a/TRANSCRIPT_RENDERING.md b/TRANSCRIPT_RENDERING.md deleted file mode 100644 index ce54604..0000000 --- a/TRANSCRIPT_RENDERING.md +++ /dev/null @@ -1,303 +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. - **Markdown is the one language that is not a `Rules` row** - (`MarkdownSyntax.kt`, added 2026-09-04). It has no keywords and no - strings, and what a character means there depends on where on the line it - sits -- a `#` opens a heading at the start of a line and is an ordinary - character three words in -- which is a question the token scanner cannot - ask. So it carries a scanner of its own, structure a line at a time and - then the inline forms left to right, and `spansOf` in `Languages.kt` is - the one entry point that hides which of the two a language got. It is - deliberately conservative where a guess would be invisible: emphasis needs - a closer on the same line with no space beside either marker (so the - `*p = *q` of a C fragment in a paragraph opens nothing), an underscore may - not start or end inside a word (`snake_case_name`), and an indented code - block is left plain, because four spaces after a blank line and four - spaces after a bullet are the same line and only what came before tells - them apart. A table is the one thing it looks ahead for: the delimiter row - (`|---|---|`) is the only line of one that cannot be anything else, and the - header belongs to the line before it -- which is what keeps the pipes of a - shell command written in a paragraph plain. - 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. From 74110b4d7205ed0d1df3c8119fcb3821353fb477 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 14:01:01 -0400 Subject: [PATCH 12/12] The reconnect loop does not reproduce; log who falls behind Measured against a session streaming 20 events a second. Reopening one with an anchor 1,800 events back connects 87-119 events behind, under CATCH_UP_LIMIT's 200 -- the restore is two requests now, the opening page and one span covering the whole distance to the anchor, where the report was written against a restore that took thirteen. Three provocations, no repeat connection between them: that reopen, twenty seconds backgrounded while 415 events were produced, and a reset forced by dropping the limit to 5, where the app cleared, refilled and carried on without reconnecting. So the TODO entry goes. What stays is the instrument that answered it: every SSE subscriber now logs at debug the cursor it arrived with and whether it was continued or reset, since nothing else could say. The app sees a window arrive and cannot tell how far it had fallen. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 19 +++++++++++++++++++ TODO.md | 13 ------------- server/src/routes.rs | 13 ++++++++++++- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 103ada8..1f49443 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -541,6 +541,25 @@ first if a remote spawn ever mangles an argument. `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/TODO.md b/TODO.md index 5155c84..1f9dcde 100644 --- a/TODO.md +++ b/TODO.md @@ -16,19 +16,6 @@ one in place when it turns out to need a decision. would show exactly as "sometimes". Confirming it means driving a real stream-json session and sending it messages in both states. -## App — reconnect - -- [ ] 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`) with the 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. **May already be fixed:** the - restore's one-event-per-request bug was part of what made it so - visible and has since been fixed, so the first thing to find out is - whether this survives that. Found 2026-09-03. - ## Session settings - [ ] Autocompact belongs in session settings; empty disables it, which is the diff --git a/server/src/routes.rs b/server/src/routes.rs index f2b5c51..6cbdf15 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -1788,9 +1788,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; }