Thin the app's comments

The same pass the server had, on the Kotlin side: comments restating what
the code says are gone, and the ones recording a measurement, a constraint
or an incident are kept but cut to a few lines each. 6540 comment lines to
5674, and 920 lines off the app.

Two doc comments had drifted onto the item above the one they describe --
`contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s
equivalent on the server was fixed in the previous commit. Each is back on
its own item, which is the only non-comment line this diff moves.

The comments are reflowed to the column limit at their own indentation:
several were written wide, and ktfmt re-wrapped them into lines holding a
single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the
result, which is the check.

Left alone deliberately: this codebase's remaining comment density is high
because the comments carry things the code cannot say -- what a null means,
what a number was measured against, which bug a guard exists for. Of the
238 one-line doc comments in the app, five were pure restatement of the
name and were removed; the rest each say something the signature does not.

ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass;
cargo test (127), clippy --all-targets and fmt still clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-04 16:20:16 -04:00
1 parent 79682f03a7
commit edc39c7371
68 files changed
+2077 -2997

No files matched your search

@@ -6,26 +6,23 @@ import java.net.URL
import org.json.JSONArray
import org.json.JSONObject
// The REST half of the backend's surface (see server/src/routes.rs for the
// table); the SSE half is EventStream.kt. All blocking network calls --
// invoke from a background dispatcher. Each throws ApiException on failure,
// carrying the server's own explanation where it sent one, since those
// messages are written to be read on this screen.
// The REST half of the backend's surface (see server/src/routes.rs for the table); the SSE half is
// EventStream.kt. All blocking network calls -- invoke from a background dispatcher. Each throws
// ApiException carrying the server's own explanation where it sent one, since those messages are
// written to be read on this screen.
// Shared with EventStream.kt, which connects the same way but then reads
// without a deadline.
// Shared with EventStream.kt, which connects the same way but then reads without a deadline.
const val CONNECT_TIMEOUT_MS = 5000
private const val READ_TIMEOUT_MS = 5000
/**
* 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.
* some.
*
* [status] is the HTTP status where there was a response at all, and null where the server was
* never reached. Callers that need it need it because the *same* failure is two different things to
* do: a 409 from a write is "somebody else changed this, here are three ways out", 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.
* do: a 409 from a write is "somebody else changed this, here are three ways out". Nothing should
* branch on it to decide what to *say* -- the message is what says that.
*/
class ApiException(message: String, val status: Int? = null, cause: Throwable? = null) :
Exception(message, cause)
@@ -33,10 +30,10 @@ class ApiException(message: String, val status: Int? = null, cause: Throwable? =
/**
* Runs one request against the backend, with the pinned TLS setup, the bearer token, and the
* failure translation every call needs. [readBody] gets the connected, already-status-checked
* connection to read from.
* connection.
*
* @param readTimeoutMs how long to wait on the response body. The SSE stream doesn't come through
* here -- an event stream has no bounded read time (see EventStream.kt).
* here -- an event stream has no bounded read time.
*/
fun <T> requestFromServer(
settings: ServerSettings,
@@ -44,9 +41,9 @@ fun <T> requestFromServer(
method: String = "GET",
jsonBody: String? = null,
/**
* A request body written as it is produced -- the upload path. Content type, and a writer
* handed the connection's stream. Sent chunked, since what a writer will produce is not known
* up front and the point is that a file never sits whole in memory on this side.
* A request body written as it is produced -- the upload path. Sent chunked, since what a
* writer will produce is not known up front and the point is that a file never sits whole in
* memory.
*/
streamBody: Pair<String, (java.io.OutputStream) -> Unit>? = null,
readTimeoutMs: Int = READ_TIMEOUT_MS,
@@ -87,9 +84,8 @@ fun <T> requestFromServer(
} catch (e: ApiException) {
throw e
} catch (e: IOException) {
// Surfacing the real exception (rather than one canned message for
// every failure mode) is what lets this be diagnosed on a device
// with no logcat access.
// Surfacing the real exception rather than one canned message for every failure mode is
// what lets this be diagnosed on a device with no logcat access.
throw ApiException(
"Couldn't reach the server at ${settings.baseUrl} " +
"(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
@@ -107,11 +103,9 @@ fun <T> requestFromServer(
}
}
/** The response body as one JSON object. */
private fun HttpURLConnection.jsonObject(): JSONObject =
JSONObject(inputStream.bufferedReader().readText())
/** The response body as a JSON array of objects, each mapped through [parse]. */
private fun <T> HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List<T> =
JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse)
@@ -120,21 +114,16 @@ private fun <T> JSONArray.mapObjects(parse: (JSONObject) -> T): List<T> =
private fun JSONArray.strings(): List<String> = (0 until length()).map { getString(it) }
/** Percent-encodes a value going into a query string. */
private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name())
// One row of GET /sessions. A session names the machine it runs on and
// which of that machine's providers it runs.
// One row of GET /sessions. A session names the machine it runs on and which of that machine's
// providers it runs.
data class SessionSummary(
val id: String,
/**
* Id of the machine this session runs on. Only ever used to *address* that machine -- to pick
* this session's row out of the per-machine usage snapshots for the header's five-hour bar.
*
* It was deliberately left out until 2026-08-29, on the grounds that nothing here addressed a
* setup and holding both the id and the name invited showing the wrong one, which had already
* happened once. Something addresses one now, so the reason lapsed rather than being overruled.
* The guard that replaces it is the rule below: never show this.
* this session's row out of the per-machine usage snapshots. Never shown; [setupName] is what a
* reader sees, and holding both invites showing the wrong one.
*/
val setup: String,
/** The machine's current label. This is the one to display; [setup] is never shown. */
@@ -147,9 +136,8 @@ data class SessionSummary(
* provider's kind rather than here from its name.
*
* What it licenses is narrow, and the delete dialog is worded to match: the driver keeps its
* own record of the conversation somewhere this app's delete does not reach. It is not a
* promise that the file is still there, and re-importing is not a restore -- this app's
* transcript holds things that record does not.
* own record somewhere this app's delete does not reach. It is not a promise that the file is
* still there, and re-importing is not a restore.
*/
val keepsOwnTranscript: Boolean,
/** How much the session asks before acting; null when it was never set. */
@@ -162,35 +150,32 @@ data class SessionSummary(
* Whether this session announces itself when it wants attention.
*
* Reported rather than assumed, for the same reason [permissionMode] is: a switch that draws
* itself from a default is one you can turn off while believing you are reading it. Defaults to
* on when a backend is too old to say, which matches what that backend actually does.
* itself from a default is one you can turn off while believing you are reading it.
*/
val notify: Boolean,
/**
* The directory the session works in, or null where it was never given one.
*
* Null is not "the home directory": it is the session never having been told, and what the
* process then starts in belongs to whatever launches it. Shown as unset rather than filled in
* with a guess, so a reader changing it is choosing rather than confirming.
* Null is not "the home directory": it is the session never having been told. Shown as unset
* rather than filled in with a guess, so a reader changing it is choosing rather than
* confirming.
*/
val cwd: String?,
/**
* How much context this session is holding, as the server last measured it -- see
* `SessionEvent.UsageDelta`.
* How much context this session is holding, as the server last measured it.
*
* Null where nothing has been measured: a session that has not run a turn, a provider that does
* not report usage, or a clear nobody has run a turn since. That is not zero, and the status
* row says so in words rather than drawing an empty context for a conversation that may be
* nearly full.
* full.
*/
val contextTokens: Long?,
/**
* The longest edge an image should have when it reaches this session, or null where the
* provider has no limit.
*
* Null and "a big number" are different answers, and only the first stays true: a provider that
* does not care about size should not be given a threshold this app invented. Decided by the
* server because that is where a provider's kind is known -- see `uploadPickedImage`.
* Null and "a big number" are different answers, and only the first stays true. Decided by the
* server because that is where a provider's kind is known.
*/
val maxImageEdge: Int?,
val status: String,
@@ -225,18 +210,16 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
*
* For screens whose controls are *set to* something rather than merely showing it. A screen opened
* from a list row carries the row the list last fetched, which is a snapshot: fine for a title,
* wrong for a switch, since a switch drawn from a stale row shows a position that may have been
* changed since -- here or on another device -- and nothing on screen says which.
* wrong for a switch, since a stale row shows a position that may have been changed since.
*/
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
// What the server offers, so the spawn screen has no hardcoded lists: a
// setup added to the server's config.ron appears here with no app rebuild.
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
// config.ron appears here with no app rebuild.
//
// One list rather than two. A provider only exists on a machine that has
// it installed, so offering machines and providers as independent choices
// would offer pairs that cannot work.
// One list rather than two. A provider only exists on a machine that has it installed, so offering
// machines and providers as independent choices would offer pairs that cannot work.
data class Provider(val name: String, val kind: String, val models: List<String>)
/**
@@ -275,7 +258,7 @@ fun fetchSetups(settings: ServerSettings): List<Setup> =
* A Claude Code session already on a machine, which can be continued here.
*
* Identified by [id] and never by a path. The server resolves which file that is, so this app has
* no way to ask it to read one -- the same rule that keeps a provider's command out of this client.
* no way to ask it to read one.
*/
data class Importable(
val id: String,
@@ -284,19 +267,15 @@ data class Importable(
val modified: Double,
val lines: Int,
/**
* Size of the session file in bytes.
*
* Worth a place on the row because it is the only thing there that predicts what continuing the
* session costs, and the line count does not: these transcripts embed screenshots as base64, so
* a single line can be a megabyte.
* Size of the session file in bytes. Worth a place on the row because it is the only thing
* there that predicts what continuing the session costs, and the line count does not: these
* transcripts embed screenshots as base64, so a single line can be a megabyte.
*/
val bytes: Long,
/**
* Tokens the model was holding at the last turn, or null if no turn has recorded any.
*
* The number that predicts what continuing this session costs. It disagrees with [bytes] in the
* direction that matters: most of a large transcript is usually history from before a
* compaction, which the model is no longer given.
* Tokens the model was holding at the last turn, or null if no turn has recorded any. It
* disagrees with [bytes] in the direction that matters: most of a large transcript is usually
* history from before a compaction, which the model is no longer given.
*/
val contextTokens: Long?,
/** Whether [title] is a name somebody chose rather than the last thing said in the session. */
@@ -305,17 +284,15 @@ data class Importable(
* Whether a Claude Code is running this session right now.
*
* "unknown" is a third answer and not a synonym for "no": the machine may keep no record of
* what is running, and a session that cannot be checked is not a session that is free. The
* server refuses an import of a "yes"; the row says so before you press it.
* what is running. The server refuses an import of a "yes"; the row says so before you press
* it.
*/
val inUse: String,
/**
* What this server is doing to the session right now -- "importing" or "deleting" -- or null
* when nothing is.
* What this server is doing to the session right now -- "importing" or "deleting" -- or null.
*
* The server's answer rather than the phone's, because the work outlives the screen that asked
* for it: leaving the import list and coming back has to show what is still running, and a
* phone that was asleep or out of range never saw the events that said so.
* for it: a phone that was asleep never saw the events that said so.
*/
val pending: String?,
/**
@@ -328,7 +305,7 @@ data class Importable(
/**
* One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing.
*
* [operation] is only set by a start, and [message] only by a failure -- the three states are every
* [operation] is only set by a start and [message] only by a failure -- the three states are every
* way an operation can be, and each carries exactly what that state knows.
*/
data class ImportableChange(
@@ -348,20 +325,18 @@ fun parseImportableChange(payload: String): ImportableChange? =
message = frame.optString("message").takeIf { it.isNotEmpty() },
)
} catch (_: org.json.JSONException) {
// A frame this build does not understand is not a reason to drop the stream: the listing
// is the truth and will say what happened whatever this missed.
// A frame this build does not understand is not a reason to drop the stream: the listing is
// the truth and will say what happened whatever this missed.
null
}
/**
* What a machine has that could be continued.
*
* The slowest call this app makes, and it was the only expensive one left on the 5 second default
* which is how it came to time out against a server that was answering perfectly well. Listing
* means reading every transcript Claude Code has ever written: about four seconds against a
* gigabyte of them before the tunnel adds anything, and that figure grows with every session
* anybody has. A timeout is for a server that has stopped answering, so it is set well clear of how
* long the work takes rather than just above it.
* The slowest call this app makes, and it was the only expensive one left on the 5 second default
* -- which is how it came to time out against a server answering perfectly well. Listing means
* reading every transcript Claude Code has ever written: about four seconds against a gigabyte of
* them before the tunnel adds anything. A timeout is for a server that has stopped answering.
*/
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) {
@@ -373,13 +348,11 @@ fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
modified = session.optDouble("modified", 0.0),
lines = session.optInt("lines", 0),
bytes = session.optLong("bytes", 0L),
// Absent means nothing has been measured -- which is not a context of zero, so
// it stays null and the row simply does not claim a figure.
// Absent means nothing has been measured, which is not a context of zero.
contextTokens =
if (session.isNull("contextTokens")) null
else session.optLong("contextTokens").takeIf { it > 0L },
// Absent means an older backend that cannot answer, which is exactly what
// "unknown" says -- so the default is the honest one rather than "no".
// Absent means an older backend that cannot answer, which is what "unknown" says.
inUse = session.optString("inUse", "unknown"),
named = session.optBoolean("named", false),
pending = session.optString("pending").takeIf { it.isNotEmpty() },
@@ -528,8 +501,7 @@ fun sendMessage(
*
* Throws rather than returning an outcome, because both ways of failing are things the reader has
* to be told: 409 means the session was already given it, and 404 means nothing is waiting under
* that id. The bubble disappearing is the success case and it arrives on the event stream, not from
* here -- every device drops it, not only the one that tapped.
* that id. The bubble disappearing arrives on the event stream, so every device drops it.
*/
fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) {
requestFromServer(
@@ -543,13 +515,11 @@ fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: Strin
/**
* Moves a session to a different working directory.
*
* The server checks the directory is there on that machine and refuses if it is not -- a mistyped
* path accepted here would surface much later, as a session that would not start, with nothing
* pointing at the typo.
* The server checks the directory is there and refuses if it is not -- a mistyped path accepted
* here would surface much later, as a session that would not start.
*
* Its process is **stopped**, because a working directory is settled when the process is spawned.
* The next thing said to the session starts it again in the new one, which is this app's rule for a
* session with no process everywhere else.
* The next thing said to the session starts it again in the new one.
*/
fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) {
requestFromServer(
@@ -593,8 +563,8 @@ fun uploadAttachment(
write(out)
out.write(tail)
},
// Long: a trace is hundreds of megabytes, and the server copies it on to a remote
// machine before answering.
// Long: a trace is hundreds of megabytes, and the server copies it on to a remote machine
// before answering.
readTimeoutMs = 600000,
) { connection ->
connection.jsonObject().getString("id")
@@ -605,8 +575,7 @@ 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.
* still says it is one. Neither is worked out here -- the machine answers both.
*/
data class DirEntry(
val name: String,
@@ -625,10 +594,10 @@ data class Listing(val path: String, val entries: List<DirEntry>)
/**
* What reading a file produced.
*
* Four cases, because they are four different things to draw and none 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.
* Four cases, because they are four different things to draw and none is an error the screen can
* shrug off: content, something that is not text, something too big to have sent, and (as
* [ApiException]) the machine's own refusal. A file with nothing in it is [FileContent.Text] with
* an empty string, which is what it is.
*/
sealed class FileContent {
abstract val path: String
@@ -688,9 +657,8 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten
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.
// A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before any
// of it moves. Well clear of that rather than just above it.
readTimeoutMs = 60000,
) { connection ->
val body = connection.jsonObject()
@@ -709,8 +677,7 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten
"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.
// the nearest of the four would draw something confident about a state never seen.
else ->
throw ApiException(
"The server described this file as \"$kind\", which this app does not know how to show."
@@ -721,9 +688,8 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten
/**
* 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.
* The refusal is a 409 carrying the server's wording, which is what the conflict dialog shows -- an
* agent editing the same file while somebody reads it is the ordinary case here.
*/
fun writeFile(
settings: ServerSettings,
@@ -770,7 +736,6 @@ fun createDir(settings: ServerSettings, setup: String, path: String) {
) {}
}
/** Fetches an image the transcript references (produced or uploaded). */
fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray =
requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) {
it.inputStream.readBytes()
@@ -779,10 +744,9 @@ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String):
// One rate-limit window, rendered as a labeled bar on the usage screen.
data class UsageWindow(
/**
* The API's own word for which window this is -- "session" for the five-hour one.
*
* How to find a particular window. The label beside it is written for a person to read, so
* matching on it would select nothing the day its wording changes.
* The API's own word for which window this is -- "session" for the five-hour one. The label
* beside it is written for a person to read, so matching on it would select nothing the day its
* wording changes.
*/
val kind: String,
val label: String,
@@ -801,8 +765,8 @@ data class UsageSnapshot(
* What came back: "ok", "notLoggedIn", "unreachable" or "failed".
*
* Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a
* machine somebody chose not to put an account on -- a fact, not a fault -- while the other two
* are faults worth chasing. Collapsing them made a healthy setup read as broken.
* machine somebody chose not to put an account on -- a fact, not a fault. Collapsing them made
* a healthy setup read as broken.
*/
val state: String,
/** Why, for the two states that are faults. Absent otherwise. */
@@ -818,8 +782,8 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
provider = snapshot.getString("provider"),
setup = snapshot.optString("setup"),
setupName = snapshot.optString("setupName"),
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok"
// would draw an empty card as a healthy one.
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok" would
// draw an empty card as a healthy one.
state = snapshot.optString("state").ifEmpty { "failed" },
detail = snapshot.optString("detail").ifEmpty { null },
windows =
@@ -840,8 +804,7 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
* Answers one question with everything that was chosen.
*
* A list even when one thing was picked, because that is the shape of the answer rather than a
* special case of it. What a provider makes of several answers is its own business and is decided
* on the server; nothing here joins, splits or reformats them for one.
* special case of it. What a provider makes of several answers is decided on the server.
*/
fun answerQuestion(
settings: ServerSettings,
@@ -868,9 +831,8 @@ fun interruptSession(settings: ServerSettings, sessionId: String) {
/**
* Ends the process behind a session, leaving the session and its transcript.
*
* Not a delete and not an interrupt: the conversation stays exactly where it is and [startSession]
* picks it back up. The server reports what it could not do -- there was nothing running, or the
* machine would not say whether there was -- rather than answering the same way either way.
* Not a delete and not an interrupt: the conversation stays where it is and [startSession] picks it
* back up. The server reports what it could not do rather than answering the same way either way.
*/
fun stopSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {}
@@ -888,12 +850,10 @@ fun startSession(settings: ServerSettings, sessionId: String) {
* caller confirms first; see ImportScreen.
*
* The work runs on the server, so this returning is not the same as it being done -- what says that
* is each row's own state, through [fetchImportable] and the change stream. That is the point:
* leaving the screen used to cancel the delete it had started.
* is each row's own state. That is the point: leaving the screen used to cancel the delete.
*
* One request for the whole batch, which is what makes a handover all-or-nothing. Sending one per
* row meant a batch could half-arrive -- four deleted, two never asked for -- and the two that were
* missed looked exactly like two that had not been picked.
* One request for the whole batch, which is what makes a handover all-or-nothing. One per row meant
* a batch could half-arrive, and the rows that were missed looked exactly like rows not picked.
*/
fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) {
requestFromServer(
@@ -910,8 +870,7 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<S
* Separate from [spawnSession] because the two are asked different questions. That one means "start
* this and take me to it", so it waits and answers with the session. This is the import list's
* batch: several at once, nobody waiting on any particular one, and the result arrives as a row
* changing rather than as a reply -- which is what lets the screen be left. One request for all of
* them, for the reason [deleteImportable] gives.
* changing -- which is what lets the screen be left.
*/
fun startImport(
settings: ServerSettings,
@@ -941,7 +900,7 @@ fun startImport(
*
* One request instead of one stream frame per event. The SSE stream is the right shape for live
* events and the wrong one for a backlog: opening an imported session replayed hundreds of frames
* before anything was readable, which looked exactly like the app loading top-down, because it was.
* before anything was readable, which looked exactly like the app loading top-down.
*
* [before] pages backwards for history somebody scrolls to; absent means the newest page.
*/
@@ -950,16 +909,14 @@ fun fetchTranscript(
sessionId: String,
before: Long? = null,
limit: Int = 80,
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page
// of a delta-heavy conversation is a page of the screen rather than a fraction of one message.
// The scroll-back pager wants this; the anchor restore does not (it counts events to a known
// seq). Ignored by the server for the newest window, where the live cursor needs real seqs.
// See the server's `read_window`.
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
// a delta-heavy conversation is a page of the screen rather than a fraction of one message. The
// scroll-back pager wants this; the anchor restore does not. Ignored by the server for the
// newest window, where the live cursor needs real seqs.
coalesce: Boolean = false,
// Return nothing at or below this seq, stopping the page here instead of at [limit]. The
// phone passes the end of the run it already holds cached, so a page never overlaps that copy
// -- an overlap it cannot store, since a coalesced event cannot be cut at a seq inside its own
// delta run. Exclusive, like the SSE route's cursor. See TranscriptCache and `read_window`.
// Return nothing at or below this seq, stopping the page here instead of at [limit]. The phone
// passes the end of the run it already holds cached, so a page never overlaps that copy -- an
// overlap it cannot store, since a coalesced event cannot be cut inside its own delta run.
after: Long? = null,
): List<Pair<String, SeqEvent>> {
val query = buildString {
@@ -970,9 +927,8 @@ fun fetchTranscript(
}
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
val body = JSONArray(connection.inputStream.bufferedReader().readText())
// The text as well as the event: the transcript cache stores the one and the fold needs
// the other, and they have to be the same line -- a second entry point differing only in
// return type would be two answers to one question.
// The text as well as the event: the transcript cache stores the one and the fold needs the
// other, and they have to be the same line.
(0 until body.length()).map {
val line = body.getJSONObject(it).toString()
line to parseSeqEvent(line)
@@ -986,7 +942,7 @@ fun fetchTranscript(
* The name is the backend's own -- it is what the list shows and it exists before any process does
* -- so this settles it rather than asking. Where the thing running the session has a name of its
* own, the backend passes it on, which is what makes a session the same session in Claude Code's
* picker and to any other agent that lists it.
* picker.
*/
fun renameSession(settings: ServerSettings, sessionId: String, title: String) {
requestFromServer(
@@ -1078,10 +1034,9 @@ fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Bo
requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {}
}
// Models: what this backend has downloaded, what it is downloading, and
// what HuggingFace offers. Browsing is proxied by the server rather than
// done here, because this app trusts exactly one certificate and has no
// general internet trust to spend on huggingface.co.
// Models: what this backend has downloaded, what it is downloading, and what HuggingFace offers.
// Browsing is proxied by the server rather than done here, because this app trusts exactly one
// certificate and has no general internet trust to spend on huggingface.co.
data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long)