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 e3c48d1..49ad60a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -111,9 +111,16 @@ private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Chars data class SessionSummary( val id: String, /** - * The machine's current label. The id is deliberately not carried: nothing here addresses a - * setup, and holding both invites showing the wrong one, which is what happened. + * 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. */ + val setup: String, + /** The machine's current label. This is the one to display; [setup] is never shown. */ val setupName: String, val provider: String, val title: String, @@ -131,6 +138,7 @@ data class SessionSummary( private fun parseSession(session: JSONObject) = SessionSummary( id = session.getString("id"), + setup = session.getString("setup"), setupName = session.getString("setupName"), provider = session.getString("provider"), title = session.getString("title"), @@ -410,6 +418,13 @@ 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. + */ + val kind: String, val label: String, val percent: Double, val resetsAt: String?, @@ -450,6 +465,7 @@ fun fetchUsage(settings: ServerSettings): List = windows = snapshot.getJSONArray("windows").mapObjects { window -> UsageWindow( + kind = window.optString("kind").ifEmpty { "unknown" }, label = window.getString("label"), percent = window.getDouble("percent"), resetsAt = window.optString("resetsAt").ifEmpty { null }, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt index 82ac0de..fe2a7a6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt @@ -47,6 +47,11 @@ val SESSION_COMMANDS = "Summarise the conversation so far and carry on from the summary", null, ), + SessionCommand( + "/clear", + "Start fresh: drop the conversation from the session's context, keeping it on screen", + null, + ), SessionCommand("/rename", "Change what this session is called", "name"), ) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt index 6f445cc..f18ef79 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt @@ -8,25 +8,19 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp /** * The mark a compaction leaves in the transcript. * - * Centred and dim, because it is a divider rather than something anybody said: everything above it - * is out of the session's context now, and that is a fact about the conversation, not a turn in it. - * It has no collapsed form -- it is already one line, and there is nothing behind it to open. + * A divider rather than something anybody said: everything above it is out of the session's context + * now, and that is a fact about the conversation, not a turn in it. It has no collapsed form -- it + * is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which + * a clear also uses, so the two marks cannot drift apart. */ @Composable fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) { - Text( - compactionSummary(item), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = modifier.fillMaxWidth().padding(vertical = 8.dp), - ) + TranscriptDivider(compactionSummary(item), modifier) } /** diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt new file mode 100644 index 0000000..8f765f4 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt @@ -0,0 +1,47 @@ +package com.example.aiapp + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * A line across the transcript saying what left the session's context. + * + * Centred and dim, because it is a divider rather than something anybody said. Two things produce + * one -- a compaction and a clear -- and they look the same on purpose: to a reader scrolling back, + * both mean "the session no longer has what is above this", and the difference between summarised + * and dropped is what the words say, not how they are drawn. + * + * Written once here rather than styled at each of them, so the two cannot drift into looking like + * different kinds of thing. + */ +@Composable +fun TranscriptDivider(text: String, modifier: Modifier = Modifier) { + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = modifier.fillMaxWidth().padding(vertical = 8.dp), + ) +} + +/** + * The mark a clear leaves. + * + * It says the conversation is still here, because the reader can see that it is -- everything above + * stays on screen and stays scrollable, and the only thing that changed is what the session will + * send. Without that sentence the divider reads as a deletion, which is the one thing it is not. + * + * No counts, unlike a compaction: nothing was measured and nothing was summarised, so there is + * nothing to report but the fact. A plausible-looking number here would be invented. + */ +@Composable +fun ClearedRow(modifier: Modifier = Modifier) { + TranscriptDivider("Cleared -- everything above stays here, and is no longer sent", modifier) +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt index 92b6d9e..158dcce 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt @@ -97,6 +97,15 @@ sealed class SessionEvent { val trigger: String?, ) : SessionEvent() + /** + * The conversation was cleared. Everything above this is still here to read and is no longer in + * the session's context. + * + * An object rather than a class because it carries nothing: what it means is entirely its + * position in the transcript. + */ + data object Cleared : SessionEvent() + data class Error(val message: String) : SessionEvent() /** @@ -171,6 +180,7 @@ fun parseSeqEvent(json: String): SeqEvent { postTokens = if (body.has("postTokens")) body.getLong("postTokens") else null, trigger = body.optString("trigger").ifEmpty { null }, ) + "cleared" -> SessionEvent.Cleared "error" -> SessionEvent.Error(body.getString("message")) else -> SessionEvent.Unknown(type) } 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 f207dc5..48be157 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -161,6 +161,13 @@ sealed class TranscriptItem { * it finishes and this is the part worth keeping: it is the explanation for a gap in the * conversation, and for a minute or two in which the session was busy with nothing to show. */ + /** + * A clear that happened: everything above it left the session's context and stayed on screen. + * + * Carries only its position, because that is all it means. + */ + data class ClearedNote(override val seq: Long) : TranscriptItem() + data class CompactedNote( override val seq: Long, val preTokens: Long?, @@ -277,6 +284,7 @@ fun foldEvent(items: List, entry: SeqEvent): List items + TranscriptItem.ClearedNote(entry.seq) is SessionEvent.Compacted -> items + TranscriptItem.CompactedNote( @@ -726,7 +734,6 @@ fun SessionScreen( // What the session says it is set to now, which is the same fact // the picker below shows and has to be the same answer. model?.let { modelLabel(it) }, - if (totalTokens > 0) "$totalTokens tok" else null, ) .joinToString(" ยท "), style = MaterialTheme.typography.bodySmall, @@ -753,6 +760,11 @@ fun SessionScreen( } } + // Under the header, above everything the session itself says: it is a fact about the + // machine rather than a turn in the conversation, and it is the number that decides + // whether to keep going -- which was a screen away from where that gets decided. + SessionUsageBar(settings = settings, setup = summary.setup) + (streamError ?: actionError)?.let { message -> Text( message, @@ -917,6 +929,7 @@ fun SessionScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) is TranscriptItem.CommandRow -> CommandBubble(item.text) + is TranscriptItem.ClearedNote -> ClearedRow() is TranscriptItem.CompactedNote -> CompactedRow(item) is TranscriptItem.PeerNote -> PeerMessageRow( @@ -934,6 +947,23 @@ fun SessionScreen( } } + // Bottom right of the transcript, pinned rather than scrolled: it reports on the + // conversation as a whole, so it should not be a thing you scroll away from and then + // wonder about. Sat in the header until 2026-08-29, where it was one item in a run of + // dot-separated facts about the session and read as another of them, rather than as + // the running total it is. + // + // BottomEnd, clear of the jump-to-latest chevron at BottomCentre. + if (totalTokens > 0) { + Text( + "$totalTokens tok", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier.align(Alignment.BottomEnd).padding(end = 12.dp, bottom = 4.dp), + ) + } + // Only while the newest message is off-screen. Reading back // through a conversation is a place to be, not a state to be // rescued from, so this waits to be wanted. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt new file mode 100644 index 0000000..9360669 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -0,0 +1,130 @@ +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.foundation.layout.width +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.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +/** How much of the five-hour window is gone, or why that isn't known. */ +sealed class FiveHourUsage { + /** Nothing has come back yet. Distinct from every answer, including an empty one. */ + data object Waiting : FiveHourUsage() + + data class Known(val percent: Double, val resetsAt: String?) : FiveHourUsage() + + /** + * 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) : FiveHourUsage() +} + +/** 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 + +/** + * The five-hour window for the machine this session runs on, under the session's own header. + * + * Here rather than only on the usage screen 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 usage screen 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(settings: ServerSettings, setup: String, modifier: Modifier = Modifier) { + var usage by remember(setup) { mutableStateOf(FiveHourUsage.Waiting) } + + LaunchedEffect(setup) { + while (true) { + usage = + try { + val snapshots = withContext(Dispatchers.IO) { fetchUsage(settings) } + fiveHourFor(snapshots, setup) + } catch (e: ApiException) { + FiveHourUsage.Unavailable(e.message ?: "couldn't reach the backend") + } + delay(REFRESH_MS) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp), + ) { + when (val state = usage) { + // Words, not a colour and not an empty bar: "couldn't check" is a different kind of + // answer from "this much is used", and only words carry a difference in kind. + is FiveHourUsage.Unavailable -> + Text( + "5-hour usage unknown -- ${state.why}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FiveHourUsage.Waiting -> + Text( + "5-hour usage: checking", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + is FiveHourUsage.Known -> { + LinearProgressIndicator( + progress = { (state.percent / 100.0).toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.weight(1f), + ) + Text( + " ${state.percent.toInt()}% of 5h", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(84.dp), + ) + } + } + } +} + +/** + * The five-hour window for one machine, out of every machine's snapshot. + * + * Selects the window by `kind`, which is the API's own word, rather than by the label beside it -- + * the label is written to be read and would stop matching the day its wording changes, silently + * leaving the bar with nothing to show. + * + * Every way of not having a number is [FiveHourUsage.Unavailable] with the reason in it: a machine + * nobody logged into, one that could not be reached, a snapshot that came back without the window. + * None of them may look like zero. + */ +fun fiveHourFor(snapshots: List, setup: String): FiveHourUsage { + val mine = snapshots.firstOrNull { it.setup == setup } + if (mine == null) { + return FiveHourUsage.Unavailable("this machine reports no usage") + } + if (mine.state != "ok") { + return FiveHourUsage.Unavailable(mine.detail ?: mine.state) + } + val window = + mine.windows.firstOrNull { it.kind == "session" } + ?: return FiveHourUsage.Unavailable("no five-hour window reported") + return FiveHourUsage.Known(window.percent, window.resetsAt) +} diff --git a/server/src/usage.rs b/server/src/usage.rs index a731eb6..d43771c 100644 --- a/server/src/usage.rs +++ b/server/src/usage.rs @@ -53,6 +53,17 @@ const USER_AGENT: &str = "claude-code/2.1.237"; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct UsageWindow { + /// The API's own word for which window this is -- `session` for the + /// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind + /// it starts sending. + /// + /// Carried beside the label because a caller that wants one + /// particular window has to be able to ask for it without matching on + /// display text: the label is written for a person, is translated the + /// moment anybody translates this app, and would silently select + /// nothing the day it changes. The session screen's bar picks + /// `session` by this field. + pub kind: String, pub label: String, /// 0-100. pub percent: f64, @@ -266,6 +277,7 @@ fn parse_windows(body: &Value) -> Vec { (other, None) => other.to_string(), }; Some(UsageWindow { + kind: kind.to_string(), label, percent, resets_at: limit