A Rust backend that owns the sessions and an Android app that reads them. The server spawns and adopts CLI processes, normalises everything they emit into one event model, keeps the transcript, and serves it over pinned TLS on a WireGuard interface; the phone streams that, replies, sends images, and imports conversations the machine already has. `AGENTS.md` is the working guide -- what runs where, what has been measured, and the faults that were expensive to find. `PLAN.md` is the design record. History before this point was squashed away. It was a personal project's running commentary and carried a name and a couple of machine paths that have no business in a public repository; the tree is what mattered and the tree is here.
225 lines
10 KiB
Kotlin
225 lines
10 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.material3.LinearProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.unit.dp
|
|
import java.time.Duration
|
|
import java.time.OffsetDateTime
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/** What one machine's rate limits came back as, or why they didn't. */
|
|
sealed class SessionUsage {
|
|
/** Nothing has come back yet. Distinct from every answer, including an empty one. */
|
|
data object Waiting : SessionUsage()
|
|
|
|
/** Every window the machine reported, in the order it reported them. */
|
|
data class Known(val windows: List<UsageWindow>) : SessionUsage()
|
|
|
|
/**
|
|
* This machine meters nothing, so there is no window to show.
|
|
*
|
|
* Separate from [Unavailable], and the distinction is the whole point: a session on `echo` or
|
|
* on a local llama.cpp has no paid quota at all, which is a fact about how it was set up and
|
|
* not a failure to find something out. The backend never asks such a machine, so it returns no
|
|
* snapshot for it -- and reading that silence as "couldn't find out" is exactly the mistake of
|
|
* answering with the nearest available word. Drawn as nothing, because there is nothing.
|
|
*/
|
|
data object NotMetered : SessionUsage()
|
|
|
|
/**
|
|
* The question could not be answered, and why.
|
|
*
|
|
* Its own state because "we couldn't find out" and "none of it is used" are the pair that must
|
|
* never share an appearance: a bar sitting at zero because a machine is unreachable reads as
|
|
* plenty of headroom, which is the opposite of the truth.
|
|
*/
|
|
data class Unavailable(val why: String) : SessionUsage()
|
|
}
|
|
|
|
/** How often to ask again. The backend caches, so this re-reads its cache rather than the API. */
|
|
private const val REFRESH_MS = 60_000L
|
|
|
|
/**
|
|
* One machine's rate limits, polled.
|
|
*
|
|
* Hoisted out of [SessionUsageBar] because two things on a session's screen show this same answer
|
|
* -- the bar, and the colour of the button that opens the usage dialog. Fetching it twice would
|
|
* cost two round trips to say one thing, and the two copies would disagree for up to a minute at a
|
|
* time, which is the interface contradicting itself about a number somebody is deciding on.
|
|
*/
|
|
@Composable
|
|
fun rememberSessionUsage(settings: ServerSettings, setup: String): SessionUsage {
|
|
var usage by remember(setup) { mutableStateOf<SessionUsage>(SessionUsage.Waiting) }
|
|
LaunchedEffect(setup) {
|
|
while (true) {
|
|
usage =
|
|
try {
|
|
usageFor(withContext(Dispatchers.IO) { fetchUsage(settings) }, setup)
|
|
} catch (e: ApiException) {
|
|
SessionUsage.Unavailable(e.message ?: "couldn't reach the backend")
|
|
}
|
|
delay(REFRESH_MS)
|
|
}
|
|
}
|
|
return usage
|
|
}
|
|
|
|
/**
|
|
* The colour for a control that reports on [usage] as a whole: the worst window's.
|
|
*
|
|
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
|
|
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
|
|
* Taken over however many windows came back rather than the three Claude sends today -- the backend
|
|
* deliberately passes windows it does not recognise straight through, so a fourth one is a thing
|
|
* that happens rather than a thing to notice later.
|
|
*
|
|
* Every state that is not a measurement takes the ordinary control colour instead. That is the
|
|
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
|
|
* unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind
|
|
* the button is where those say, in words, which one they are.
|
|
*/
|
|
@Composable
|
|
fun usageGlyphColour(usage: SessionUsage): Color =
|
|
when (usage) {
|
|
is SessionUsage.Known ->
|
|
usage.windows.maxOfOrNull { it.percent }?.let { quotaColor(it) }
|
|
?: MaterialTheme.colorScheme.primary
|
|
else -> MaterialTheme.colorScheme.primary
|
|
}
|
|
|
|
/**
|
|
* The five-hour window for the machine this session runs on, under the session's own header.
|
|
*
|
|
* Here rather than only in the usage dialog because it is the number that decides whether to keep
|
|
* going, and it was a screen away from the place that decision gets made. It reports on this
|
|
* session's machine alone -- the dialog is still where every machine is compared.
|
|
*
|
|
* What it shows is the paid service's own metering, fetched from the machine that holds the
|
|
* account. It is never derived from what this app has watched go past: the transcript's token
|
|
* counts are a different quantity, measured differently, and a bar shaped like a quota gauge built
|
|
* out of them would be a guess wearing a measurement's clothes.
|
|
*/
|
|
@Composable
|
|
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|
DebugStats.count("usage bar recomposed")
|
|
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
|
|
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
|
|
// value, Compose skips the recomposition, and a "left" that only ticked when the quota
|
|
// happened to move would sit at a stale figure for hours.
|
|
var now by remember { mutableStateOf(OffsetDateTime.now()) }
|
|
LaunchedEffect(Unit) {
|
|
while (true) {
|
|
delay(REFRESH_MS)
|
|
now = OffsetDateTime.now()
|
|
}
|
|
}
|
|
|
|
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would
|
|
// report a problem about a setup somebody chose, on every screen, forever.
|
|
if (usage is SessionUsage.NotMetered) {
|
|
return
|
|
}
|
|
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp),
|
|
) {
|
|
// Words, not a colour and not an empty bar: every one of these is a different kind of
|
|
// answer from "this much is used", and only words carry a difference in kind.
|
|
when (val state = usage) {
|
|
SessionUsage.NotMetered -> Unit
|
|
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
|
|
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
|
|
is SessionUsage.Known -> {
|
|
val window = state.windows.firstOrNull { it.kind == "session" }
|
|
if (window == null) {
|
|
UsageNote("5-hour usage unknown -- no five-hour window reported")
|
|
} else {
|
|
LinearProgressIndicator(
|
|
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
|
// The same step at the same percentages as the dialog's bars: this is the
|
|
// same measurement, and a reader who learned the colour there has to be
|
|
// able to read it here without checking which screen they are on.
|
|
color = quotaColor(window.percent),
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
Text(
|
|
fiveHourLabel(window, now),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.padding(start = 8.dp),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Anything this row says instead of drawing a bar, so all of them look the same. */
|
|
@Composable
|
|
private fun UsageNote(text: String) {
|
|
Text(
|
|
text,
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* "42% -- 2h 15m left": how much is gone, then how long what is left has to last.
|
|
*
|
|
* The percentage on its own does not answer the question it gets asked, which is whether to start
|
|
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
|
|
*
|
|
* The window's end has two missing cases and they are worded differently on purpose; see
|
|
* [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is
|
|
* no countdown to report and inventing one would be the same fault as inventing the number.
|
|
*/
|
|
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
|
val percent = "${window.percent.toInt()}%"
|
|
return when (val end = windowEnd(window.resetsAt, now)) {
|
|
// Between blocks the five-hour window has no reset time, and saying so is a fact about
|
|
// nothing: there is no window to run out. The percentage is the whole answer.
|
|
WindowEnd.NotRunning -> percent
|
|
WindowEnd.Unreadable -> "$percent · reset time unreadable"
|
|
is WindowEnd.Ends ->
|
|
// Under a minute, including past the end: the number would round to "0m left", which
|
|
// reads as a measurement rather than as the window having run out.
|
|
if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon"
|
|
else "$percent · ${formatSpan(end.until)} left"
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One machine's snapshot, out of every machine's.
|
|
*
|
|
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it:
|
|
* a machine nobody logged into, one that could not be reached, a snapshot that came back empty.
|
|
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
|
* machine having no quota rather than the question going unanswered.
|
|
*/
|
|
fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage {
|
|
// No snapshot at all means the backend never asked, which it only does for a machine with
|
|
// nothing metered on it. That is a different answer from having asked and failed.
|
|
val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered
|
|
if (mine.state != "ok") {
|
|
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
|
}
|
|
return SessionUsage.Known(mine.windows)
|
|
}
|