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:
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"
|
||||
}
|
||||
Reference in new issue
Block a user