git.iko.soy/opencode-android
on master help sign in
Batch of UI/UX improvements: render previously-unrendered model data (tool call durations, todo priority badges, synthetic-text markers, reasoning/cache token breakdown in message bubbles, revert diff in the revert banner, agent-reported errors); add per-message share via the long-press menu, TTS pause/resume with chunk-index tracking, and inline validation feedback for the new-session custom path; and fix consistency gaps (dropdown back-handling across session/server/file/diagnostics lists, ImeAction.Search + keyboard-hide on the server/diagnostics search fields, rememberSaveable for sort/row menu state)
06fd41biko
app/src/main/java/soy/iko/opencode/data/model/Todo.kt+11
@@ -27,6 +27,17 @@ data class TodoItem(
27/** Normalized lifecycle of a [TodoItem]; [UNKNOWN] covers any status the client doesn't model. */
27/** Normalized lifecycle of a [TodoItem]; [UNKNOWN] covers any status the client doesn't model. */
28enum class TodoStatus { PENDING, IN_PROGRESS, COMPLETED, CANCELLED, UNKNOWN }
28enum class TodoStatus { PENDING, IN_PROGRESS, COMPLETED, CANCELLED, UNKNOWN }
29
29
30+/** Normalized priority of a [TodoItem]; [NONE] covers null/unmodelled priorities. The order
31+ * matters: HIGH > MEDIUM > LOW, used by [TodoPlan] tinting and a11y labels. */
32+enum class TodoPriority { HIGH, MEDIUM, LOW, NONE }
33+
34+fun TodoItem.priorityEnum(): TodoPriority = when ((priority ?: "").trim().lowercase()) {
35+ "high", "h", "1", "urgent", "critical" -> TodoPriority.HIGH
36+ "medium", "med", "m", "normal", "2" -> TodoPriority.MEDIUM
37+ "low", "l", "3", "minor", "backlog" -> TodoPriority.LOW
38+ else -> if (priority.isNullOrBlank()) TodoPriority.NONE else TodoPriority.NONE
39+}
40+
30fun TodoItem.statusEnum(): TodoStatus = when (status.trim().lowercase()) {
41fun TodoItem.statusEnum(): TodoStatus = when (status.trim().lowercase()) {
31 "pending", "todo", "" -> TodoStatus.PENDING
42 "pending", "todo", "" -> TodoStatus.PENDING
32 "in_progress", "in-progress", "inprogress", "running", "active" -> TodoStatus.IN_PROGRESS
43 "in_progress", "in-progress", "inprogress", "running", "active" -> TodoStatus.IN_PROGRESS
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+103−27
@@ -52,6 +52,8 @@ import androidx.compose.material.icons.filled.Description
52import androidx.compose.material.icons.filled.Close
52import androidx.compose.material.icons.filled.Close
53import androidx.compose.material.icons.filled.Edit
53import androidx.compose.material.icons.filled.Edit
54import androidx.compose.material.icons.filled.Expand
54import androidx.compose.material.icons.filled.Expand
55+import androidx.compose.material.icons.filled.ExpandLess
56+import androidx.compose.material.icons.filled.ExpandMore
55import androidx.compose.material.icons.filled.Search
57import androidx.compose.material.icons.filled.Search
56import androidx.compose.material.icons.filled.KeyboardArrowDown
58import androidx.compose.material.icons.filled.KeyboardArrowDown
57import androidx.compose.material.icons.filled.KeyboardArrowUp
59import androidx.compose.material.icons.filled.KeyboardArrowUp
@@ -160,6 +162,7 @@ import soy.iko.opencode.di.AppContainer
160import soy.iko.opencode.R
162import soy.iko.opencode.R
161import soy.iko.opencode.ui.components.CommandPalette
163import soy.iko.opencode.ui.components.CommandPalette
162import soy.iko.opencode.ui.components.ConnectionBanner
164import soy.iko.opencode.ui.components.ConnectionBanner
165+import soy.iko.opencode.ui.components.DiffView
163import soy.iko.opencode.ui.components.PaletteAction
166import soy.iko.opencode.ui.components.PaletteAction
164import soy.iko.opencode.ui.components.LocalReducedMotion
167import soy.iko.opencode.ui.components.LocalReducedMotion
165import soy.iko.opencode.ui.components.copyToClipboard
168import soy.iko.opencode.ui.components.copyToClipboard
@@ -206,6 +209,7 @@ fun ChatScreen(
206 val draft by vm.draft.collectAsStateWithLifecycle()
209 val draft by vm.draft.collectAsStateWithLifecycle()
207 val attachments by vm.attachments.collectAsStateWithLifecycle()
210 val attachments by vm.attachments.collectAsStateWithLifecycle()
208 val reverted by vm.reverted.collectAsStateWithLifecycle()
211 val reverted by vm.reverted.collectAsStateWithLifecycle()
212+ val revertDiff by vm.revertDiff.collectAsStateWithLifecycle()
209 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
213 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
210 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
214 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
211 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
215 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
@@ -758,7 +762,10 @@ fun ChatScreen(
758 enter = bannerMotion.enter,
762 enter = bannerMotion.enter,
759 exit = bannerMotion.exit,
763 exit = bannerMotion.exit,
760 ) {
764 ) {
765+ RevertBanner(
766+ diff = revertDiff,
761− RevertBanner(onUndo = { vm.unrevert() })
767+ onUndo = { vm.unrevert() },
768+ )
762 }
769 }
763 AnimatedVisibility(
770 AnimatedVisibility(
764 visible = queuedOffline.isNotEmpty(),
771 visible = queuedOffline.isNotEmpty(),
@@ -1122,6 +1129,7 @@ fun ChatScreen(
1122 // message list leaves composition.
1129 // message list leaves composition.
1123 val tts = rememberTtsController()
1130 val tts = rememberTtsController()
1124 val speakingMessageId by tts.speakingId
1131 val speakingMessageId by tts.speakingId
1132+ val ttsState by tts.state
1125 LazyColumn(
1133 LazyColumn(
1126 state = listState,
1134 state = listState,
1127 modifier = Modifier.fillMaxSize(),
1135 modifier = Modifier.fillMaxSize(),
@@ -1251,6 +1259,9 @@ fun ChatScreen(
1251 }
1259 }
1252 },
1260 },
1253 isSpeaking = message.info.id == speakingMessageId,
1261 isSpeaking = message.info.id == speakingMessageId,
1262+ ttsState = if (message.info.id == speakingMessageId) ttsState else TtsState.IDLE,
1263+ onPause = { tts.pause() },
1264+ onResume = { tts.resume() },
1254 onQuote = { text ->
1265 onQuote = { text ->
1255 vm.quoteReply(text)
1266 vm.quoteReply(text)
1256 runCatching { inputFocusRequester.requestFocus() }
1267 runCatching { inputFocusRequester.requestFocus() }
@@ -1280,6 +1291,23 @@ fun ChatScreen(
1280 null
1291 null
1281 }
1292 }
1282 },
1293 },
1294+ onShare = {
1295+ // Per-message share: build a single-message Markdown
1296+ // transcript off-thread and fire ACTION_SEND, mirroring the
1297+ // whole-conversation share but scoped to just this message.
1298+ scope.launch {
1299+ val md = withContext(Dispatchers.Default) {
1300+ buildMessageMarkdown(message)
1301+ } ?: return@launch
1302+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
1303+ type = "text/markdown"
1304+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
1305+ putExtra(android.content.Intent.EXTRA_TEXT, md)
1306+ }
1307+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareContext.getString(R.string.share_message))) }
1308+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
1309+ }
1310+ },
1283 )
1311 )
1284 }
1312 }
1285 }
1313 }
@@ -2175,40 +2203,88 @@ private fun EmptyConversation(
2175 }
2203 }
2176}
2204}
2177
2205
2178−/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
2206+/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). When the
2207+ * server included a diff of the revert, a collapsible "Show what changed" affordance reveals
2208+ * it inline via [DiffView] so the user can see what was rolled back before deciding to undo. */
2179@Composable
2209@Composable
2180−private fun RevertBanner(onUndo: () -> Unit) {
2210+private fun RevertBanner(diff: String?, onUndo: () -> Unit) {
2181 val haptics = LocalHapticFeedback.current
2211 val haptics = LocalHapticFeedback.current
2212+ var showDiff by rememberSaveable { mutableStateOf(false) }
2213+ val expandMotion = rememberVisibilityTransitions()
2182 Surface(
2214 Surface(
2183 color = MaterialTheme.colorScheme.tertiaryContainer,
2215 color = MaterialTheme.colorScheme.tertiaryContainer,
2184 tonalElevation = 2.dp,
2216 tonalElevation = 2.dp,
2185 modifier = Modifier.fillMaxWidth(),
2217 modifier = Modifier.fillMaxWidth(),
2186 ) {
2218 ) {
2219+ Column {
2187− Row(
2220+ Row(
2188− modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2221+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2189− verticalAlignment = Alignment.CenterVertically,
2222+ verticalAlignment = Alignment.CenterVertically,
2190− horizontalArrangement = Arrangement.SpaceBetween,
2223+ horizontalArrangement = Arrangement.SpaceBetween,
2191− ) {
2224+ ) {
2192− Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2225+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2193− Icon(
2226+ Icon(
2194− Icons.Filled.Restore,
2227+ Icons.Filled.Restore,
2195− contentDescription = null,
2228+ contentDescription = null,
2196− tint = MaterialTheme.colorScheme.onTertiaryContainer,
2229+ tint = MaterialTheme.colorScheme.onTertiaryContainer,
2197− modifier = Modifier.size(18.dp),
2230+ modifier = Modifier.size(18.dp),
2198− )
2231+ )
2199− Text(
2232+ Text(
2200− stringResource(R.string.reverted_banner),
2233+ stringResource(R.string.reverted_banner),
2201− style = MaterialTheme.typography.bodySmall,
2234+ style = MaterialTheme.typography.bodySmall,
2202− color = MaterialTheme.colorScheme.onTertiaryContainer,
2235+ color = MaterialTheme.colorScheme.onTertiaryContainer,
2203− modifier = Modifier.padding(start = 8.dp),
2236+ modifier = Modifier.padding(start = 8.dp),
2204− )
2237+ )
2238+ // "Show what changed" toggle, shown only when the server provided a diff.
2239+ // Collapsible so the banner stays compact by default; a user curious about
2240+ // what the revert rolled back can expand it inline without leaving the chat.
2241+ if (!diff.isNullOrBlank()) {
2242+ TextButton(
2243+ onClick = {
2244+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2245+ showDiff = !showDiff
2246+ },
2247+ contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 8.dp, end = 4.dp),
2248+ ) {
2249+ Icon(
2250+ if (showDiff) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
2251+ contentDescription = null,
2252+ modifier = Modifier.size(16.dp),
2253+ )
2254+ Text(
2255+ stringResource(if (showDiff) R.string.revert_hide_diff else R.string.revert_show_diff),
2256+ style = MaterialTheme.typography.labelSmall,
2257+ modifier = Modifier.padding(start = 4.dp),
2258+ )
2259+ }
2260+ }
2261+ }
2262+ TextButton(onClick = {
2263+ // Haptic on undo to match the other destructive/important confirmations: a
2264+ // revert restores potentially hundreds of hidden messages.
2265+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2266+ onUndo()
2267+ }) { Text(stringResource(R.string.undo)) }
2268+ }
2269+ // The diff render lives inside the banner's Surface so it inherits the tertiary
2270+ // tonal container and reads as part of the banner, not a floating panel.
2271+ if (!diff.isNullOrBlank()) {
2272+ AnimatedVisibility(
2273+ visible = showDiff,
2274+ enter = expandMotion.enter,
2275+ exit = expandMotion.exit,
2276+ ) {
2277+ Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
2278+ Text(
2279+ stringResource(R.string.revert_diff_header),
2280+ style = MaterialTheme.typography.labelSmall,
2281+ color = MaterialTheme.colorScheme.onTertiaryContainer,
2282+ modifier = Modifier.padding(bottom = 4.dp),
2283+ )
2284+ DiffView(diff, saveKey = "revertDiff")
2285+ }
2286+ }
2205 }
2287 }
2206− TextButton(onClick = {
2207− // Haptic on undo to match the other destructive/important confirmations: a
2208− // revert restores potentially hundreds of hidden messages.
2209− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2210− onUndo()
2211− }) { Text(stringResource(R.string.undo)) }
2212 }
2288 }
2213 }
2289 }
2214}
2290}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+18−2
@@ -529,6 +529,12 @@ class ChatViewModel(
529 private val _reverted = MutableStateFlow(false)
529 private val _reverted = MutableStateFlow(false)
530 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
530 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
531
531
532+ /** The diff of the active revert checkpoint, when the server includes one. Shown in the
533+ * revert banner so the user can see what changed before deciding to undo. Null when the
534+ * session isn't reverted or the server didn't send a diff. */
535+ private val _revertDiff = MutableStateFlow<String?>(null)
536+ val revertDiff: StateFlow<String?> = _revertDiff.asStateFlow()
537+
532 /** The active public share URL for this session, or null when not shared. */
538 /** The active public share URL for this session, or null when not shared. */
533 private val _shareUrl = MutableStateFlow<String?>(null)
539 private val _shareUrl = MutableStateFlow<String?>(null)
534 val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
540 val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
@@ -713,6 +719,7 @@ class ChatViewModel(
713 clearPermissions()
719 clearPermissions()
714 sessionAllowed.clear()
720 sessionAllowed.clear()
715 sessionTitleJob?.cancel()
721 sessionTitleJob?.cancel()
722+ _revertDiff.value = null
716 return@collectLatest
723 return@collectLatest
717 }
724 }
718 _running.value = false
725 _running.value = false
@@ -723,6 +730,7 @@ class ChatViewModel(
723 clearPermissions()
730 clearPermissions()
724 sessionAllowed.clear()
731 sessionAllowed.clear()
725 _failedDraft.value = null
732 _failedDraft.value = null
733+ _revertDiff.value = null
726 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
734 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
727 // user intent that is now persisted; wiping it on every (re)connect — which
735 // user intent that is now persisted; wiping it on every (re)connect — which
728 // this collector does, including transient SSE drops and the cold-start
736 // this collector does, including transient SSE drops and the cold-start
@@ -743,6 +751,7 @@ class ChatViewModel(
743 if (_sessionTitle.value == null) {
751 if (_sessionTitle.value == null) {
744 _sessionTitle.value = session.displayTitle
752 _sessionTitle.value = session.displayTitle
745 _reverted.value = session.isReverted
753 _reverted.value = session.isReverted
754+ _revertDiff.value = session.revert?.diff?.takeIf { it.isNotBlank() }
746 _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
755 _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
747 }
756 }
748 }
757 }
@@ -800,6 +809,7 @@ class ChatViewModel(
800 val info = event.properties.info
809 val info = event.properties.info
801 _sessionTitle.value = info.displayTitle
810 _sessionTitle.value = info.displayTitle
802 _reverted.value = info.isReverted
811 _reverted.value = info.isReverted
812+ _revertDiff.value = info.revert?.diff?.takeIf { it.isNotBlank() }
803 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
813 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
804 }
814 }
805 is SessionDeleted ->
815 is SessionDeleted ->
@@ -1275,7 +1285,10 @@ class ChatViewModel(
1275 val conn = connection ?: return
1285 val conn = connection ?: return
1276 viewModelScope.launch {
1286 viewModelScope.launch {
1277 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1287 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1288+ .onSuccess {
1278− .onSuccess { _reverted.value = it.isReverted }
1289+ _reverted.value = it.isReverted
1290+ _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1291+ }
1279 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1292 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1280 }
1293 }
1281 }
1294 }
@@ -1362,7 +1375,10 @@ class ChatViewModel(
1362 val conn = connection ?: return
1375 val conn = connection ?: return
1363 viewModelScope.launch {
1376 viewModelScope.launch {
1364 runCatchingCancellable { conn.api.unrevert(sessionId) }
1377 runCatchingCancellable { conn.api.unrevert(sessionId) }
1378+ .onSuccess {
1365− .onSuccess { _reverted.value = it.isReverted }
1379+ _reverted.value = it.isReverted
1380+ _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1381+ }
1366 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1382 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1367 }
1383 }
1368 }
1384 }
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+39
@@ -68,6 +68,45 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
68 return sb.toString().trimEnd()
68 return sb.toString().trimEnd()
69}
69}
70
70
71+/**
72+ * Render a single message as Markdown for per-message sharing. A trimmed-down version of
73+ * [buildConversationMarkdown] that emits just this message's role heading + body (no metadata
74+ * header), so a shared single reply/exchange is self-contained without dragging the whole
75+ * transcript. Returns null when the message has no exportable body (e.g. an image-only prompt).
76+ */
77+fun buildMessageMarkdown(message: MessageWithParts): String? {
78+ val heading = when (message.info) {
79+ is UserMessage -> "## You"
80+ is AssistantMessage -> "## opencode"
81+ else -> return null
82+ }
83+ val escapeText = message.info is UserMessage
84+ val body = message.parts
85+ .mapNotNull { part ->
86+ when (part) {
87+ is TextPart -> part.text.takeIf { it.isNotBlank() }
88+ ?.let { if (escapeText) escapeMarkdown(it) else it }
89+ is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
90+ ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
91+ is ToolPart -> formatToolCall(part)
92+ is FilePart -> {
93+ val name = part.sourcePath ?: part.url ?: part.filename ?: "attachment"
94+ if (part.mime?.startsWith("image/") == true) "_[image: ${escapeMarkdown(name)}]_"
95+ else "_[file: ${escapeMarkdown(name)}]_"
96+ }
97+ else -> null
98+ }
99+ }
100+ .joinToString("\n\n")
101+ .trim()
102+ if (body.isEmpty()) return null
103+ return buildString {
104+ append(heading).append("\n\n")
105+ message.info.time?.created?.let { ts -> append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
106+ append(body)
107+ }
108+}
109+
71/** Format an epoch-millis as a locale-stable ISO-ish timestamp for the export. Thread-confined
110/** Format an epoch-millis as a locale-stable ISO-ish timestamp for the export. Thread-confined
72 * to the exporting coroutine, so a shared SimpleDateFormat (not thread-safe) is fine. */
111 * to the exporting coroutine, so a shared SimpleDateFormat (not thread-safe) is fine. */
73private fun formatExportTimestamp(epochMillis: Long): String =
112private fun formatExportTimestamp(epochMillis: Long): String =
app/src/main/java/soy/iko/opencode/ui/chat/CostFormat.ktnew file+60
@@ -0,0 +1,60 @@
1+package soy.iko.opencode.ui.chat
2+
3+import soy.iko.opencode.data.model.Tokens
4+
5+// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
6+// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
7+// when the message list re-seeds after a reconnect and every visible assistant bubble
8+// recomposes at once) don't each pay that cost. Thread-local because NumberFormat is
9+// not thread-safe.
10+internal val tokenNumberFormat: ThreadLocal<java.text.NumberFormat> = ThreadLocal.withInitial {
11+ java.text.NumberFormat.getNumberInstance(java.util.Locale.US)
12+}
13+
14+internal fun formatTokens(tokens: Tokens, format: String): String {
15+ val nf = tokenNumberFormat.get()!!
16+ return format.format(nf.format(tokens.input), nf.format(tokens.output))
17+}
18+
19+internal fun formatCost(cost: Double, shortFormat: String, longFormat: String): String =
20+ // Locale.US so the formatting is stable regardless of device locale (avoids
21+ // non-ASCII digits or comma decimal separators in a dollar amount). The format
22+ // string itself is localized via strings.xml so the currency symbol can be
23+ // adapted by translators.
24+ if (cost < 0.01) String.format(java.util.Locale.US, longFormat, cost)
25+ else String.format(java.util.Locale.US, shortFormat, cost)
26+
27+/** Assemble the single-line cost/tokens summary for an assistant bubble. Returns null when the
28+ * message isn't complete, when neither cost nor tokens were reported, or when every figure is
29+ * zero (so the bubble doesn't show a meaningless "0 in · 0 out • $0.0000"). Extracted from
30+ * [AssistantBlock] to keep that function's cyclomatic complexity under the detekt threshold.
31+ * Reasoning and cache segments are added only when non-zero. */
32+internal fun buildCostSummary(
33+ isComplete: Boolean,
34+ tokens: Tokens?,
35+ cost: Double?,
36+ tokenFormat: String,
37+ reasoningFormat: String,
38+ cacheReadFormat: String,
39+ cacheWriteFormat: String,
40+ costShort: String,
41+ costLong: String,
42+): String? {
43+ if (!isComplete || (cost == null && tokens == null)) return null
44+ val nf = tokenNumberFormat.get()!!
45+ return buildList {
46+ // Skip an all-zero token count (e.g. a completed message that reported no usage) so
47+ // the bubble doesn't show a meaningless "0 in · 0 out".
48+ tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
49+ tokens?.takeIf { it.reasoning > 0 }?.let {
50+ add(reasoningFormat.format(nf.format(it.reasoning)))
51+ }
52+ tokens?.cache?.takeIf { it.read > 0 }?.let {
53+ add(cacheReadFormat.format(nf.format(it.read)))
54+ }
55+ tokens?.cache?.takeIf { it.write > 0 }?.let {
56+ add(cacheWriteFormat.format(nf.format(it.write)))
57+ }
58+ cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
59+ }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
60+}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+194−77
@@ -22,10 +22,12 @@ import androidx.compose.material.icons.filled.Edit
22import androidx.compose.material.icons.filled.Error
22import androidx.compose.material.icons.filled.Error
23import androidx.compose.material.icons.filled.FormatQuote
23import androidx.compose.material.icons.filled.FormatQuote
24import androidx.compose.material.icons.filled.MoreVert
24import androidx.compose.material.icons.filled.MoreVert
25+import androidx.compose.material.icons.filled.Pause
26+import androidx.compose.material.icons.filled.PlayArrow
25import androidx.compose.material.icons.filled.Refresh
27import androidx.compose.material.icons.filled.Refresh
26import androidx.compose.material.icons.filled.Restore
28import androidx.compose.material.icons.filled.Restore
29+import androidx.compose.material.icons.filled.Share
27import androidx.compose.material.icons.filled.SmartToy
30import androidx.compose.material.icons.filled.SmartToy
28−import androidx.compose.material.icons.filled.StopCircle
29import androidx.compose.material3.DropdownMenu
31import androidx.compose.material3.DropdownMenu
30import androidx.compose.material3.DropdownMenuItem
32import androidx.compose.material3.DropdownMenuItem
31import androidx.compose.material3.CircularProgressIndicator
33import androidx.compose.material3.CircularProgressIndicator
@@ -60,7 +62,6 @@ import soy.iko.opencode.data.model.MessageWithParts
60import soy.iko.opencode.data.model.ModelOption
62import soy.iko.opencode.data.model.ModelOption
61import soy.iko.opencode.data.model.FilePart
63import soy.iko.opencode.data.model.FilePart
62import soy.iko.opencode.data.model.TextPart
64import soy.iko.opencode.data.model.TextPart
63−import soy.iko.opencode.data.model.Tokens
64import soy.iko.opencode.data.model.UnknownMessage
65import soy.iko.opencode.data.model.UnknownMessage
65import soy.iko.opencode.data.model.UserMessage
66import soy.iko.opencode.data.model.UserMessage
66import soy.iko.opencode.data.network.NetworkConfig
67import soy.iko.opencode.data.network.NetworkConfig
@@ -100,6 +101,9 @@ fun MessageBubble(
100 onEdit: ((String) -> Unit)? = null,
101 onEdit: ((String) -> Unit)? = null,
101 onSpeak: ((String) -> Unit)? = null,
102 onSpeak: ((String) -> Unit)? = null,
102 isSpeaking: Boolean = false,
103 isSpeaking: Boolean = false,
104+ ttsState: TtsState = TtsState.IDLE,
105+ onPause: (() -> Unit)? = null,
106+ onResume: (() -> Unit)? = null,
103 onQuote: ((String) -> Unit)? = null,
107 onQuote: ((String) -> Unit)? = null,
104 onBranch: ((String) -> Unit)? = null,
108 onBranch: ((String) -> Unit)? = null,
105 onRegenerate: (() -> Unit)? = null,
109 onRegenerate: (() -> Unit)? = null,
@@ -107,11 +111,12 @@ fun MessageBubble(
107 isEdited: Boolean = false,
111 isEdited: Boolean = false,
108 onRetry: (() -> Unit)? = null,
112 onRetry: (() -> Unit)? = null,
109 onDismiss: (() -> Unit)? = null,
113 onDismiss: (() -> Unit)? = null,
114+ onShare: (() -> Unit)? = null,
110) {
115) {
111 when (message.info) {
116 when (message.info) {
112− is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss)
117+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss, onShare)
113 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
118 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
114− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote, onBranch, onRegenerate, isEdited)
119+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, ttsState, onPause, onResume, onQuote, onBranch, onRegenerate, isEdited, onShare)
115 }
120 }
116}
121}
117
122
@@ -130,6 +135,7 @@ private fun MessageLongPressMenu(
130 onQuote: ((String) -> Unit)?,
135 onQuote: ((String) -> Unit)?,
131 onBranch: ((String) -> Unit)?,
136 onBranch: ((String) -> Unit)?,
132 onRevert: (() -> Unit)?,
137 onRevert: (() -> Unit)?,
138+ onShare: (() -> Unit)?,
133) {
139) {
134 DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
140 DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
135 if (text != null && onCopy != null) {
141 if (text != null && onCopy != null) {
@@ -139,6 +145,16 @@ private fun MessageLongPressMenu(
139 onClick = { onDismiss(); onCopy() },
145 onClick = { onDismiss(); onCopy() },
140 )
146 )
141 }
147 }
148+ // Per-message share: fire an ACTION_SEND with this message's Markdown, so a user can
149+ // forward a single reply (e.g. a code snippet) without exporting the whole transcript.
150+ // Gated on text so an image-only prompt doesn't show a no-op Share entry.
151+ if (text != null && onShare != null) {
152+ DropdownMenuItem(
153+ leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
154+ text = { Text(stringResource(R.string.share_message)) },
155+ onClick = { onDismiss(); onShare() },
156+ )
157+ }
142 if (text != null && onQuote != null) {
158 if (text != null && onQuote != null) {
143 DropdownMenuItem(
159 DropdownMenuItem(
144 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
160 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
@@ -288,6 +304,7 @@ private fun UserBubble(
288 isEdited: Boolean = false,
304 isEdited: Boolean = false,
289 onRetry: (() -> Unit)? = null,
305 onRetry: (() -> Unit)? = null,
290 onDismiss: (() -> Unit)? = null,
306 onDismiss: (() -> Unit)? = null,
307+ onShare: (() -> Unit)? = null,
291) {
308) {
292 val context = LocalContext.current
309 val context = LocalContext.current
293 val haptics = LocalHapticFeedback.current
310 val haptics = LocalHapticFeedback.current
@@ -493,6 +510,7 @@ private fun UserBubble(
493 onQuote = onQuote,
510 onQuote = onQuote,
494 onBranch = onBranch,
511 onBranch = onBranch,
495 onRevert = onRevert,
512 onRevert = onRevert,
513+ onShare = onShare,
496 )
514 )
497 }
515 }
498 }
516 }
@@ -511,10 +529,14 @@ private fun AssistantBlock(
511 onRevert: (() -> Unit)? = null,
529 onRevert: (() -> Unit)? = null,
512 onSpeak: ((String) -> Unit)? = null,
530 onSpeak: ((String) -> Unit)? = null,
513 isSpeaking: Boolean = false,
531 isSpeaking: Boolean = false,
532+ ttsState: TtsState = TtsState.IDLE,
533+ onPause: (() -> Unit)? = null,
534+ onResume: (() -> Unit)? = null,
514 onQuote: ((String) -> Unit)? = null,
535 onQuote: ((String) -> Unit)? = null,
515 onBranch: ((String) -> Unit)? = null,
536 onBranch: ((String) -> Unit)? = null,
516 onRegenerate: (() -> Unit)? = null,
537 onRegenerate: (() -> Unit)? = null,
517 isEdited: Boolean = false,
538 isEdited: Boolean = false,
539+ onShare: (() -> Unit)? = null,
518) {
540) {
519 Column(
541 Column(
520 modifier = modifier.fillMaxWidth(),
542 modifier = modifier.fillMaxWidth(),
@@ -522,38 +544,10 @@ private fun AssistantBlock(
522 ) {
544 ) {
523 val info = message.info
545 val info = message.info
524 if (info is AssistantMessage) {
546 if (info is AssistantMessage) {
525− val label = modelLabel ?: info.modelID
526 // Avatar + model label row: the robot icon gives the assistant a consistent visual
547 // Avatar + model label row: the robot icon gives the assistant a consistent visual
527 // identity (left-aligned, mirroring the user's right-aligned bubble) so on a long
548 // identity (left-aligned, mirroring the user's right-aligned bubble) so on a long
528 // scroll the speaker is unambiguous without reading the label.
549 // scroll the speaker is unambiguous without reading the label.
529− Row(verticalAlignment = Alignment.CenterVertically) {
550+ AssistantHeader(label = modelLabel ?: info.modelID)
530− Box(
531− modifier = Modifier
532− .size(28.dp)
533− .clip(MaterialTheme.shapes.extraSmall)
534− .background(MaterialTheme.colorScheme.secondaryContainer),
535− contentAlignment = Alignment.Center,
536− ) {
537− Icon(
538− Icons.Filled.SmartToy,
539− contentDescription = null,
540− modifier = Modifier.size(18.dp),
541− tint = MaterialTheme.colorScheme.onSecondaryContainer,
542− )
543− }
544− if (label != null) {
545− Text(
546− label,
547− style = MaterialTheme.typography.labelSmall,
548− color = MaterialTheme.colorScheme.onSurfaceVariant,
549− maxLines = 1,
550− overflow = TextOverflow.Ellipsis,
551− modifier = Modifier
552− .padding(start = 6.dp)
553− .weight(1f, fill = false),
554− )
555− }
556− }
557 }
551 }
558 message.parts.forEachIndexed { index, part ->
552 message.parts.forEachIndexed { index, part ->
559 // Only the final part of a running message is actively streaming; earlier parts
553 // Only the final part of a running message is actively streaming; earlier parts
@@ -568,23 +562,34 @@ private fun AssistantBlock(
568 PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
562 PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
569 }
563 }
570 }
564 }
565+ // Agent-reported error: the server sets AssistantMessage.error when the run finished
566+ // with a model/agent error (distinct from a transport failure, which surfaces as a
567+ // snackbar). Render it as an error-tinted note above the footer so a reader can tell
568+ // the reply is incomplete/failed by glancing at the bubble, not just from the absence
569+ // of content. Folded into the same `info is AssistantMessage` branch as the cost
570+ // summary below to avoid an extra conditional raising this function's complexity.
571 if (info is AssistantMessage) {
571 if (info is AssistantMessage) {
572+ info.error?.let { err -> AgentErrorNote(err) }
572 val cost = info.cost
573 val cost = info.cost
573 val tokens = info.tokens
574 val tokens = info.tokens
574 val tokenFormat = stringResource(R.string.tokens_in_out)
575 val tokenFormat = stringResource(R.string.tokens_in_out)
576+ val reasoningFormat = stringResource(R.string.tokens_reasoning_format)
577+ val cacheReadFormat = stringResource(R.string.tokens_cache_read_format)
578+ val cacheWriteFormat = stringResource(R.string.tokens_cache_write_format)
575 val costShort = stringResource(R.string.cost_format_short)
579 val costShort = stringResource(R.string.cost_format_short)
576 val costLong = stringResource(R.string.cost_format_long)
580 val costLong = stringResource(R.string.cost_format_long)
577 // Memoize the formatted cost/tokens line so a scroll-induced or unrelated
581 // Memoize the formatted cost/tokens line so a scroll-induced or unrelated
578 // state-flip recomposition doesn't re-run NumberFormat + buildList +
582 // state-flip recomposition doesn't re-run NumberFormat + buildList +
579− // joinToString for every visible assistant bubble.
583+ // joinToString for every visible assistant bubble. The assembly logic lives in
580− val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, costShort, costLong) {
584+ // [buildCostSummary] (extracted to keep this function's complexity in check).
581− if (!info.isComplete || (cost == null && tokens == null)) null
585+ //
582− else buildList {
586+ // Reasoning and cache figures are only added when non-zero, so a model that
583− // Skip an all-zero token count (e.g. a completed message that reported no
587+ // doesn't report them (or a request that didn't use them) shows the same compact
584− // usage) so the bubble doesn't show a meaningless "0 in · 0 out".
588+ // "1.2k in · 540 out • $0.012" line as before; a reasoning-model reply gains a
585− tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
589+ // "3.1k reasoning" and/or "8.0k cache read" segment. The single-line cap and
586− cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
590+ // ellipsis below keep the line tidy at narrow widths even with all four segments.
587− }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
591+ val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong) {
592+ buildCostSummary(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong)
588 }
593 }
589 // Long-press context menu state (see UserBubble for rationale).
594 // Long-press context menu state (see UserBubble for rationale).
590 var longPressMenu by remember { mutableStateOf(false) }
595 var longPressMenu by remember { mutableStateOf(false) }
@@ -647,7 +652,10 @@ private fun AssistantBlock(
647 AssistantActions(
652 AssistantActions(
648 textToCopy = textToCopy,
653 textToCopy = textToCopy,
649 isSpeaking = isSpeaking,
654 isSpeaking = isSpeaking,
655+ ttsState = ttsState,
650 onSpeak = onSpeak,
656 onSpeak = onSpeak,
657+ onPause = onPause,
658+ onResume = onResume,
651 onRevert = onRevert,
659 onRevert = onRevert,
652 onQuote = onQuote,
660 onQuote = onQuote,
653 onBranch = onBranch,
661 onBranch = onBranch,
@@ -663,6 +671,7 @@ private fun AssistantBlock(
663 onQuote = onQuote,
671 onQuote = onQuote,
664 onBranch = onBranch,
672 onBranch = onBranch,
665 onRevert = onRevert,
673 onRevert = onRevert,
674+ onShare = onShare,
666 )
675 )
667 }
676 }
668 } else {
677 } else {
@@ -671,13 +680,50 @@ private fun AssistantBlock(
671 }
680 }
672}
681}
673
682
683+/** Avatar + model label row for an assistant message. Extracted from [AssistantBlock] to keep
684+ * that function's cyclomatic complexity under the detekt threshold. */
685+@Composable
686+private fun AssistantHeader(label: String?) {
687+ Row(verticalAlignment = Alignment.CenterVertically) {
688+ Box(
689+ modifier = Modifier
690+ .size(28.dp)
691+ .clip(MaterialTheme.shapes.extraSmall)
692+ .background(MaterialTheme.colorScheme.secondaryContainer),
693+ contentAlignment = Alignment.Center,
694+ ) {
695+ Icon(
696+ Icons.Filled.SmartToy,
697+ contentDescription = null,
698+ modifier = Modifier.size(18.dp),
699+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
700+ )
701+ }
702+ if (label != null) {
703+ Text(
704+ label,
705+ style = MaterialTheme.typography.labelSmall,
706+ color = MaterialTheme.colorScheme.onSurfaceVariant,
707+ maxLines = 1,
708+ overflow = TextOverflow.Ellipsis,
709+ modifier = Modifier
710+ .padding(start = 6.dp)
711+ .weight(1f, fill = false),
712+ )
713+ }
714+ }
715+}
716+
674/** Trailing action buttons for an assistant message: read-aloud (TTS), revert, copy.
717/** Trailing action buttons for an assistant message: read-aloud (TTS), revert, copy.
675 * Extracted from [AssistantBlock] to keep that function under the complexity threshold. */
718 * Extracted from [AssistantBlock] to keep that function under the complexity threshold. */
676@Composable
719@Composable
677private fun AssistantActions(
720private fun AssistantActions(
678 textToCopy: String?,
721 textToCopy: String?,
679 isSpeaking: Boolean,
722 isSpeaking: Boolean,
723+ ttsState: TtsState,
680 onSpeak: ((String) -> Unit)?,
724 onSpeak: ((String) -> Unit)?,
725+ onPause: (() -> Unit)?,
726+ onResume: (() -> Unit)?,
681 onRevert: (() -> Unit)?,
727 onRevert: (() -> Unit)?,
682 onQuote: ((String) -> Unit)? = null,
728 onQuote: ((String) -> Unit)? = null,
683 onBranch: ((String) -> Unit)? = null,
729 onBranch: ((String) -> Unit)? = null,
@@ -703,22 +749,19 @@ private fun AssistantActions(
703 )
749 )
704 }
750 }
705 }
751 }
706− // Read-aloud toggle: speaks the assistant text via TextToSpeech, showing a Stop
752+ // Read-aloud control with pause/resume. Tapping cycles through play → pause → resume,
707− // icon while this message is the one being spoken.
753+ // so a user listening to a long reply can step away and continue from roughly where
754+ // they stopped without re-playing the whole message. Extracted to [TtsButton] to keep
755+ // this function's cyclomatic complexity under the detekt threshold.
708 if (onSpeak != null && textToCopy != null) {
756 if (onSpeak != null && textToCopy != null) {
709− val speakLabel = stringResource(if (isSpeaking) R.string.stop_reading else R.string.read_aloud)
757+ TtsButton(
710− IconButton(onClick = {
711− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
712− onSpeak(textToCopy)
758+ text = textToCopy,
713− }) {
759+ isSpeaking = isSpeaking,
714− Icon(
760+ ttsState = ttsState,
715− if (isSpeaking) Icons.Filled.StopCircle else Icons.AutoMirrored.Filled.VolumeUp,
761+ onSpeak = onSpeak,
716− contentDescription = speakLabel,
762+ onPause = onPause,
717− modifier = Modifier.size(18.dp),
763+ onResume = onResume,
718− tint = if (isSpeaking) MaterialTheme.colorScheme.primary
764+ )
719− else MaterialTheme.colorScheme.onSurfaceVariant,
720− )
721− }
722 }
765 }
723 onRevert?.let { RevertButton(it) }
766 onRevert?.let { RevertButton(it) }
724 if (textToCopy != null) {
767 if (textToCopy != null) {
@@ -741,6 +784,48 @@ private fun AssistantActions(
741 }
784 }
742}
785}
743
786
787+/** Read-aloud button with play/pause/resume states. Extracted from [AssistantActions] to keep
788+ * that function's cyclomatic complexity under the detekt threshold. The icon reflects the
789+ * next action: VolumeUp (play) when idle, Pause when playing, PlayArrow (resume) when paused. */
790+@Composable
791+private fun TtsButton(
792+ text: String,
793+ isSpeaking: Boolean,
794+ ttsState: TtsState,
795+ onSpeak: (String) -> Unit,
796+ onPause: (() -> Unit)?,
797+ onResume: (() -> Unit)?,
798+) {
799+ val haptics = LocalHapticFeedback.current
800+ val label = when {
801+ isSpeaking && ttsState == TtsState.PAUSED -> stringResource(R.string.resume_reading)
802+ isSpeaking -> stringResource(R.string.pause_reading)
803+ else -> stringResource(R.string.read_aloud)
804+ }
805+ IconButton(onClick = {
806+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
807+ when {
808+ isSpeaking && ttsState == TtsState.PAUSED -> onResume?.invoke()
809+ isSpeaking -> onPause?.invoke()
810+ else -> onSpeak(text)
811+ }
812+ }) {
813+ val icon = when {
814+ isSpeaking && ttsState == TtsState.PAUSED -> Icons.Filled.PlayArrow
815+ isSpeaking -> Icons.Filled.Pause
816+ else -> Icons.AutoMirrored.Filled.VolumeUp
817+ }
818+ val tint = if (isSpeaking) MaterialTheme.colorScheme.primary
819+ else MaterialTheme.colorScheme.onSurfaceVariant
820+ Icon(
821+ icon,
822+ contentDescription = label,
823+ modifier = Modifier.size(18.dp),
824+ tint = tint,
825+ )
826+ }
827+}
828+
744@Composable
829@Composable
745private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
830private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
746 // Compact relative label by default; long-press reveals the full absolute timestamp
831 // Compact relative label by default; long-press reveals the full absolute timestamp
@@ -748,24 +833,56 @@ private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo)
748 RelativeTimeText(info.time?.created)
833 RelativeTimeText(info.time?.created)
749}
834}
750
835
751−// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
836+/**
752−// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
837+ * Render an [AssistantMessage.error] as an error-tinted note on the bubble. The error is a
753−// when the message list re-seeds after a reconnect and every visible assistant bubble
838+ * tolerant-decoded [JsonElement]; we extract a readable string from it (a primitive's content
754−// recomposes at once) don't each pay that cost. Thread-local because NumberFormat is
839+ * for a bare string, a pretty-printed object otherwise, trimmed to a reasonable length so a
755−// not thread-safe.
840+ * verbose error object doesn't dominate the bubble).
756−private val tokenNumberFormat: ThreadLocal<java.text.NumberFormat> = ThreadLocal.withInitial {
841+ */
757− java.text.NumberFormat.getNumberInstance(java.util.Locale.US)
842+@Composable
758−}
843+private fun AgentErrorNote(error: kotlinx.serialization.json.JsonElement) {
759−
844+ // Extract a readable string from the tolerant-decoded JsonElement: a bare string
760−private fun formatTokens(tokens: Tokens, format: String): String {
845+ // primitive returns its content (the common `{"error": "..."}` case); an object/array
761− val nf = tokenNumberFormat.get()!!
846+ // is pretty-printed and trimmed so a verbose error doesn't dominate the bubble.
762− return format.format(nf.format(tokens.input), nf.format(tokens.output))
847+ val text = remember(error) {
848+ val raw = if (error is kotlinx.serialization.json.JsonPrimitive) {
849+ error.content
850+ } else {
851+ runCatching {
852+ soy.iko.opencode.data.network.OpencodeJson.encodeToString(
853+ kotlinx.serialization.json.JsonElement.serializer(),
854+ error,
855+ )
856+ }.getOrDefault(error.toString())
857+ }
858+ if (raw.length <= 500) raw else raw.take(500) + "…"
859+ }.takeIf { it.isNotBlank() } ?: return
860+ val label = stringResource(R.string.agent_error_label)
861+ Row(
862+ modifier = Modifier
863+ .fillMaxWidth()
864+ .clip(MaterialTheme.shapes.small)
865+ .background(MaterialTheme.colorScheme.errorContainer)
866+ .padding(10.dp),
867+ verticalAlignment = Alignment.Top,
868+ ) {
869+ Icon(
870+ Icons.Filled.Error,
871+ contentDescription = null,
872+ tint = MaterialTheme.colorScheme.onErrorContainer,
873+ modifier = Modifier.size(16.dp),
874+ )
875+ Column(modifier = Modifier.padding(start = 8.dp)) {
876+ Text(
877+ label,
878+ style = MaterialTheme.typography.labelMedium,
879+ color = MaterialTheme.colorScheme.onErrorContainer,
880+ )
881+ Text(
882+ text,
883+ style = MaterialTheme.typography.bodySmall,
884+ color = MaterialTheme.colorScheme.onErrorContainer,
885+ )
886+ }
887+ }
763}
888}
764−
765−private fun formatCost(cost: Double, shortFormat: String, longFormat: String): String =
766− // Locale.US so the formatting is stable regardless of device locale (avoids
767− // non-ASCII digits or comma decimal separators in a dollar amount). The format
768− // string itself is localized via strings.xml so the currency symbol can be
769− // adapted by translators.
770− if (cost < 0.01) String.format(java.util.Locale.US, longFormat, cost)
771− else String.format(java.util.Locale.US, shortFormat, cost)
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+67
@@ -95,6 +95,40 @@ private fun ToolState.titleText(): String? = when (this) {
95 else -> null
95 else -> null
96}
96}
97
97
98+/** The elapsed wall-clock seconds a tool state ran for, when its time window is known.
99+ * Returns null while a tool is still pending or when the server didn't report timing. */
100+private fun ToolState.durationMs(): Long? {
101+ val time = when (this) {
102+ is ToolRunning -> time
103+ is ToolCompleted -> time
104+ is ToolError -> time
105+ else -> return null
106+ } ?: return null
107+ val start = time.start ?: return null
108+ val end = time.end ?: return null
109+ return (end - start).coerceAtLeast(0L)
110+}
111+
112+/** Compact human-readable duration for a tool call (e.g. "2.3s", "1m 04s"). Sub-second
113+ * runs render in milliseconds so a quick read still shows something distinguishable. */
114+fun formatToolDuration(ms: Long): String {
115+ if (ms < 1_000) return "${ms}ms"
116+ val totalSeconds = ms / 1_000
117+ val tenths = (ms % 1_000) / 100
118+ if (totalSeconds < 60) {
119+ return if (tenths == 0L) "${totalSeconds}s" else "${totalSeconds}.${tenths}s"
120+ }
121+ val minutes = totalSeconds / 60
122+ val seconds = totalSeconds % 60
123+ return if (minutes < 60) {
124+ "${minutes}m ${seconds.toString().padStart(2, '0')}s"
125+ } else {
126+ val hours = minutes / 60
127+ val mins = minutes % 60
128+ "${hours}h ${mins.toString().padStart(2, '0')}m"
129+ }
130+}
131+
98/**
132/**
99 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
133 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
100 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
134 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
@@ -113,6 +147,27 @@ fun PartView(
113) {
147) {
114 when (part) {
148 when (part) {
115 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
149 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
150+ // A synthetic TextPart is server-generated content (e.g. a compacted-conversation
151+ // summary) standing in for real exchanged messages. Mark it with a small muted
152+ // chip above the body so a reader can tell it isn't a verbatim assistant reply —
153+ // important when reviewing a compacted history where the "summary" framing would
154+ // otherwise be indistinguishable from the agent speaking in its own voice.
155+ if (part.synthetic) {
156+ Row(
157+ modifier = modifier.fillMaxWidth(),
158+ verticalAlignment = Alignment.CenterVertically,
159+ ) {
160+ Text(
161+ stringResource(R.string.synthetic_chip),
162+ style = MaterialTheme.typography.labelSmall,
163+ color = MaterialTheme.colorScheme.onSurfaceVariant,
164+ modifier = Modifier
165+ .clip(MaterialTheme.shapes.extraSmall)
166+ .background(MaterialTheme.colorScheme.surfaceVariant)
167+ .padding(horizontal = 6.dp, vertical = 2.dp),
168+ )
169+ }
170+ }
116 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
171 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
117 }
172 }
118 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
173 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
@@ -305,6 +360,18 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
305 fontFamily = FontFamily.Monospace,
360 fontFamily = FontFamily.Monospace,
306 modifier = Modifier.padding(start = 6.dp),
361 modifier = Modifier.padding(start = 6.dp),
307 )
362 )
363+ // Wall-clock duration of the tool call (e.g. "· 2.3s"), shown when the server
364+ // reports both start and end. Omitted while still running (no end yet) and when
365+ // timing wasn't reported — a bare "· 0ms" would be noise. Placed inline with the
366+ // tool name so a glance at the row tells both *what* and *how long*.
367+ part.state.durationMs()?.let { ms ->
368+ Text(
369+ stringResource(R.string.tool_duration_format, formatToolDuration(ms)),
370+ style = MaterialTheme.typography.labelSmall,
371+ color = MaterialTheme.colorScheme.onSurfaceVariant,
372+ modifier = Modifier.padding(start = 6.dp),
373+ )
374+ }
308 }
375 }
309 // A human-readable summary of what the tool was asked to do (e.g. "Reading
376 // A human-readable summary of what the tool was asked to do (e.g. "Reading
310 // src/main.kt"), when the server provides one. Sits directly under the tool
377 // src/main.kt"), when the server provides one. Sits directly under the tool
app/src/main/java/soy/iko/opencode/ui/chat/TodoPlan.kt+35−1
@@ -35,6 +35,7 @@ import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
35import androidx.compose.ui.Modifier
36import androidx.compose.ui.res.stringResource
36import androidx.compose.ui.res.stringResource
37import androidx.compose.ui.semantics.Role
37import androidx.compose.ui.semantics.Role
38+import androidx.compose.ui.semantics.contentDescription
38import androidx.compose.ui.semantics.semantics
39import androidx.compose.ui.semantics.semantics
39import androidx.compose.ui.semantics.stateDescription
40import androidx.compose.ui.semantics.stateDescription
40import androidx.compose.ui.text.font.FontWeight
41import androidx.compose.ui.text.font.FontWeight
@@ -42,7 +43,9 @@ import androidx.compose.ui.text.style.TextDecoration
42import androidx.compose.ui.unit.dp
43import androidx.compose.ui.unit.dp
43import soy.iko.opencode.R
44import soy.iko.opencode.R
44import soy.iko.opencode.data.model.TodoItem
45import soy.iko.opencode.data.model.TodoItem
46+import soy.iko.opencode.data.model.TodoPriority
45import soy.iko.opencode.data.model.TodoStatus
47import soy.iko.opencode.data.model.TodoStatus
48+import soy.iko.opencode.data.model.priorityEnum
46import soy.iko.opencode.data.model.statusEnum
49import soy.iko.opencode.data.model.statusEnum
47import soy.iko.opencode.ui.components.rememberVisibilityTransitions
50import soy.iko.opencode.ui.components.rememberVisibilityTransitions
48
51
@@ -58,6 +61,7 @@ fun TodoPlanChecklist(todos: List<TodoItem>, modifier: Modifier = Modifier) {
58@Composable
61@Composable
59private fun TodoRow(todo: TodoItem) {
62private fun TodoRow(todo: TodoItem) {
60 val status = todo.statusEnum()
63 val status = todo.statusEnum()
64+ val priority = todo.priorityEnum()
61 val struck = status == TodoStatus.COMPLETED || status == TodoStatus.CANCELLED
65 val struck = status == TodoStatus.COMPLETED || status == TodoStatus.CANCELLED
62 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
66 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
63 TodoStatusIcon(status)
67 TodoStatusIcon(status)
@@ -71,8 +75,38 @@ private fun TodoRow(todo: TodoItem) {
71 },
75 },
72 fontWeight = if (status == TodoStatus.IN_PROGRESS) FontWeight.Medium else null,
76 fontWeight = if (status == TodoStatus.IN_PROGRESS) FontWeight.Medium else null,
73 textDecoration = if (struck) TextDecoration.LineThrough else null,
77 textDecoration = if (struck) TextDecoration.LineThrough else null,
74− modifier = Modifier.padding(start = 8.dp),
78+ modifier = Modifier.padding(start = 8.dp).weight(1f),
75 )
79 )
80+ // Priority badge: a small uppercase label to the right of the content. Tinted to
81+ // distinguish high (error) from medium (tertiary) from low (muted); skipped entirely
82+ // when the server didn't report a priority (NONE) so a plain todo doesn't get a
83+ // meaningless "LOW" chip. Struck-through items keep the badge but muted, so a
84+ // completed/cancelled high-priority task still records its original urgency.
85+ if (priority != TodoPriority.NONE) {
86+ val label = when (priority) {
87+ TodoPriority.HIGH -> stringResource(R.string.todo_priority_high)
88+ TodoPriority.MEDIUM -> stringResource(R.string.todo_priority_medium)
89+ TodoPriority.LOW -> stringResource(R.string.todo_priority_low)
90+ TodoPriority.NONE -> ""
91+ }
92+ val tint = when (priority) {
93+ TodoPriority.HIGH -> if (struck) MaterialTheme.colorScheme.onSurfaceVariant
94+ else MaterialTheme.colorScheme.error
95+ TodoPriority.MEDIUM -> if (struck) MaterialTheme.colorScheme.onSurfaceVariant
96+ else MaterialTheme.colorScheme.tertiary
97+ TodoPriority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
98+ TodoPriority.NONE -> MaterialTheme.colorScheme.onSurfaceVariant
99+ }
100+ val a11yLabel = stringResource(R.string.todo_priority_label, label)
101+ Text(
102+ label.uppercase(),
103+ style = MaterialTheme.typography.labelSmall,
104+ color = tint,
105+ modifier = Modifier
106+ .padding(start = 8.dp, top = 2.dp)
107+ .semantics { contentDescription = a11yLabel },
108+ )
109+ }
76 }
110 }
77}
111}
78
112
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+103−14
@@ -24,14 +24,28 @@ fun rememberTtsController(): TtsController {
24 return remember { TtsController(context.applicationContext) }
24 return remember { TtsController(context.applicationContext) }
25}
25}
26
26
27+/**
28+ * Playback state of the current utterance, surfaced so the UI can show a play/pause/stop
29+ * toggle. [PAUSED] is only reached from [PLAYING] via [TtsController.pause]; a fresh toggle
30+ * resets to [IDLE].
31+ */
32+enum class TtsState { IDLE, PLAYING, PAUSED }
33+
27/**
34/**
28 * Thin wrapper over Android [TextToSpeech] for reading an assistant message aloud. Exposes
35 * Thin wrapper over Android [TextToSpeech] for reading an assistant message aloud. Exposes
29− * [speakingId] — the id of the message currently being spoken, or null — so the UI can show
36+ * [speakingId] — the id of the message currently being spoken, or null — and [state] so the
30− * a play/stop toggle. [toggle] plays a message or stops it if it's already the one speaking.
37+ * UI can show a play/pause/stop toggle. [toggle] plays a message or stops it if it's already
38+ * the one speaking. [pause] / [resume] suspend and continue playback without losing position.
31 *
39 *
32 * The engine initializes asynchronously; calls before it's ready are dropped (best-effort —
40 * The engine initializes asynchronously; calls before it's ready are dropped (best-effort —
33 * TTS is an optional convenience). Utterance-progress callbacks arrive on a binder thread,
41 * TTS is an optional convenience). Utterance-progress callbacks arrive on a binder thread,
34 * so state is cleared on the main thread via [mainHandler].
42 * so state is cleared on the main thread via [mainHandler].
43+ *
44+ * Pause/resume: Android's TextToSpeech has no native pause — `stop()` discards the queue.
45+ * To approximate pause, we track the chunk list and the index of the chunk currently playing
46+ * (advanced by `onStart`); pause calls `stop()` and stashes the resume index; resume re-enqueues
47+ * the remaining chunks back-to-back (starting with the paused-from one) so playback continues
48+ * from roughly where it stopped.
35 */
49 */
36class TtsController(context: Context) : RememberObserver {
50class TtsController(context: Context) : RememberObserver {
37
51
@@ -45,11 +59,26 @@ class TtsController(context: Context) : RememberObserver {
45 private val _speakingId = mutableStateOf<String?>(null)
59 private val _speakingId = mutableStateOf<String?>(null)
46 val speakingId: State<String?> = _speakingId
60 val speakingId: State<String?> = _speakingId
47
61
62+ private val _state = mutableStateOf(TtsState.IDLE)
63+ val state: State<TtsState> = _state
64+
65+ // The chunked text for the currently-speaking utterance, plus the index of the chunk
66+ // currently (or about to be) playing. Tracked so pause()/resume() can re-enqueue the
67+ // tail without re-splitting the text. Cleared when playback ends or is stopped.
68+ private var chunks: List<String> = emptyList()
69+ private var currentChunk = 0
70+ private var pausedFromChunk = 0
71+
48 private val tts: TextToSpeech = TextToSpeech(context) { status ->
72 private val tts: TextToSpeech = TextToSpeech(context) { status ->
49 ready = status == TextToSpeech.SUCCESS
73 ready = status == TextToSpeech.SUCCESS
50 }.apply {
74 }.apply {
51 setOnUtteranceProgressListener(object : UtteranceProgressListener() {
75 setOnUtteranceProgressListener(object : UtteranceProgressListener() {
52− override fun onStart(utteranceId: String?) { /* speakingId already set by toggle() */ }
76+ override fun onStart(utteranceId: String?) {
77+ // Advance the current-chunk pointer so pause() can capture the right resume
78+ // index. The tracked id is the final chunk; intermediate ids are "$id#$index".
79+ val idx = utteranceId?.substringAfter('#')?.toIntOrNull()
80+ if (idx != null) currentChunk = idx
81+ }
53 override fun onDone(utteranceId: String?) = clearIfCurrent(utteranceId)
82 override fun onDone(utteranceId: String?) = clearIfCurrent(utteranceId)
54 @Deprecated("Deprecated in Java", ReplaceWith(""))
83 @Deprecated("Deprecated in Java", ReplaceWith(""))
55 override fun onError(utteranceId: String?) = clearIfCurrent(utteranceId)
84 override fun onError(utteranceId: String?) = clearIfCurrent(utteranceId)
@@ -59,7 +88,15 @@ class TtsController(context: Context) : RememberObserver {
59
88
60 /** Clear the speaking state when the utterance that finished is the one we're tracking. */
89 /** Clear the speaking state when the utterance that finished is the one we're tracking. */
61 private fun clearIfCurrent(utteranceId: String?) {
90 private fun clearIfCurrent(utteranceId: String?) {
91+ mainHandler.post {
62− mainHandler.post { if (_speakingId.value == utteranceId) _speakingId.value = null }
92+ if (_speakingId.value == utteranceId) {
93+ _speakingId.value = null
94+ _state.value = TtsState.IDLE
95+ chunks = emptyList()
96+ currentChunk = 0
97+ pausedFromChunk = 0
98+ }
99+ }
63 }
100 }
64
101
65 /**
102 /**
@@ -70,18 +107,31 @@ class TtsController(context: Context) : RememberObserver {
70 * Callers use this to surface feedback instead of the button appearing dead.
107 * Callers use this to surface feedback instead of the button appearing dead.
71 */
108 */
72 fun toggle(id: String, text: String): Boolean {
109 fun toggle(id: String, text: String): Boolean {
73− if (_speakingId.value == id) { stop(); return true }
110+ if (_speakingId.value == id) {
111+ // Tapping play on the already-speaking message stops it (matching the prior
112+ // behavior and the Stop icon the UI shows while playing).
113+ stop(); return true
114+ }
74 if (!ready || text.isBlank()) return false
115 if (!ready || text.isBlank()) return false
75 // TextToSpeech.speak() silently returns ERROR (enqueuing nothing, firing no callback)
116 // TextToSpeech.speak() silently returns ERROR (enqueuing nothing, firing no callback)
76 // when a single input exceeds getMaxSpeechInputLength(), which would leave the Stop
117 // when a single input exceeds getMaxSpeechInputLength(), which would leave the Stop
77 // button stuck with no audio. Chunk long text and enqueue the parts back-to-back.
118 // button stuck with no audio. Chunk long text and enqueue the parts back-to-back.
78− val chunks = chunkForTts(text)
119+ val split = chunkForTts(text)
79− if (chunks.isEmpty()) return false
120+ if (split.isEmpty()) return false
80− chunks.forEachIndexed { index, chunk ->
121+ startPlayback(id, split, fromChunk = 0)
122+ return true
123+ }
124+
125+ /** Enqueue [split] for [id] starting at [fromChunk], marking the message as playing. */
126+ private fun startPlayback(id: String, split: List<String>, fromChunk: Int) {
127+ chunks = split
128+ currentChunk = fromChunk
129+ for (index in fromChunk until split.size) {
130+ val chunk = split[index]
81− val queueMode = if (index == 0) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
131+ val queueMode = if (index == fromChunk) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
82 // Only the final chunk carries the tracked [id]; intermediate chunks get a
132 // Only the final chunk carries the tracked [id]; intermediate chunks get a
83 // distinct id so their per-utterance onDone doesn't clear the Stop state early.
133 // distinct id so their per-utterance onDone doesn't clear the Stop state early.
84− val utteranceId = if (index == chunks.lastIndex) id else "$id#$index"
134+ val utteranceId = if (index == split.lastIndex) id else "$id#$index"
85 val result = tts.speak(chunk, queueMode, null, utteranceId)
135 val result = tts.speak(chunk, queueMode, null, utteranceId)
86 // If any chunk can't be enqueued, the final chunk's onDone (which would clear
136 // If any chunk can't be enqueued, the final chunk's onDone (which would clear
87 // the Stop state) never fires — leaving the Stop button stuck with partial or
137 // the Stop state) never fires — leaving the Stop button stuck with partial or
@@ -89,13 +139,47 @@ class TtsController(context: Context) : RememberObserver {
89 // stick. A mid-queue failure also breaks the contiguous playback, so stopping
139 // stick. A mid-queue failure also breaks the contiguous playback, so stopping
90 // is the safe default rather than playing a truncated prefix silently.
140 // is the safe default rather than playing a truncated prefix silently.
91 if (result != TextToSpeech.SUCCESS) {
141 if (result != TextToSpeech.SUCCESS) {
142+ if (index == fromChunk) {
92− if (index == 0) _speakingId.value = null
143+ _speakingId.value = null
144+ _state.value = TtsState.IDLE
145+ chunks = emptyList()
146+ currentChunk = 0
93− else stop()
147+ } else {
148+ stop()
149+ }
94− return false
150+ return
95 }
151 }
96 }
152 }
97 _speakingId.value = id
153 _speakingId.value = id
98− return true
154+ _state.value = TtsState.PLAYING
155+ }
156+
157+ /**
158+ * Pause playback if a message is currently playing. Android's TextToSpeech has no native
159+ * pause, so this stops the engine and stashes the index of the chunk that was playing;
160+ * [resume] re-enqueues from there. No-op when not playing (e.g. already paused or idle).
161+ */
162+ fun pause() {
163+ if (_state.value != TtsState.PLAYING) return
164+ // Capture the chunk currently playing so resume restarts from it rather than the next
165+ // one — otherwise pause skips a chunk. currentChunk is advanced by onStart, so for a
166+ // mid-utterance pause this points at the chunk that was interrupted.
167+ pausedFromChunk = currentChunk
168+ tts.stop()
169+ _state.value = TtsState.PAUSED
170+ }
171+
172+ /**
173+ * Resume playback after [pause], re-enqueuing the remaining chunks from the paused-from
174+ * index. No-op when not paused (e.g. idle or already playing).
175+ */
176+ fun resume() {
177+ if (_state.value != TtsState.PAUSED) return
178+ val id = _speakingId.value ?: return
179+ val split = chunks
180+ val from = pausedFromChunk.coerceIn(0, split.lastIndex.coerceAtLeast(0))
181+ if (split.isEmpty()) { stop(); return }
182+ startPlayback(id, split, fromChunk = from)
99 }
183 }
100
184
101 /**
185 /**
@@ -133,6 +217,10 @@ class TtsController(context: Context) : RememberObserver {
133 fun stop() {
217 fun stop() {
134 tts.stop()
218 tts.stop()
135 _speakingId.value = null
219 _speakingId.value = null
220+ _state.value = TtsState.IDLE
221+ chunks = emptyList()
222+ currentChunk = 0
223+ pausedFromChunk = 0
136 }
224 }
137
225
138 fun shutdown() {
226 fun shutdown() {
@@ -141,6 +229,7 @@ class TtsController(context: Context) : RememberObserver {
141 tts.stop()
229 tts.stop()
142 tts.shutdown()
230 tts.shutdown()
143 _speakingId.value = null
231 _speakingId.value = null
232+ _state.value = TtsState.IDLE
144 }
233 }
145
234
146 // RememberObserver: release the engine whenever this instance leaves the composition,
235 // RememberObserver: release the engine whenever this instance leaves the composition,
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+12−2
@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.file
1package soy.iko.opencode.ui.file
2
2
3+import androidx.activity.compose.BackHandler
3import androidx.compose.foundation.ExperimentalFoundationApi
4import androidx.compose.foundation.ExperimentalFoundationApi
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
5import androidx.compose.foundation.combinedClickable
6import androidx.compose.foundation.combinedClickable
@@ -559,7 +560,12 @@ private fun DirectoryListing(
559 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
560 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
560 verticalAlignment = Alignment.CenterVertically,
561 verticalAlignment = Alignment.CenterVertically,
561 ) {
562 ) {
563+ // rememberSaveable so an open sort dropdown survives a rotation instead of
564+ // closing and leaving the user to re-open it.
562− var showSortMenu by remember { mutableStateOf(false) }
565+ var showSortMenu by rememberSaveable { mutableStateOf(false) }
566+ // Close the sort dropdown on back press instead of navigating away, matching
567+ // the session/server list dropdown back-handling.
568+ BackHandler(enabled = showSortMenu) { showSortMenu = false }
563 androidx.compose.material3.FilterChip(
569 androidx.compose.material3.FilterChip(
564 selected = sortKey != FileSortKey.NAME || sortDesc,
570 selected = sortKey != FileSortKey.NAME || sortDesc,
565 onClick = { showSortMenu = true },
571 onClick = { showSortMenu = true },
@@ -657,7 +663,11 @@ private fun FileRow(
657 val copyPathLabel = stringResource(R.string.copy_path)
663 val copyPathLabel = stringResource(R.string.copy_path)
658 // Long-press → "Copy path" dropdown. Rendered only when [onCopyPath] is supplied, so the
664 // Long-press → "Copy path" dropdown. Rendered only when [onCopyPath] is supplied, so the
659 // parent ".." row (which passes none) stays a plain tap target. Anchored within the row.
665 // parent ".." row (which passes none) stays a plain tap target. Anchored within the row.
666+ // rememberSaveable so an open row menu survives a rotation instead of closing.
660− var menu by remember { mutableStateOf(false) }
667+ var menu by rememberSaveable { mutableStateOf(false) }
668+ // Close this row's overflow on back press instead of navigating away, matching the
669+ // sort menu BackHandler above.
670+ BackHandler(enabled = menu) { menu = false }
661 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
671 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
662 else if (icon) stringResource(R.string.folder, label)
672 else if (icon) stringResource(R.string.folder, label)
663 else stringResource(R.string.file_label, label)
673 else stringResource(R.string.file_label, label)
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+17−1
@@ -69,6 +69,7 @@ import androidx.compose.ui.semantics.semantics
69import androidx.compose.ui.text.style.TextOverflow
69import androidx.compose.ui.text.style.TextOverflow
70import androidx.compose.ui.unit.dp
70import androidx.compose.ui.unit.dp
71import androidx.compose.ui.res.stringResource
71import androidx.compose.ui.res.stringResource
72+import androidx.activity.compose.BackHandler
72import androidx.activity.compose.rememberLauncherForActivityResult
73import androidx.activity.compose.rememberLauncherForActivityResult
73import androidx.lifecycle.compose.collectAsStateWithLifecycle
74import androidx.lifecycle.compose.collectAsStateWithLifecycle
74import androidx.lifecycle.viewmodel.compose.viewModel
75import androidx.lifecycle.viewmodel.compose.viewModel
@@ -203,7 +204,12 @@ fun ServerListScreen(
203 // servers to order; with one or two it's faster to scan by eye.
204 // servers to order; with one or two it's faster to scan by eye.
204 if (profiles.size > 1) {
205 if (profiles.size > 1) {
205 val sortMode by vm.sortMode.collectAsStateWithLifecycle()
206 val sortMode by vm.sortMode.collectAsStateWithLifecycle()
207+ // rememberSaveable so an open sort dropdown survives a rotation instead
208+ // of closing and leaving the user to re-open it.
206− var sortMenu by remember { mutableStateOf(false) }
209+ var sortMenu by rememberSaveable { mutableStateOf(false) }
210+ // Close the sort dropdown on back press instead of navigating away,
211+ // matching the session list's dropdown back-handling.
212+ BackHandler(enabled = sortMenu) { sortMenu = false }
207 IconButton(onClick = { sortMenu = true }) {
213 IconButton(onClick = { sortMenu = true }) {
208 Icon(Icons.Filled.Sort, contentDescription = stringResource(R.string.sort_by))
214 Icon(Icons.Filled.Sort, contentDescription = stringResource(R.string.sort_by))
209 }
215 }
@@ -521,6 +527,9 @@ private fun ServerCardContent(
521 )
527 )
522 } else {
528 } else {
523 var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
529 var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
530+ // Close this row's overflow on back press instead of navigating away, matching
531+ // the sort menu BackHandler above.
532+ BackHandler(enabled = showRowMenu) { showRowMenu = false }
524 val editLabel = stringResource(R.string.edit)
533 val editLabel = stringResource(R.string.edit)
525 val duplicateLabel = stringResource(R.string.duplicate_server)
534 val duplicateLabel = stringResource(R.string.duplicate_server)
526 val removeLabel = stringResource(R.string.remove)
535 val removeLabel = stringResource(R.string.remove)
@@ -670,6 +679,7 @@ private fun filterServerProfiles(profiles: List<ServerProfile>, query: String):
670 * its cyclomatic complexity under the detekt threshold. */
679 * its cyclomatic complexity under the detekt threshold. */
671@Composable
680@Composable
672private fun ServerListSearchField(query: String, onQueryChange: (String) -> Unit) {
681private fun ServerListSearchField(query: String, onQueryChange: (String) -> Unit) {
682+ val keyboard = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
673 OutlinedTextField(
683 OutlinedTextField(
674 value = query,
684 value = query,
675 onValueChange = onQueryChange,
685 onValueChange = onQueryChange,
@@ -684,5 +694,11 @@ private fun ServerListSearchField(query: String, onQueryChange: (String) -> Unit
684 }
694 }
685 }
695 }
686 },
696 },
697+ // Match the session/file search fields: ImeAction.Search dismisses the keyboard on
698+ // submit, so a user filtering servers isn't left with the IME covering the list.
699+ keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
700+ imeAction = androidx.compose.ui.text.input.ImeAction.Search,
701+ ),
702+ keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { keyboard?.hide() }),
687 )
703 )
688}
704}
app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+42−15
@@ -187,22 +187,12 @@ fun NewSessionDialog(
187 onSelect = { choice = DirChoice.Custom },
187 onSelect = { choice = DirChoice.Custom },
188 )
188 )
189 if (choice is DirChoice.Custom) {
189 if (choice is DirChoice.Custom) {
190− // Autofocus the path field the moment the Custom row is chosen so the
190+ CustomPathField(
191− // keyboard is ready without a second tap.
192− val customFocus = remember { FocusRequester() }
193− LaunchedEffect(Unit) { customFocus.requestFocus() }
194− OutlinedTextField(
195− value = customText,
191+ text = customText,
196− onValueChange = { customText = it },
192+ onTextChange = { customText = it },
197− modifier = Modifier
198− .fillMaxWidth()
199− .focusRequester(customFocus)
200− .padding(start = 36.dp, top = 4.dp, bottom = 4.dp),
201− placeholder = { Text(stringResource(R.string.directory_custom_hint)) },
202− singleLine = true,
203 enabled = !creating,
193 enabled = !creating,
204− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
194+ canCreate = canCreate,
205− keyboardActions = KeyboardActions(onDone = { if (canCreate) onCreate(resolved, resolvedTitle) }),
195+ onCreate = { onCreate(resolved, resolvedTitle) },
206 )
196 )
207 }
197 }
208
198
@@ -247,6 +237,43 @@ private fun TitleCharCounter(length: Int, limit: Int) {
247 }
237 }
248}
238}
249
239
240+/** The custom-path text field shown when the Custom directory choice is selected. Autofocuses
241+ * on appearance and marks itself error when blank so the user sees why Create is disabled.
242+ * Extracted from [NewSessionDialog] to keep its cyclomatic complexity under the threshold. */
243+@Composable
244+private fun CustomPathField(
245+ text: String,
246+ onTextChange: (String) -> Unit,
247+ enabled: Boolean,
248+ canCreate: Boolean,
249+ onCreate: () -> Unit,
250+) {
251+ // Autofocus the path field the moment the Custom row is chosen so the keyboard is ready
252+ // without a second tap.
253+ val customFocus = remember { FocusRequester() }
254+ LaunchedEffect(Unit) { customFocus.requestFocus() }
255+ // Mark the field error when Custom is selected but the path is still blank, so the user
256+ // sees *why* Create is disabled instead of inferring it from the disabled button alone.
257+ val customBlank = text.isBlank()
258+ OutlinedTextField(
259+ value = text,
260+ onValueChange = onTextChange,
261+ modifier = Modifier
262+ .fillMaxWidth()
263+ .focusRequester(customFocus)
264+ .padding(start = 36.dp, top = 4.dp, bottom = 4.dp),
265+ placeholder = { Text(stringResource(R.string.directory_custom_hint)) },
266+ singleLine = true,
267+ enabled = enabled,
268+ isError = customBlank,
269+ supportingText = if (customBlank) {
270+ { Text(stringResource(R.string.custom_path_required), color = MaterialTheme.colorScheme.error) }
271+ } else null,
272+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
273+ keyboardActions = KeyboardActions(onDone = { if (canCreate) onCreate() }),
274+ )
275+}
276+
250@Composable
277@Composable
251private fun DirectoryRow(
278private fun DirectoryRow(
252 label: String,
279 label: String,
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+4
@@ -200,6 +200,7 @@ fun SessionListScreen(
200 // Close the open dropdown on back press instead of navigating away.
200 // Close the open dropdown on back press instead of navigating away.
201 BackHandler(enabled = showServerMenu) { showServerMenu = false }
201 BackHandler(enabled = showServerMenu) { showServerMenu = false }
202 BackHandler(enabled = showSortMenu) { showSortMenu = false }
202 BackHandler(enabled = showSortMenu) { showSortMenu = false }
203+ BackHandler(enabled = showMainMenu) { showMainMenu = false }
203
204
204 // Dismiss the server switcher once an in-flight switch resolves (switchingId returns to
205 // Dismiss the server switcher once an in-flight switch resolves (switchingId returns to
205 // null), so its per-row spinner stays visible for the whole switch instead of the menu
206 // null), so its per-row spinner stays visible for the whole switch instead of the menu
@@ -1128,6 +1129,9 @@ private fun SessionCard(
1128 // long list look busy. Swipe-to-delete and the overflow cover the same
1129 // long list look busy. Swipe-to-delete and the overflow cover the same
1129 // actions; swipe remains the gesture path, overflow the discovery path.
1130 // actions; swipe remains the gesture path, overflow the discovery path.
1130 var showRowMenu by rememberSaveable(session.id) { mutableStateOf(false) }
1131 var showRowMenu by rememberSaveable(session.id) { mutableStateOf(false) }
1132+ // Close this row's overflow on back press instead of navigating away, matching
1133+ // the top-bar dropdowns (showServerMenu/showSortMenu/showMainMenu).
1134+ BackHandler(enabled = showRowMenu) { showRowMenu = false }
1131 val renameLabel = stringResource(R.string.rename)
1135 val renameLabel = stringResource(R.string.rename)
1132 val deleteLabel = stringResource(R.string.delete)
1136 val deleteLabel = stringResource(R.string.delete)
1133 // Include the session title in the overflow button's a11y label so a TalkBack
1137 // Include the session title in the overflow button's a11y label so a TalkBack
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+13−1
@@ -21,6 +21,7 @@ import androidx.compose.material.icons.Icons
21import androidx.compose.material.icons.filled.BugReport
21import androidx.compose.material.icons.filled.BugReport
22import androidx.compose.material.icons.filled.Close
22import androidx.compose.material.icons.filled.Close
23import androidx.compose.material.icons.filled.ContentCopy
23import androidx.compose.material.icons.filled.ContentCopy
24+import androidx.activity.compose.BackHandler
24import androidx.activity.compose.rememberLauncherForActivityResult
25import androidx.activity.compose.rememberLauncherForActivityResult
25import androidx.compose.material.icons.filled.Delete
26import androidx.compose.material.icons.filled.Delete
26import androidx.compose.material.icons.filled.DeleteSweep
27import androidx.compose.material.icons.filled.DeleteSweep
@@ -256,7 +257,11 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
256 crashQuery = it
257 crashQuery = it
257 }
258 }
258 items(filtered, key = { it.fileName }) { report ->
259 items(filtered, key = { it.fileName }) { report ->
260+ // rememberSaveable so an open row menu survives a rotation; BackHandler
261+ // so system back closes it instead of navigating away, matching the
262+ // session/server/file list dropdown back-handling.
259− var menuExpanded by remember { mutableStateOf(false) }
263+ var menuExpanded by rememberSaveable(report.fileName) { mutableStateOf(false) }
264+ BackHandler(enabled = menuExpanded) { menuExpanded = false }
260 // Swipe end-to-start reveals a delete affordance. The delete is
265 // Swipe end-to-start reveals a delete affordance. The delete is
261 // deferred for an undo window (matching the session/server lists);
266 // deferred for an undo window (matching the session/server lists);
262 // confirmValueChange snaps back so the row remains while the undo
267 // confirmValueChange snaps back so the row remains while the undo
@@ -542,6 +547,7 @@ private fun filterCrashReports(
542 * its cyclomatic complexity under the detekt threshold. */
547 * its cyclomatic complexity under the detekt threshold. */
543@Composable
548@Composable
544private fun CrashQueryField(query: String, onQueryChange: (String) -> Unit) {
549private fun CrashQueryField(query: String, onQueryChange: (String) -> Unit) {
550+ val keyboard = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
545 OutlinedTextField(
551 OutlinedTextField(
546 value = query,
552 value = query,
547 onValueChange = onQueryChange,
553 onValueChange = onQueryChange,
@@ -556,6 +562,12 @@ private fun CrashQueryField(query: String, onQueryChange: (String) -> Unit) {
556 }
562 }
557 }
563 }
558 },
564 },
565+ // Match the session/file/server search fields: ImeAction.Search dismisses the keyboard
566+ // on submit so the IME doesn't keep covering the crash list after filtering.
567+ keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
568+ imeAction = androidx.compose.ui.text.input.ImeAction.Search,
569+ ),
570+ keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { keyboard?.hide() }),
559 )
571 )
560}
572}
561
573
app/src/main/res/values-es/strings.xml+20
@@ -681,4 +681,24 @@
681 <item quantity="one">%1$d palabra oculta</item>
681 <item quantity="one">%1$d palabra oculta</item>
682 <item quantity="other">%1$d palabras ocultas</item>
682 <item quantity="other">%1$d palabras ocultas</item>
683 </plurals>
683 </plurals>
684+
685+ <!-- UI/UX batch 5: tool durations, todo priority, synthetic text, token breakdown, share, TTS, revert diff, agent error, path validation -->
686+ <string name="tool_duration_format">· %1$s</string>
687+ <string name="todo_priority_high">Alta</string>
688+ <string name="todo_priority_medium">Media</string>
689+ <string name="todo_priority_low">Baja</string>
690+ <string name="todo_priority_label">Prioridad: %1$s</string>
691+ <string name="synthetic_label">Resumen</string>
692+ <string name="synthetic_chip">Resumido</string>
693+ <string name="tokens_reasoning_format">%1$s de razonamiento</string>
694+ <string name="tokens_cache_read_format">%1$s caché leído</string>
695+ <string name="tokens_cache_write_format">%1$s caché escrito</string>
696+ <string name="share_message">Compartir mensaje</string>
697+ <string name="pause_reading">Pausar lectura</string>
698+ <string name="resume_reading">Reanudar lectura</string>
699+ <string name="revert_show_diff">Ver cambios</string>
700+ <string name="revert_hide_diff">Ocultar cambios</string>
701+ <string name="revert_diff_header">Cambios del revertido</string>
702+ <string name="agent_error_label">El agente informó un error</string>
703+ <string name="custom_path_required">Escribe una ruta para crear una sesión aquí.</string>
684</resources>
704</resources>
app/src/main/res/values/strings.xml+20
@@ -681,4 +681,24 @@
681 <item quantity="one">%1$d word hidden</item>
681 <item quantity="one">%1$d word hidden</item>
682 <item quantity="other">%1$d words hidden</item>
682 <item quantity="other">%1$d words hidden</item>
683 </plurals>
683 </plurals>
684+
685+ <!-- UI/UX batch 5: tool durations, todo priority, synthetic text, token breakdown, share, TTS, revert diff, agent error, path validation -->
686+ <string name="tool_duration_format">· %1$s</string>
687+ <string name="todo_priority_high">High</string>
688+ <string name="todo_priority_medium">Medium</string>
689+ <string name="todo_priority_low">Low</string>
690+ <string name="todo_priority_label">Priority: %1$s</string>
691+ <string name="synthetic_label">Summary</string>
692+ <string name="synthetic_chip">Summarized</string>
693+ <string name="tokens_reasoning_format">%1$s reasoning</string>
694+ <string name="tokens_cache_read_format">%1$s cache read</string>
695+ <string name="tokens_cache_write_format">%1$s cache write</string>
696+ <string name="share_message">Share message</string>
697+ <string name="pause_reading">Pause reading</string>
698+ <string name="resume_reading">Resume reading</string>
699+ <string name="revert_show_diff">Show what changed</string>
700+ <string name="revert_hide_diff">Hide changes</string>
701+ <string name="revert_diff_header">Changes from revert</string>
702+ <string name="agent_error_label">The agent reported an error</string>
703+ <string name="custom_path_required">Enter a path to create a session here.</string>
684</resources>
704</resources>
app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.kt+28
@@ -89,4 +89,32 @@ class ConversationExportTest {
89 assertTrue(md.contains("**edit**"))
89 assertTrue(md.contains("**edit**"))
90 assertTrue(md.contains("Editing src/main.kt"))
90 assertTrue(md.contains("Editing src/main.kt"))
91 }
91 }
92+
93+ @Test
94+ fun buildMessageMarkdownRendersAssistantReply() {
95+ val md = buildMessageMarkdown(assistant("a1", "Hi there!"))
96+ assertNotNull(md)
97+ assertTrue(md!!.startsWith("## opencode"))
98+ assertTrue(md.contains("Hi there!"))
99+ }
100+
101+ @Test
102+ fun buildMessageMarkdownRendersUserMessage() {
103+ val md = buildMessageMarkdown(user("u1", "Hello"))
104+ assertNotNull(md)
105+ assertTrue(md!!.startsWith("## You"))
106+ assertTrue(md.contains("Hello"))
107+ }
108+
109+ @Test
110+ fun buildMessageMarkdownReturnsNullForEmptyMessage() {
111+ val empty = MessageWithParts(
112+ AssistantMessage("a1", sid),
113+ listOf(ReasoningPart(id = "r1", text = "")),
114+ )
115+ assertNull(buildMessageMarkdown(empty))
116+ }
117+
118+ private fun assertNotNull(value: String?) = org.junit.Assert.assertNotNull(value)
119+ private fun assertNull(value: String?) = org.junit.Assert.assertNull(value)
92}
120}
app/src/test/java/soy/iko/opencode/ui/chat/ToolDurationFormatTest.ktnew file+45
@@ -0,0 +1,45 @@
1+package soy.iko.opencode.ui.chat
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Test
5+
6+class ToolDurationFormatTest {
7+
8+ @Test
9+ fun subSecondRendersInMilliseconds() {
10+ assertEquals("0ms", formatToolDuration(0))
11+ assertEquals("123ms", formatToolDuration(123))
12+ assertEquals("999ms", formatToolDuration(999))
13+ }
14+
15+ @Test
16+ fun underTenSecondsRendersWithTenths() {
17+ assertEquals("1s", formatToolDuration(1_000))
18+ assertEquals("1.5s", formatToolDuration(1_500))
19+ assertEquals("2.3s", formatToolDuration(2_300))
20+ assertEquals("9.9s", formatToolDuration(9_900))
21+ }
22+
23+ @Test
24+ fun wholeSecondsOmitTenths() {
25+ assertEquals("2s", formatToolDuration(2_000))
26+ assertEquals("10s", formatToolDuration(10_000))
27+ }
28+
29+ @Test
30+ fun underAMinuteRendersSeconds() {
31+ assertEquals("59s", formatToolDuration(59_000))
32+ }
33+
34+ @Test
35+ fun minutesPadSeconds() {
36+ assertEquals("1m 04s", formatToolDuration(64_000))
37+ assertEquals("2m 09s", formatToolDuration(129_000))
38+ }
39+
40+ @Test
41+ fun hoursPadMinutes() {
42+ assertEquals("1h 05m", formatToolDuration(3_900_000))
43+ assertEquals("2h 00m", formatToolDuration(7_200_000))
44+ }
45+}