1package soy.iko.opencode.ui.usage
3import androidx.compose.foundation.background
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.ExperimentalLayoutApi
9import androidx.compose.foundation.layout.FlowRow
10import androidx.compose.foundation.layout.PaddingValues
11import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.Spacer
13import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.height
16import androidx.compose.foundation.layout.imePadding
17import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.size
19import androidx.compose.foundation.layout.widthIn
20import androidx.compose.foundation.layout.wrapContentWidth
21import androidx.compose.foundation.lazy.LazyColumn
22import androidx.compose.foundation.lazy.items
23import androidx.compose.animation.Crossfade
24import androidx.compose.animation.core.tween
25import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
27import androidx.compose.material.icons.filled.Close
28import androidx.compose.material.icons.filled.CloudOff
29import androidx.compose.material.icons.filled.ErrorOutline
30import androidx.compose.material.icons.filled.QueryStats
31import androidx.compose.material.icons.filled.Refresh
32import androidx.compose.material.icons.filled.Search
33import androidx.compose.material.icons.filled.SearchOff
34import androidx.compose.material3.Card
35import androidx.compose.material3.ExperimentalMaterial3Api
36import androidx.compose.material3.FilterChip
37import androidx.compose.material3.Icon
38import androidx.compose.material3.IconButton
39import androidx.compose.material3.MaterialTheme
40import androidx.compose.material3.OutlinedTextField
41import androidx.compose.material3.Scaffold
42import androidx.compose.material3.SnackbarHost
43import androidx.compose.material3.SnackbarHostState
44import androidx.compose.material3.Text
45import androidx.compose.material3.pulltorefresh.PullToRefreshBox
46import androidx.compose.runtime.Composable
47import androidx.compose.runtime.getValue
48import androidx.compose.runtime.mutableStateOf
49import androidx.compose.runtime.remember
50import androidx.compose.runtime.saveable.rememberSaveable
51import androidx.compose.runtime.setValue
52import androidx.compose.ui.Alignment
53import androidx.compose.ui.Modifier
54import androidx.compose.ui.draw.clip
55import androidx.compose.ui.res.pluralStringResource
56import androidx.compose.ui.res.stringResource
57import androidx.compose.ui.semantics.Role
58import androidx.compose.ui.semantics.contentDescription
59import androidx.compose.ui.semantics.heading
60import androidx.compose.ui.semantics.invisibleToUser
61import androidx.compose.ui.semantics.semantics
62import androidx.compose.ui.text.style.TextOverflow
63import androidx.compose.ui.unit.dp
64import androidx.lifecycle.compose.collectAsStateWithLifecycle
65import androidx.lifecycle.viewmodel.compose.viewModel
66import soy.iko.opencode.R
67import soy.iko.opencode.data.model.Tokens
68import soy.iko.opencode.data.network.NetworkConfig
69import soy.iko.opencode.di.AppContainer
70import soy.iko.opencode.ui.components.AppTopBar
71import soy.iko.opencode.ui.components.ConnectionBannerFor
72import soy.iko.opencode.ui.components.EmptyState
73import soy.iko.opencode.ui.components.SectionHeader
74import soy.iko.opencode.ui.components.SkeletonRow
75import soy.iko.opencode.ui.components.reducedMotionAnimateItem
76import soy.iko.opencode.ui.vmFactory
77import java.text.NumberFormat
78import java.util.Locale
80@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
82fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (String) -> Unit = {}) {
83 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
84 val state by vm.state.collectAsStateWithLifecycle()
85 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
86 val snackbar = remember { SnackbarHostState() }
91 title = stringResource(R.string.usage_title),
94 IconButton(onClick = { vm.load() }) {
95 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
100 snackbarHost = { SnackbarHost(snackbar) },
103 isRefreshing = refreshing,
104 onRefresh = { vm.load() },
105 modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally).widthIn(max = soy.iko.opencode.data.network.NetworkConfig.listContentMaxWidthDp.dp).imePadding().padding(padding),
107 Box(modifier = Modifier.fillMaxSize()) {
108 ConnectionBannerFor(container)
109 // Crossfade between content states so transitions read as a smooth fade instead
110 // of an instant snap. Matches the session list's Crossfade pattern; reduced
111 // motion is honored by Crossfade's default spec.
112 val stateKey = state::class.simpleName
113 @Suppress("UnusedCrossfadeTargetStateParameter")
115 targetState = stateKey,
116 animationSpec = tween(NetworkConfig.motionFadeDurationMs.toInt()),
117 label = "usage_state",
119 when (val s = state) {
120 is UsageViewModel.State.Loading -> Column(
121 modifier = Modifier.fillMaxSize().padding(16.dp),
122 verticalArrangement = Arrangement.spacedBy(12.dp),
124 repeat(6) { SkeletonRow() }
126 is UsageViewModel.State.Disconnected -> EmptyState(
127 icon = Icons.Filled.CloudOff,
128 title = stringResource(R.string.not_connected),
129 modifier = Modifier.align(Alignment.Center),
131 is UsageViewModel.State.Error -> EmptyState(
132 icon = Icons.Filled.ErrorOutline,
133 title = stringResource(R.string.usage_failed),
134 description = s.message,
135 modifier = Modifier.align(Alignment.Center),
136 actionLabel = stringResource(R.string.retry),
137 onAction = { vm.load() },
139 is UsageViewModel.State.Ready ->
140 if (s.report.isEmpty) {
142 icon = Icons.Filled.QueryStats,
143 title = stringResource(R.string.usage_empty),
144 description = stringResource(R.string.usage_empty_desc),
145 modifier = Modifier.align(Alignment.Center),
148 val timeRange by vm.timeRange.collectAsStateWithLifecycle()
151 onOpenSession = onOpenSession,
152 timeRange = timeRange,
153 onTimeRangeChange = vm::setTimeRange,
163@OptIn(ExperimentalLayoutApi::class)
165private fun UsageContent(
167 onOpenSession: (String) -> Unit,
168 timeRange: UsageTimeRange,
169 onTimeRangeChange: (UsageTimeRange) -> Unit,
171 // Optional session-title filter. The global totals (cost/tokens/by-model) always reflect
172 // the whole history, but a user with many sessions can narrow the per-session list to find
173 // a specific conversation's spend without scrolling through all of it.
174 var sessionQuery by rememberSaveable { mutableStateOf("") }
175 val filteredSessions = remember(report.bySession, sessionQuery) {
176 val q = sessionQuery.trim()
177 if (q.isEmpty()) report.bySession
178 else report.bySession.filter { it.title.contains(q, ignoreCase = true) }
180 val ranges = remember {
182 UsageTimeRange.TWENTY_FOUR_HOURS to R.string.usage_range_24h,
183 UsageTimeRange.SEVEN_DAYS to R.string.usage_range_7d,
184 UsageTimeRange.THIRTY_DAYS to R.string.usage_range_30d,
185 UsageTimeRange.ALL_TIME to R.string.usage_range_all,
189 modifier = Modifier.fillMaxSize(),
190 contentPadding = PaddingValues(16.dp),
191 verticalArrangement = Arrangement.spacedBy(12.dp),
193 // Time-range filter: narrows the totals AND the breakdowns to the selected window, so
194 // "how much did I spend today?" is answerable instead of drowning in all-time history.
197 modifier = Modifier.fillMaxWidth(),
198 horizontalArrangement = Arrangement.spacedBy(8.dp),
200 ranges.forEach { (range, labelRes) ->
202 selected = timeRange == range,
203 onClick = { onTimeRangeChange(range) },
204 label = { Text(stringResource(labelRes)) },
209 item { TotalsCard(report, timeRange) }
210 item { TokenBreakdownCard(report.totalTokens) }
211 if (report.byModel.isNotEmpty()) {
212 item { SectionHeader(stringResource(R.string.usage_by_model), Modifier.semantics { heading() }) }
213 // Key on provider+model: the same model id served by two providers yields two
214 // rows, so keying on the bare model would duplicate keys and crash the list.
215 items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
217 title = m.provider + " / " + m.model,
220 messages = m.messages,
221 costFraction = fractionOf(m.cost, report.totalCost),
222 modifier = Modifier.then(reducedMotionAnimateItem()),
226 if (report.bySession.isNotEmpty()) {
227 item { SectionHeader(stringResource(R.string.usage_all_sessions), Modifier.semantics { heading() }) }
228 // Only show the filter field once there are enough sessions to justify it, so a
229 // short history isn't cluttered with a search bar.
230 if (report.bySession.size >= NetworkConfig.usageSessionSearchThreshold) {
231 item(key = "__session_search") {
233 value = sessionQuery,
234 onValueChange = { sessionQuery = it },
235 modifier = Modifier.fillMaxWidth(),
236 placeholder = { Text(stringResource(R.string.usage_filter_sessions)) },
238 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
240 if (sessionQuery.isNotEmpty()) {
241 IconButton(onClick = { sessionQuery = "" }) {
242 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
249 if (sessionQuery.isNotBlank() && filteredSessions.isEmpty()) {
250 item(key = "__no_match") {
252 icon = Icons.Filled.SearchOff,
253 title = stringResource(R.string.usage_no_sessions),
254 modifier = Modifier.fillMaxWidth().padding(24.dp),
258 items(filteredSessions, key = { it.sessionId }) { s ->
263 messages = s.messages,
264 costFraction = fractionOf(s.cost, report.totalCost),
265 onClick = { onOpenSession(s.sessionId) },
266 modifier = Modifier.then(reducedMotionAnimateItem()),
273private fun fractionOf(part: Double, total: Double): Float =
274 if (total > 0.0) (part / total).toFloat().coerceIn(0f, 1f) else 0f
276/** A slim horizontal bar showing this row's share of the total cost. */
278private fun CostBar(fraction: Float) {
279 val pct = (fraction * 100).toInt()
284 .clip(MaterialTheme.shapes.small)
285 .background(MaterialTheme.colorScheme.surfaceVariant)
287 contentDescription = "${pct}% of total"
294 .fillMaxWidth(fraction)
296 .clip(MaterialTheme.shapes.small)
297 .background(MaterialTheme.colorScheme.primary),
304private fun TotalsCard(report: UsageReport, timeRange: UsageTimeRange) {
305 val rangeLabelRes = when (timeRange) {
306 UsageTimeRange.TWENTY_FOUR_HOURS -> R.string.usage_range_24h
307 UsageTimeRange.SEVEN_DAYS -> R.string.usage_range_7d
308 UsageTimeRange.THIRTY_DAYS -> R.string.usage_range_30d
309 UsageTimeRange.ALL_TIME -> R.string.usage_range_all
311 Card(modifier = Modifier.fillMaxWidth()) {
312 Column(Modifier.padding(16.dp)) {
314 stringResource(R.string.usage_total_cost_with_range, stringResource(rangeLabelRes)),
315 style = MaterialTheme.typography.labelMedium,
316 color = MaterialTheme.colorScheme.onSurfaceVariant,
319 formatCost(report.totalCost),
320 style = MaterialTheme.typography.headlineMedium,
321 color = MaterialTheme.colorScheme.primary,
323 Spacer(Modifier.size(8.dp))
325 pluralStringResource(R.plurals.usage_messages_count, report.messageCount, report.messageCount),
326 style = MaterialTheme.typography.bodySmall,
327 color = MaterialTheme.colorScheme.onSurfaceVariant,
330 stringResource(R.string.usage_total_tokens) + ": " + formatInt(report.totalTokens.total),
331 style = MaterialTheme.typography.bodySmall,
332 color = MaterialTheme.colorScheme.onSurfaceVariant,
339private fun TokenBreakdownCard(tokens: Tokens) {
340 Card(modifier = Modifier.fillMaxWidth()) {
341 Column(Modifier.padding(16.dp)) {
342 StatRow(stringResource(R.string.usage_input), formatInt(tokens.input))
343 StatRow(stringResource(R.string.usage_output), formatInt(tokens.output))
344 if (tokens.reasoning > 0) StatRow(stringResource(R.string.usage_reasoning), formatInt(tokens.reasoning))
345 if (tokens.cache.read > 0 || tokens.cache.write > 0) {
346 StatRow(stringResource(R.string.usage_cache), formatInt(tokens.cache.read + tokens.cache.write))
353private fun StatRow(label: String, value: String) {
355 modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
356 horizontalArrangement = Arrangement.SpaceBetween,
358 Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
359 Text(value, style = MaterialTheme.typography.bodyMedium)
370 onClick: (() -> Unit)? = null,
371 modifier: Modifier = Modifier,
373 val rowModifier = modifier
375 .let { if (onClick != null) it.clickable(role = Role.Button, onClick = onClick) else it }
376 .padding(vertical = 6.dp)
377 Column(modifier = rowModifier) {
378 Row(verticalAlignment = Alignment.CenterVertically) {
379 Column(modifier = Modifier.weight(1f)) {
380 Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
382 pluralStringResource(R.plurals.usage_messages_count, messages, messages) + " · " + formatInt(tokens.total),
383 style = MaterialTheme.typography.labelSmall,
384 color = MaterialTheme.colorScheme.onSurfaceVariant,
389 style = MaterialTheme.typography.bodyMedium,
390 color = MaterialTheme.colorScheme.primary,
391 modifier = Modifier.padding(start = 12.dp),
393 // Chevron signals that tapping a session row navigates to it (mirrors the M3
394 // list-item pattern for navigable rows), so the affordance isn't hidden behind
395 // just the click ripple.
396 if (onClick != null) {
398 Icons.AutoMirrored.Filled.KeyboardArrowRight,
399 contentDescription = null,
400 tint = MaterialTheme.colorScheme.onSurfaceVariant,
401 modifier = Modifier.size(20.dp),
405 Spacer(Modifier.size(6.dp))
406 CostBar(costFraction)
410// Locale.US so amounts/counts format identically regardless of device locale (stable digits
411// and a dot decimal for a dollar figure). Sub-cent totals get more precision so a tiny run
412// doesn't collapse to "$0.00".
413private fun formatCost(cost: Double): String =
414 if (cost in 0.0..0.01) String.format(Locale.US, "$%.4f", cost)
415 else String.format(Locale.US, "$%.2f", cost)
417private val integerFormat: NumberFormat = NumberFormat.getIntegerInstance(Locale.US)
419private fun formatInt(n: Long): String = integerFormat.format(n)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list