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.PaddingValues
9import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.Spacer
11import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.height
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.lazy.LazyColumn
17import androidx.compose.foundation.lazy.items
18import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
20import androidx.compose.material.icons.filled.Close
21import androidx.compose.material.icons.filled.CloudOff
22import androidx.compose.material.icons.filled.ErrorOutline
23import androidx.compose.material.icons.filled.QueryStats
24import androidx.compose.material.icons.filled.Refresh
25import androidx.compose.material.icons.filled.Search
26import androidx.compose.material.icons.filled.SearchOff
27import androidx.compose.material3.Card
28import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.ExperimentalMaterial3Api
30import androidx.compose.material3.Icon
31import androidx.compose.material3.IconButton
32import androidx.compose.material3.MaterialTheme
33import androidx.compose.material3.OutlinedTextField
34import androidx.compose.material3.Scaffold
35import androidx.compose.material3.SnackbarHost
36import androidx.compose.material3.SnackbarHostState
37import androidx.compose.material3.Text
38import androidx.compose.material3.pulltorefresh.PullToRefreshBox
39import androidx.compose.runtime.Composable
40import androidx.compose.runtime.getValue
41import androidx.compose.runtime.mutableStateOf
42import androidx.compose.runtime.remember
43import androidx.compose.runtime.saveable.rememberSaveable
44import androidx.compose.runtime.setValue
45import androidx.compose.ui.Alignment
46import androidx.compose.ui.Modifier
47import androidx.compose.ui.draw.clip
48import androidx.compose.ui.res.pluralStringResource
49import androidx.compose.ui.res.stringResource
50import androidx.compose.ui.semantics.Role
51import androidx.compose.ui.semantics.contentDescription
52import androidx.compose.ui.semantics.heading
53import androidx.compose.ui.semantics.invisibleToUser
54import androidx.compose.ui.semantics.semantics
55import androidx.compose.ui.text.style.TextOverflow
56import androidx.compose.ui.unit.dp
57import androidx.lifecycle.compose.collectAsStateWithLifecycle
58import androidx.lifecycle.viewmodel.compose.viewModel
59import soy.iko.opencode.R
60import soy.iko.opencode.data.model.Tokens
61import soy.iko.opencode.data.network.NetworkConfig
62import soy.iko.opencode.di.AppContainer
63import soy.iko.opencode.ui.components.AppTopBar
64import soy.iko.opencode.ui.components.ConnectionBannerFor
65import soy.iko.opencode.ui.components.EmptyState
66import soy.iko.opencode.ui.components.SectionHeader
67import soy.iko.opencode.ui.vmFactory
68import java.text.NumberFormat
69import java.util.Locale
71@OptIn(ExperimentalMaterial3Api::class)
73fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (String) -> Unit = {}) {
74 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
75 val state by vm.state.collectAsStateWithLifecycle()
76 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
77 val snackbar = remember { SnackbarHostState() }
82 title = stringResource(R.string.usage_title),
85 IconButton(onClick = { vm.load() }) {
86 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
91 snackbarHost = { SnackbarHost(snackbar) },
94 isRefreshing = refreshing,
95 onRefresh = { vm.load() },
96 modifier = Modifier.fillMaxSize().padding(padding),
98 Box(modifier = Modifier.fillMaxSize()) {
99 ConnectionBannerFor(container)
100 val loadingLabel = stringResource(R.string.usage_loading)
101 when (val s = state) {
102 is UsageViewModel.State.Loading -> Centered {
103 CircularProgressIndicator(
104 Modifier.semantics { contentDescription = loadingLabel },
106 Spacer(Modifier.size(12.dp))
107 Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
109 is UsageViewModel.State.Disconnected -> EmptyState(
110 icon = Icons.Filled.CloudOff,
111 title = stringResource(R.string.not_connected),
112 modifier = Modifier.align(Alignment.Center),
114 is UsageViewModel.State.Error -> EmptyState(
115 icon = Icons.Filled.ErrorOutline,
116 title = stringResource(R.string.usage_failed),
117 description = s.message,
118 modifier = Modifier.align(Alignment.Center),
119 actionLabel = stringResource(R.string.retry),
120 onAction = { vm.load() },
122 is UsageViewModel.State.Ready ->
123 if (s.report.isEmpty) {
125 icon = Icons.Filled.QueryStats,
126 title = stringResource(R.string.usage_empty),
127 description = stringResource(R.string.usage_empty_desc),
128 modifier = Modifier.align(Alignment.Center),
131 UsageContent(s.report, onOpenSession)
140private fun Centered(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {
141 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
142 Column(horizontalAlignment = Alignment.CenterHorizontally, content = content)
147private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
148 // Optional session-title filter. The global totals (cost/tokens/by-model) always reflect
149 // the whole history, but a user with many sessions can narrow the per-session list to find
150 // a specific conversation's spend without scrolling through all of it.
151 var sessionQuery by rememberSaveable { mutableStateOf("") }
152 val filteredSessions = remember(report.bySession, sessionQuery) {
153 val q = sessionQuery.trim()
154 if (q.isEmpty()) report.bySession
155 else report.bySession.filter { it.title.contains(q, ignoreCase = true) }
158 modifier = Modifier.fillMaxSize(),
159 contentPadding = PaddingValues(16.dp),
160 verticalArrangement = Arrangement.spacedBy(12.dp),
162 item { TotalsCard(report) }
163 item { TokenBreakdownCard(report.totalTokens) }
164 if (report.byModel.isNotEmpty()) {
165 item { SectionHeader(stringResource(R.string.usage_by_model), Modifier.semantics { heading() }) }
166 // Key on provider+model: the same model id served by two providers yields two
167 // rows, so keying on the bare model would duplicate keys and crash the list.
168 items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
170 title = m.provider + " / " + m.model,
173 messages = m.messages,
174 costFraction = fractionOf(m.cost, report.totalCost),
178 if (report.bySession.isNotEmpty()) {
179 item { SectionHeader(stringResource(R.string.usage_all_sessions), Modifier.semantics { heading() }) }
180 // Only show the filter field once there are enough sessions to justify it, so a
181 // short history isn't cluttered with a search bar.
182 if (report.bySession.size >= NetworkConfig.usageSessionSearchThreshold) {
183 item(key = "__session_search") {
185 value = sessionQuery,
186 onValueChange = { sessionQuery = it },
187 modifier = Modifier.fillMaxWidth(),
188 placeholder = { Text(stringResource(R.string.usage_filter_sessions)) },
190 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
192 if (sessionQuery.isNotEmpty()) {
193 IconButton(onClick = { sessionQuery = "" }) {
194 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
201 if (sessionQuery.isNotBlank() && filteredSessions.isEmpty()) {
202 item(key = "__no_match") {
204 icon = Icons.Filled.SearchOff,
205 title = stringResource(R.string.usage_no_sessions),
206 modifier = Modifier.fillMaxWidth().padding(24.dp),
210 items(filteredSessions, key = { it.sessionId }) { s ->
215 messages = s.messages,
216 costFraction = fractionOf(s.cost, report.totalCost),
217 onClick = { onOpenSession(s.sessionId) },
224private fun fractionOf(part: Double, total: Double): Float =
225 if (total > 0.0) (part / total).toFloat().coerceIn(0f, 1f) else 0f
227/** A slim horizontal bar showing this row's share of the total cost. */
229private fun CostBar(fraction: Float) {
230 val pct = (fraction * 100).toInt()
235 .clip(MaterialTheme.shapes.small)
236 .background(MaterialTheme.colorScheme.surfaceVariant)
238 contentDescription = "${pct}% of total"
245 .fillMaxWidth(fraction)
247 .clip(MaterialTheme.shapes.small)
248 .background(MaterialTheme.colorScheme.primary),
255private fun TotalsCard(report: UsageReport) {
256 Card(modifier = Modifier.fillMaxWidth()) {
257 Column(Modifier.padding(16.dp)) {
259 stringResource(R.string.usage_total_cost),
260 style = MaterialTheme.typography.labelMedium,
261 color = MaterialTheme.colorScheme.onSurfaceVariant,
264 formatCost(report.totalCost),
265 style = MaterialTheme.typography.headlineMedium,
266 color = MaterialTheme.colorScheme.primary,
268 Spacer(Modifier.size(8.dp))
270 pluralStringResource(R.plurals.usage_messages_count, report.messageCount, report.messageCount),
271 style = MaterialTheme.typography.bodySmall,
272 color = MaterialTheme.colorScheme.onSurfaceVariant,
275 stringResource(R.string.usage_total_tokens) + ": " + formatInt(report.totalTokens.total),
276 style = MaterialTheme.typography.bodySmall,
277 color = MaterialTheme.colorScheme.onSurfaceVariant,
284private fun TokenBreakdownCard(tokens: Tokens) {
285 Card(modifier = Modifier.fillMaxWidth()) {
286 Column(Modifier.padding(16.dp)) {
287 StatRow(stringResource(R.string.usage_input), formatInt(tokens.input))
288 StatRow(stringResource(R.string.usage_output), formatInt(tokens.output))
289 if (tokens.reasoning > 0) StatRow(stringResource(R.string.usage_reasoning), formatInt(tokens.reasoning))
290 if (tokens.cache.read > 0 || tokens.cache.write > 0) {
291 StatRow(stringResource(R.string.usage_cache), formatInt(tokens.cache.read + tokens.cache.write))
298private fun StatRow(label: String, value: String) {
300 modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
301 horizontalArrangement = Arrangement.SpaceBetween,
303 Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
304 Text(value, style = MaterialTheme.typography.bodyMedium)
315 onClick: (() -> Unit)? = null,
317 val rowModifier = Modifier
319 .let { if (onClick != null) it.clickable(role = Role.Button, onClick = onClick) else it }
320 .padding(vertical = 6.dp)
321 Column(modifier = rowModifier) {
322 Row(verticalAlignment = Alignment.CenterVertically) {
323 Column(modifier = Modifier.weight(1f)) {
324 Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
326 pluralStringResource(R.plurals.usage_messages_count, messages, messages) + " · " + formatInt(tokens.total),
327 style = MaterialTheme.typography.labelSmall,
328 color = MaterialTheme.colorScheme.onSurfaceVariant,
333 style = MaterialTheme.typography.bodyMedium,
334 color = MaterialTheme.colorScheme.primary,
335 modifier = Modifier.padding(start = 12.dp),
337 // Chevron signals that tapping a session row navigates to it (mirrors the M3
338 // list-item pattern for navigable rows), so the affordance isn't hidden behind
339 // just the click ripple.
340 if (onClick != null) {
342 Icons.AutoMirrored.Filled.KeyboardArrowRight,
343 contentDescription = null,
344 tint = MaterialTheme.colorScheme.onSurfaceVariant,
345 modifier = Modifier.size(20.dp),
349 Spacer(Modifier.size(6.dp))
350 CostBar(costFraction)
354// Locale.US so amounts/counts format identically regardless of device locale (stable digits
355// and a dot decimal for a dollar figure). Sub-cent totals get more precision so a tiny run
356// doesn't collapse to "$0.00".
357private fun formatCost(cost: Double): String =
358 if (cost in 0.0..0.01) String.format(Locale.US, "$%.4f", cost)
359 else String.format(Locale.US, "$%.2f", cost)
361private val integerFormat: NumberFormat = NumberFormat.getIntegerInstance(Locale.US)
363private 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