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
@@ -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