Phase 3: usage screen

GET /usage serves the numbers behind Claude Code's /usage, read with the
CLI's own stored OAuth credentials (nothing to configure). The endpoint
is undocumented, so parsing is defensive -- the generic limits[] array
becomes labeled window bars, unknown kinds surface under their raw name,
and any failure degrades to an 'unavailable' snapshot with the reason.
One UsageProvider per paid service behind a caching monitor that
enforces the >=180s minimum poll regardless of phone refreshes; no
background polling at all. ureq (rustls) does the outbound call, with
the process-level CryptoProvider now chosen explicitly in main -- ureq
brings ring while axum-server brings aws-lc-rs, and with both in the
graph rustls refuses to guess.

App: a Usage screen off the session list -- per-window bars colored by
utilization with relative reset times. Verified live: 74%/26%/16%
windows rendered against the real endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-24 21:47:51 -04:00
1 parent f2430671a2
commit 3c97a5ef28
10 files changed
+606 -10

No files matched your search

@@ -195,6 +195,45 @@ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String):
it.inputStream.readBytes()
}
// One rate-limit window, rendered as a labeled bar on the usage screen.
data class UsageWindow(
val label: String,
val percent: Double,
val resetsAt: String?,
val active: Boolean,
)
data class UsageSnapshot(
val provider: String,
val available: Boolean,
val windows: List<UsageWindow>,
val error: String?,
)
/** The backend caches; refreshing more often than its poll interval just re-reads the cache. */
fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
requestFromServer(settings, "/usage", readTimeoutMs = 30000) { connection ->
val snapshots = JSONArray(connection.inputStream.bufferedReader().readText())
(0 until snapshots.length()).map { i ->
val snapshot = snapshots.getJSONObject(i)
val windows = snapshot.getJSONArray("windows")
UsageSnapshot(
provider = snapshot.getString("provider"),
available = snapshot.getBoolean("available"),
error = snapshot.optString("error").ifEmpty { null },
windows = (0 until windows.length()).map { j ->
val window = windows.getJSONObject(j)
UsageWindow(
label = window.getString("label"),
percent = window.getDouble("percent"),
resetsAt = window.optString("resetsAt").ifEmpty { null },
active = window.getBoolean("active"),
)
},
)
}
}
fun answerQuestion(settings: ServerSettings, sessionId: String, questionId: String, answer: String) {
requestFromServer(
settings,
@@ -16,6 +16,7 @@ private sealed class Screen {
data object SessionList : Screen()
data class Session(val summary: SessionSummary) : Screen()
data object Spawn : Screen()
data object Usage : Screen()
data object Settings : Screen()
}
@@ -54,6 +55,7 @@ fun AppRoot(settingsVersion: Int) {
reloadToken = reloadToken,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onUsage = { screen = Screen.Usage },
onSettings = { screen = Screen.Settings },
)
is Screen.Session -> {
@@ -81,6 +83,10 @@ fun AppRoot(settingsVersion: Int) {
onBack = { screen = Screen.SessionList },
)
}
is Screen.Usage -> {
BackHandler { screen = Screen.SessionList }
UsageScreen(settings = current, onBack = { screen = Screen.SessionList })
}
is Screen.Settings -> {
BackHandler { screen = Screen.SessionList }
SettingsScreen(
@@ -56,6 +56,7 @@ fun SessionListScreen(
reloadToken: Int,
onOpen: (SessionSummary) -> Unit,
onSpawn: () -> Unit,
onUsage: () -> Unit,
onSettings: () -> Unit,
) {
val scope = rememberCoroutineScope()
@@ -83,6 +84,7 @@ fun SessionListScreen(
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onUsage) { Text("Usage") }
TextButton(onClick = onSettings) { Text("Settings") }
TextButton(onClick = { refresh() }) { Text("Refresh") }
}
@@ -0,0 +1,145 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
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.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.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Duration
import java.time.OffsetDateTime
private val WARN_COLOR = Color(0xFFB26A00)
private val OVER_COLOR = Color(0xFFB3261E)
private sealed class UsageState {
data object Loading : UsageState()
data class Loaded(val snapshots: List<UsageSnapshot>) : UsageState()
data class Error(val message: String) : UsageState()
}
/** Window bars for the account's rate limits, with reset times. */
@Composable
fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<UsageState>(UsageState.Loading) }
fun refresh() {
state = UsageState.Loading
scope.launch {
state = try {
withContext(Dispatchers.IO) { UsageState.Loaded(fetchUsage(settings)) }
} catch (e: ApiException) {
UsageState.Error(e.message ?: "Unknown error")
}
}
}
LaunchedEffect(Unit) { refresh() }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
"Usage",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack) { Text("Back") }
TextButton(onClick = { refresh() }) { Text("Refresh") }
}
Spacer(Modifier.height(16.dp))
when (val current = state) {
is UsageState.Loading -> CircularProgressIndicator()
is UsageState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is UsageState.Loaded -> current.snapshots.forEach { snapshot ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(snapshot.provider, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
if (!snapshot.available) {
Text(
snapshot.error ?: "Unavailable",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
snapshot.windows.forEach { window ->
WindowBar(window)
Spacer(Modifier.height(12.dp))
}
}
}
Spacer(Modifier.height(12.dp))
}
}
}
}
@Composable
private fun WindowBar(window: UsageWindow) {
val color = when {
window.percent >= 95 -> OVER_COLOR
window.percent >= 75 -> WARN_COLOR
else -> MaterialTheme.colorScheme.primary
}
Column {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
window.label + if (window.active) " (active)" else "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.height(4.dp))
LinearProgressIndicator(
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
color = color,
modifier = Modifier.fillMaxWidth(),
)
window.resetsAt?.let {
Spacer(Modifier.height(2.dp))
Text(
"resets ${formatReset(it)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** "in 3h 12m" -- close enough for deciding whether to start a big task. */
private fun formatReset(resetsAt: String): String = try {
val until = Duration.between(OffsetDateTime.now(), OffsetDateTime.parse(resetsAt))
when {
until.isNegative -> "soon"
until.toHours() >= 24 -> "in ${until.toDays()}d ${until.toHours() % 24}h"
until.toHours() > 0 -> "in ${until.toHours()}h ${until.toMinutes() % 60}m"
else -> "in ${until.toMinutes()}m"
}
} catch (_: Exception) {
"at $resetsAt"
}