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)
commit06fd41b542af35267a4970b2c9a05e40406bdf26 authoriko <mail@iko.soy> dateSat 4 Jul 2026, 02:30 (3 months ago) parent4fd9a39
18 files changed+831−140
app/src/main/java/soy/iko/opencode/data/model/Todo.kt+11 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+103−27 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+18−2 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+39 app/src/main/java/soy/iko/opencode/ui/chat/CostFormat.kt+60 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+194−77 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+67 app/src/main/java/soy/iko/opencode/ui/chat/TodoPlan.kt+35−1 app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+103−14 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+12−2 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+17−1 app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+42−15 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+4 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+13−1 app/src/main/res/values-es/strings.xml+20 app/src/main/res/values/strings.xml+20 app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.kt+28 app/src/test/java/soy/iko/opencode/ui/chat/ToolDurationFormatTest.kt+45
app/src/main/java/soy/iko/opencode/data/model/Todo.kt+11
@@ -27,6 +27,17 @@ data class TodoItem(
2727/** Normalized lifecycle of a [TodoItem]; [UNKNOWN] covers any status the client doesn't model. */
2828enum class TodoStatus { PENDING, IN_PROGRESS, COMPLETED, CANCELLED, UNKNOWN }
2929
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+
3041fun TodoItem.statusEnum(): TodoStatus = when (status.trim().lowercase()) {
3142 "pending", "todo", "" -> TodoStatus.PENDING
3243 "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
5252import androidx.compose.material.icons.filled.Close
5353import androidx.compose.material.icons.filled.Edit
5454import androidx.compose.material.icons.filled.Expand
55+import androidx.compose.material.icons.filled.ExpandLess
56+import androidx.compose.material.icons.filled.ExpandMore
5557import androidx.compose.material.icons.filled.Search
5658import androidx.compose.material.icons.filled.KeyboardArrowDown
5759import androidx.compose.material.icons.filled.KeyboardArrowUp
@@ -160,6 +162,7 @@ import soy.iko.opencode.di.AppContainer
160162import soy.iko.opencode.R
161163import soy.iko.opencode.ui.components.CommandPalette
162164import soy.iko.opencode.ui.components.ConnectionBanner
165+import soy.iko.opencode.ui.components.DiffView
163166import soy.iko.opencode.ui.components.PaletteAction
164167import soy.iko.opencode.ui.components.LocalReducedMotion
165168import soy.iko.opencode.ui.components.copyToClipboard
@@ -206,6 +209,7 @@ fun ChatScreen(
206209 val draft by vm.draft.collectAsStateWithLifecycle()
207210 val attachments by vm.attachments.collectAsStateWithLifecycle()
208211 val reverted by vm.reverted.collectAsStateWithLifecycle()
212+ val revertDiff by vm.revertDiff.collectAsStateWithLifecycle()
209213 val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
210214 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
211215 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
@@ -758,7 +762,10 @@ fun ChatScreen(
758762 enter = bannerMotion.enter,
759763 exit = bannerMotion.exit,
760764 ) {
761− RevertBanner(onUndo = { vm.unrevert() })
765+ RevertBanner(
766+ diff = revertDiff,
767+ onUndo = { vm.unrevert() },
768+ )
762769 }
763770 AnimatedVisibility(
764771 visible = queuedOffline.isNotEmpty(),
@@ -1122,6 +1129,7 @@ fun ChatScreen(
11221129 // message list leaves composition.
11231130 val tts = rememberTtsController()
11241131 val speakingMessageId by tts.speakingId
1132+ val ttsState by tts.state
11251133 LazyColumn(
11261134 state = listState,
11271135 modifier = Modifier.fillMaxSize(),
@@ -1251,6 +1259,9 @@ fun ChatScreen(
12511259 }
12521260 },
12531261 isSpeaking = message.info.id == speakingMessageId,
1262+ ttsState = if (message.info.id == speakingMessageId) ttsState else TtsState.IDLE,
1263+ onPause = { tts.pause() },
1264+ onResume = { tts.resume() },
12541265 onQuote = { text ->
12551266 vm.quoteReply(text)
12561267 runCatching { inputFocusRequester.requestFocus() }
@@ -1280,6 +1291,23 @@ fun ChatScreen(
12801291 null
12811292 }
12821293 },
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+ },
12831311 )
12841312 }
12851313 }
@@ -2175,40 +2203,88 @@ private fun EmptyConversation(
21752203 }
21762204}
21772205
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. */
21792209@Composable
2180−private fun RevertBanner(onUndo: () -> Unit) {
2210+private fun RevertBanner(diff: String?, onUndo: () -> Unit) {
21812211 val haptics = LocalHapticFeedback.current
2212+ var showDiff by rememberSaveable { mutableStateOf(false) }
2213+ val expandMotion = rememberVisibilityTransitions()
21822214 Surface(
21832215 color = MaterialTheme.colorScheme.tertiaryContainer,
21842216 tonalElevation = 2.dp,
21852217 modifier = Modifier.fillMaxWidth(),
21862218 ) {
2187− Row(
2188− modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2189− verticalAlignment = Alignment.CenterVertically,
2190− horizontalArrangement = Arrangement.SpaceBetween,
2191− ) {
2192− Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2193− Icon(
2194− Icons.Filled.Restore,
2195− contentDescription = null,
2196− tint = MaterialTheme.colorScheme.onTertiaryContainer,
2197− modifier = Modifier.size(18.dp),
2198− )
2199− Text(
2200− stringResource(R.string.reverted_banner),
2201− style = MaterialTheme.typography.bodySmall,
2202− color = MaterialTheme.colorScheme.onTertiaryContainer,
2203− modifier = Modifier.padding(start = 8.dp),
2204− )
2219+ Column {
2220+ Row(
2221+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2222+ verticalAlignment = Alignment.CenterVertically,
2223+ horizontalArrangement = Arrangement.SpaceBetween,
2224+ ) {
2225+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2226+ Icon(
2227+ Icons.Filled.Restore,
2228+ contentDescription = null,
2229+ tint = MaterialTheme.colorScheme.onTertiaryContainer,
2230+ modifier = Modifier.size(18.dp),
2231+ )
2232+ Text(
2233+ stringResource(R.string.reverted_banner),
2234+ style = MaterialTheme.typography.bodySmall,
2235+ color = MaterialTheme.colorScheme.onTertiaryContainer,
2236+ modifier = Modifier.padding(start = 8.dp),
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+ }
22052287 }
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)) }
22122288 }
22132289 }
22142290}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+18−2
@@ -529,6 +529,12 @@ class ChatViewModel(
529529 private val _reverted = MutableStateFlow(false)
530530 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
531531
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+
532538 /** The active public share URL for this session, or null when not shared. */
533539 private val _shareUrl = MutableStateFlow<String?>(null)
534540 val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
@@ -713,6 +719,7 @@ class ChatViewModel(
713719 clearPermissions()
714720 sessionAllowed.clear()
715721 sessionTitleJob?.cancel()
722+ _revertDiff.value = null
716723 return@collectLatest
717724 }
718725 _running.value = false
@@ -723,6 +730,7 @@ class ChatViewModel(
723730 clearPermissions()
724731 sessionAllowed.clear()
725732 _failedDraft.value = null
733+ _revertDiff.value = null
726734 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
727735 // user intent that is now persisted; wiping it on every (re)connect — which
728736 // this collector does, including transient SSE drops and the cold-start
@@ -743,6 +751,7 @@ class ChatViewModel(
743751 if (_sessionTitle.value == null) {
744752 _sessionTitle.value = session.displayTitle
745753 _reverted.value = session.isReverted
754+ _revertDiff.value = session.revert?.diff?.takeIf { it.isNotBlank() }
746755 _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
747756 }
748757 }
@@ -800,6 +809,7 @@ class ChatViewModel(
800809 val info = event.properties.info
801810 _sessionTitle.value = info.displayTitle
802811 _reverted.value = info.isReverted
812+ _revertDiff.value = info.revert?.diff?.takeIf { it.isNotBlank() }
803813 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
804814 }
805815 is SessionDeleted ->
@@ -1275,7 +1285,10 @@ class ChatViewModel(
12751285 val conn = connection ?: return
12761286 viewModelScope.launch {
12771287 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1278− .onSuccess { _reverted.value = it.isReverted }
1288+ .onSuccess {
1289+ _reverted.value = it.isReverted
1290+ _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1291+ }
12791292 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
12801293 }
12811294 }
@@ -1362,7 +1375,10 @@ class ChatViewModel(
13621375 val conn = connection ?: return
13631376 viewModelScope.launch {
13641377 runCatchingCancellable { conn.api.unrevert(sessionId) }
1365− .onSuccess { _reverted.value = it.isReverted }
1378+ .onSuccess {
1379+ _reverted.value = it.isReverted
1380+ _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1381+ }
13661382 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
13671383 }
13681384 }
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+39
@@ -68,6 +68,45 @@ fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?):
6868 return sb.toString().trimEnd()
6969}
7070
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+
71110/** Format an epoch-millis as a locale-stable ISO-ish timestamp for the export. Thread-confined
72111 * to the exporting coroutine, so a shared SimpleDateFormat (not thread-safe) is fine. */
73112private 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
2222import androidx.compose.material.icons.filled.Error
2323import androidx.compose.material.icons.filled.FormatQuote
2424import androidx.compose.material.icons.filled.MoreVert
25+import androidx.compose.material.icons.filled.Pause
26+import androidx.compose.material.icons.filled.PlayArrow
2527import androidx.compose.material.icons.filled.Refresh
2628import androidx.compose.material.icons.filled.Restore
29+import androidx.compose.material.icons.filled.Share
2730import androidx.compose.material.icons.filled.SmartToy
28−import androidx.compose.material.icons.filled.StopCircle
2931import androidx.compose.material3.DropdownMenu
3032import androidx.compose.material3.DropdownMenuItem
3133import androidx.compose.material3.CircularProgressIndicator
@@ -60,7 +62,6 @@ import soy.iko.opencode.data.model.MessageWithParts
6062import soy.iko.opencode.data.model.ModelOption
6163import soy.iko.opencode.data.model.FilePart
6264import soy.iko.opencode.data.model.TextPart
63−import soy.iko.opencode.data.model.Tokens
6465import soy.iko.opencode.data.model.UnknownMessage
6566import soy.iko.opencode.data.model.UserMessage
6667import soy.iko.opencode.data.network.NetworkConfig
@@ -100,6 +101,9 @@ fun MessageBubble(
100101 onEdit: ((String) -> Unit)? = null,
101102 onSpeak: ((String) -> Unit)? = null,
102103 isSpeaking: Boolean = false,
104+ ttsState: TtsState = TtsState.IDLE,
105+ onPause: (() -> Unit)? = null,
106+ onResume: (() -> Unit)? = null,
103107 onQuote: ((String) -> Unit)? = null,
104108 onBranch: ((String) -> Unit)? = null,
105109 onRegenerate: (() -> Unit)? = null,
@@ -107,11 +111,12 @@ fun MessageBubble(
107111 isEdited: Boolean = false,
108112 onRetry: (() -> Unit)? = null,
109113 onDismiss: (() -> Unit)? = null,
114+ onShare: (() -> Unit)? = null,
110115) {
111116 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)
113118 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)
115120 }
116121}
117122
@@ -130,6 +135,7 @@ private fun MessageLongPressMenu(
130135 onQuote: ((String) -> Unit)?,
131136 onBranch: ((String) -> Unit)?,
132137 onRevert: (() -> Unit)?,
138+ onShare: (() -> Unit)?,
133139) {
134140 DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
135141 if (text != null && onCopy != null) {
@@ -139,6 +145,16 @@ private fun MessageLongPressMenu(
139145 onClick = { onDismiss(); onCopy() },
140146 )
141147 }
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+ }
142158 if (text != null && onQuote != null) {
143159 DropdownMenuItem(
144160 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
@@ -288,6 +304,7 @@ private fun UserBubble(
288304 isEdited: Boolean = false,
289305 onRetry: (() -> Unit)? = null,
290306 onDismiss: (() -> Unit)? = null,
307+ onShare: (() -> Unit)? = null,
291308) {
292309 val context = LocalContext.current
293310 val haptics = LocalHapticFeedback.current
@@ -493,6 +510,7 @@ private fun UserBubble(
493510 onQuote = onQuote,
494511 onBranch = onBranch,
495512 onRevert = onRevert,
513+ onShare = onShare,
496514 )
497515 }
498516 }
@@ -511,10 +529,14 @@ private fun AssistantBlock(
511529 onRevert: (() -> Unit)? = null,
512530 onSpeak: ((String) -> Unit)? = null,
513531 isSpeaking: Boolean = false,
532+ ttsState: TtsState = TtsState.IDLE,
533+ onPause: (() -> Unit)? = null,
534+ onResume: (() -> Unit)? = null,
514535 onQuote: ((String) -> Unit)? = null,
515536 onBranch: ((String) -> Unit)? = null,
516537 onRegenerate: (() -> Unit)? = null,
517538 isEdited: Boolean = false,
539+ onShare: (() -> Unit)? = null,
518540) {
519541 Column(
520542 modifier = modifier.fillMaxWidth(),
@@ -522,38 +544,10 @@ private fun AssistantBlock(
522544 ) {
523545 val info = message.info
524546 if (info is AssistantMessage) {
525− val label = modelLabel ?: info.modelID
526547 // Avatar + model label row: the robot icon gives the assistant a consistent visual
527548 // identity (left-aligned, mirroring the user's right-aligned bubble) so on a long
528549 // scroll the speaker is unambiguous without reading the label.
529− Row(verticalAlignment = Alignment.CenterVertically) {
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− }
550+ AssistantHeader(label = modelLabel ?: info.modelID)
557551 }
558552 message.parts.forEachIndexed { index, part ->
559553 // Only the final part of a running message is actively streaming; earlier parts
@@ -568,23 +562,34 @@ private fun AssistantBlock(
568562 PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
569563 }
570564 }
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.
571571 if (info is AssistantMessage) {
572+ info.error?.let { err -> AgentErrorNote(err) }
572573 val cost = info.cost
573574 val tokens = info.tokens
574575 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)
575579 val costShort = stringResource(R.string.cost_format_short)
576580 val costLong = stringResource(R.string.cost_format_long)
577581 // Memoize the formatted cost/tokens line so a scroll-induced or unrelated
578582 // state-flip recomposition doesn't re-run NumberFormat + buildList +
579− // joinToString for every visible assistant bubble.
580− val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, costShort, costLong) {
581− if (!info.isComplete || (cost == null && tokens == null)) null
582− else buildList {
583− // Skip an all-zero token count (e.g. a completed message that reported no
584− // usage) so the bubble doesn't show a meaningless "0 in · 0 out".
585− tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
586− cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
587− }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
583+ // joinToString for every visible assistant bubble. The assembly logic lives in
584+ // [buildCostSummary] (extracted to keep this function's complexity in check).
585+ //
586+ // Reasoning and cache figures are only added when non-zero, so a model that
587+ // doesn't report them (or a request that didn't use them) shows the same compact
588+ // "1.2k in · 540 out • $0.012" line as before; a reasoning-model reply gains a
589+ // "3.1k reasoning" and/or "8.0k cache read" segment. The single-line cap and
590+ // ellipsis below keep the line tidy at narrow widths even with all four segments.
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)
588593 }
589594 // Long-press context menu state (see UserBubble for rationale).
590595 var longPressMenu by remember { mutableStateOf(false) }
@@ -647,7 +652,10 @@ private fun AssistantBlock(
647652 AssistantActions(
648653 textToCopy = textToCopy,
649654 isSpeaking = isSpeaking,
655+ ttsState = ttsState,
650656 onSpeak = onSpeak,
657+ onPause = onPause,
658+ onResume = onResume,
651659 onRevert = onRevert,
652660 onQuote = onQuote,
653661 onBranch = onBranch,
@@ -663,6 +671,7 @@ private fun AssistantBlock(
663671 onQuote = onQuote,
664672 onBranch = onBranch,
665673 onRevert = onRevert,
674+ onShare = onShare,
666675 )
667676 }
668677 } else {
@@ -671,13 +680,50 @@ private fun AssistantBlock(
671680 }
672681}
673682
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+
674717/** Trailing action buttons for an assistant message: read-aloud (TTS), revert, copy.
675718 * Extracted from [AssistantBlock] to keep that function under the complexity threshold. */
676719@Composable
677720private fun AssistantActions(
678721 textToCopy: String?,
679722 isSpeaking: Boolean,
723+ ttsState: TtsState,
680724 onSpeak: ((String) -> Unit)?,
725+ onPause: (() -> Unit)?,
726+ onResume: (() -> Unit)?,
681727 onRevert: (() -> Unit)?,
682728 onQuote: ((String) -> Unit)? = null,
683729 onBranch: ((String) -> Unit)? = null,
@@ -703,22 +749,19 @@ private fun AssistantActions(
703749 )
704750 }
705751 }
706− // Read-aloud toggle: speaks the assistant text via TextToSpeech, showing a Stop
707− // icon while this message is the one being spoken.
752+ // Read-aloud control with pause/resume. Tapping cycles through play → pause → resume,
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.
708756 if (onSpeak != null && textToCopy != null) {
709− val speakLabel = stringResource(if (isSpeaking) R.string.stop_reading else R.string.read_aloud)
710− IconButton(onClick = {
711− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
712− onSpeak(textToCopy)
713− }) {
714− Icon(
715− if (isSpeaking) Icons.Filled.StopCircle else Icons.AutoMirrored.Filled.VolumeUp,
716− contentDescription = speakLabel,
717− modifier = Modifier.size(18.dp),
718− tint = if (isSpeaking) MaterialTheme.colorScheme.primary
719− else MaterialTheme.colorScheme.onSurfaceVariant,
720− )
721− }
757+ TtsButton(
758+ text = textToCopy,
759+ isSpeaking = isSpeaking,
760+ ttsState = ttsState,
761+ onSpeak = onSpeak,
762+ onPause = onPause,
763+ onResume = onResume,
764+ )
722765 }
723766 onRevert?.let { RevertButton(it) }
724767 if (textToCopy != null) {
@@ -741,6 +784,48 @@ private fun AssistantActions(
741784 }
742785}
743786
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+
744829@Composable
745830private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
746831 // 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)
748833 RelativeTimeText(info.time?.created)
749834}
750835
751−// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
752−// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
753−// when the message list re-seeds after a reconnect and every visible assistant bubble
754−// recomposes at once) don't each pay that cost. Thread-local because NumberFormat is
755−// not thread-safe.
756−private val tokenNumberFormat: ThreadLocal<java.text.NumberFormat> = ThreadLocal.withInitial {
757− java.text.NumberFormat.getNumberInstance(java.util.Locale.US)
758−}
759−
760−private fun formatTokens(tokens: Tokens, format: String): String {
761− val nf = tokenNumberFormat.get()!!
762− return format.format(nf.format(tokens.input), nf.format(tokens.output))
836+/**
837+ * Render an [AssistantMessage.error] as an error-tinted note on the bubble. The error is a
838+ * tolerant-decoded [JsonElement]; we extract a readable string from it (a primitive's content
839+ * for a bare string, a pretty-printed object otherwise, trimmed to a reasonable length so a
840+ * verbose error object doesn't dominate the bubble).
841+ */
842+@Composable
843+private fun AgentErrorNote(error: kotlinx.serialization.json.JsonElement) {
844+ // Extract a readable string from the tolerant-decoded JsonElement: a bare string
845+ // primitive returns its content (the common `{"error": "..."}` case); an object/array
846+ // is pretty-printed and trimmed so a verbose error doesn't dominate the bubble.
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+ }
763888}
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) {
9595 else -> null
9696}
9797
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+
98132/**
99133 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
100134 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
@@ -113,6 +147,27 @@ fun PartView(
113147) {
114148 when (part) {
115149 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+ }
116171 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
117172 }
118173 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
@@ -305,6 +360,18 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
305360 fontFamily = FontFamily.Monospace,
306361 modifier = Modifier.padding(start = 6.dp),
307362 )
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+ }
308375 }
309376 // A human-readable summary of what the tool was asked to do (e.g. "Reading
310377 // 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
3535import androidx.compose.ui.Modifier
3636import androidx.compose.ui.res.stringResource
3737import androidx.compose.ui.semantics.Role
38+import androidx.compose.ui.semantics.contentDescription
3839import androidx.compose.ui.semantics.semantics
3940import androidx.compose.ui.semantics.stateDescription
4041import androidx.compose.ui.text.font.FontWeight
@@ -42,7 +43,9 @@ import androidx.compose.ui.text.style.TextDecoration
4243import androidx.compose.ui.unit.dp
4344import soy.iko.opencode.R
4445import soy.iko.opencode.data.model.TodoItem
46+import soy.iko.opencode.data.model.TodoPriority
4547import soy.iko.opencode.data.model.TodoStatus
48+import soy.iko.opencode.data.model.priorityEnum
4649import soy.iko.opencode.data.model.statusEnum
4750import soy.iko.opencode.ui.components.rememberVisibilityTransitions
4851
@@ -58,6 +61,7 @@ fun TodoPlanChecklist(todos: List<TodoItem>, modifier: Modifier = Modifier) {
5861@Composable
5962private fun TodoRow(todo: TodoItem) {
6063 val status = todo.statusEnum()
64+ val priority = todo.priorityEnum()
6165 val struck = status == TodoStatus.COMPLETED || status == TodoStatus.CANCELLED
6266 Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
6367 TodoStatusIcon(status)
@@ -71,8 +75,38 @@ private fun TodoRow(todo: TodoItem) {
7175 },
7276 fontWeight = if (status == TodoStatus.IN_PROGRESS) FontWeight.Medium else null,
7377 textDecoration = if (struck) TextDecoration.LineThrough else null,
74− modifier = Modifier.padding(start = 8.dp),
78+ modifier = Modifier.padding(start = 8.dp).weight(1f),
7579 )
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+ }
76110 }
77111}
78112
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+103−14
@@ -24,14 +24,28 @@ fun rememberTtsController(): TtsController {
2424 return remember { TtsController(context.applicationContext) }
2525}
2626
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+
2734/**
2835 * 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
30− * a play/stop toggle. [toggle] plays a message or stops it if it's already the one speaking.
36+ * [speakingId] — the id of the message currently being spoken, or null — and [state] so the
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.
3139 *
3240 * The engine initializes asynchronously; calls before it's ready are dropped (best-effort —
3341 * TTS is an optional convenience). Utterance-progress callbacks arrive on a binder thread,
3442 * 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.
3549 */
3650class TtsController(context: Context) : RememberObserver {
3751
@@ -45,11 +59,26 @@ class TtsController(context: Context) : RememberObserver {
4559 private val _speakingId = mutableStateOf<String?>(null)
4660 val speakingId: State<String?> = _speakingId
4761
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+
4872 private val tts: TextToSpeech = TextToSpeech(context) { status ->
4973 ready = status == TextToSpeech.SUCCESS
5074 }.apply {
5175 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+ }
5382 override fun onDone(utteranceId: String?) = clearIfCurrent(utteranceId)
5483 @Deprecated("Deprecated in Java", ReplaceWith(""))
5584 override fun onError(utteranceId: String?) = clearIfCurrent(utteranceId)
@@ -59,7 +88,15 @@ class TtsController(context: Context) : RememberObserver {
5988
6089 /** Clear the speaking state when the utterance that finished is the one we're tracking. */
6190 private fun clearIfCurrent(utteranceId: String?) {
62− mainHandler.post { if (_speakingId.value == utteranceId) _speakingId.value = null }
91+ mainHandler.post {
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+ }
63100 }
64101
65102 /**
@@ -70,18 +107,31 @@ class TtsController(context: Context) : RememberObserver {
70107 * Callers use this to surface feedback instead of the button appearing dead.
71108 */
72109 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+ }
74115 if (!ready || text.isBlank()) return false
75116 // TextToSpeech.speak() silently returns ERROR (enqueuing nothing, firing no callback)
76117 // when a single input exceeds getMaxSpeechInputLength(), which would leave the Stop
77118 // button stuck with no audio. Chunk long text and enqueue the parts back-to-back.
78− val chunks = chunkForTts(text)
79− if (chunks.isEmpty()) return false
80− chunks.forEachIndexed { index, chunk ->
81− val queueMode = if (index == 0) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
119+ val split = chunkForTts(text)
120+ if (split.isEmpty()) return false
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]
131+ val queueMode = if (index == fromChunk) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
82132 // Only the final chunk carries the tracked [id]; intermediate chunks get a
83133 // 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"
85135 val result = tts.speak(chunk, queueMode, null, utteranceId)
86136 // If any chunk can't be enqueued, the final chunk's onDone (which would clear
87137 // the Stop state) never fires — leaving the Stop button stuck with partial or
@@ -89,13 +139,47 @@ class TtsController(context: Context) : RememberObserver {
89139 // stick. A mid-queue failure also breaks the contiguous playback, so stopping
90140 // is the safe default rather than playing a truncated prefix silently.
91141 if (result != TextToSpeech.SUCCESS) {
92− if (index == 0) _speakingId.value = null
93− else stop()
94− return false
142+ if (index == fromChunk) {
143+ _speakingId.value = null
144+ _state.value = TtsState.IDLE
145+ chunks = emptyList()
146+ currentChunk = 0
147+ } else {
148+ stop()
149+ }
150+ return
95151 }
96152 }
97153 _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)
99183 }
100184
101185 /**
@@ -133,6 +217,10 @@ class TtsController(context: Context) : RememberObserver {
133217 fun stop() {
134218 tts.stop()
135219 _speakingId.value = null
220+ _state.value = TtsState.IDLE
221+ chunks = emptyList()
222+ currentChunk = 0
223+ pausedFromChunk = 0
136224 }
137225
138226 fun shutdown() {
@@ -141,6 +229,7 @@ class TtsController(context: Context) : RememberObserver {
141229 tts.stop()
142230 tts.shutdown()
143231 _speakingId.value = null
232+ _state.value = TtsState.IDLE
144233 }
145234
146235 // 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 @@
11package soy.iko.opencode.ui.file
22
3+import androidx.activity.compose.BackHandler
34import androidx.compose.foundation.ExperimentalFoundationApi
45import androidx.compose.foundation.clickable
56import androidx.compose.foundation.combinedClickable
@@ -559,7 +560,12 @@ private fun DirectoryListing(
559560 horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
560561 verticalAlignment = Alignment.CenterVertically,
561562 ) {
562− var showSortMenu by remember { mutableStateOf(false) }
563+ // rememberSaveable so an open sort dropdown survives a rotation instead of
564+ // closing and leaving the user to re-open it.
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 }
563569 androidx.compose.material3.FilterChip(
564570 selected = sortKey != FileSortKey.NAME || sortDesc,
565571 onClick = { showSortMenu = true },
@@ -657,7 +663,11 @@ private fun FileRow(
657663 val copyPathLabel = stringResource(R.string.copy_path)
658664 // Long-press → "Copy path" dropdown. Rendered only when [onCopyPath] is supplied, so the
659665 // parent ".." row (which passes none) stays a plain tap target. Anchored within the row.
660− var menu by remember { mutableStateOf(false) }
666+ // rememberSaveable so an open row menu survives a rotation instead of closing.
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 }
661671 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
662672 else if (icon) stringResource(R.string.folder, label)
663673 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
6969import androidx.compose.ui.text.style.TextOverflow
7070import androidx.compose.ui.unit.dp
7171import androidx.compose.ui.res.stringResource
72+import androidx.activity.compose.BackHandler
7273import androidx.activity.compose.rememberLauncherForActivityResult
7374import androidx.lifecycle.compose.collectAsStateWithLifecycle
7475import androidx.lifecycle.viewmodel.compose.viewModel
@@ -203,7 +204,12 @@ fun ServerListScreen(
203204 // servers to order; with one or two it's faster to scan by eye.
204205 if (profiles.size > 1) {
205206 val sortMode by vm.sortMode.collectAsStateWithLifecycle()
206− var sortMenu by remember { mutableStateOf(false) }
207+ // rememberSaveable so an open sort dropdown survives a rotation instead
208+ // of closing and leaving the user to re-open it.
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 }
207213 IconButton(onClick = { sortMenu = true }) {
208214 Icon(Icons.Filled.Sort, contentDescription = stringResource(R.string.sort_by))
209215 }
@@ -521,6 +527,9 @@ private fun ServerCardContent(
521527 )
522528 } else {
523529 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 }
524533 val editLabel = stringResource(R.string.edit)
525534 val duplicateLabel = stringResource(R.string.duplicate_server)
526535 val removeLabel = stringResource(R.string.remove)
@@ -670,6 +679,7 @@ private fun filterServerProfiles(profiles: List<ServerProfile>, query: String):
670679 * its cyclomatic complexity under the detekt threshold. */
671680@Composable
672681private fun ServerListSearchField(query: String, onQueryChange: (String) -> Unit) {
682+ val keyboard = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
673683 OutlinedTextField(
674684 value = query,
675685 onValueChange = onQueryChange,
@@ -684,5 +694,11 @@ private fun ServerListSearchField(query: String, onQueryChange: (String) -> Unit
684694 }
685695 }
686696 },
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() }),
687703 )
688704}
app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+42−15
@@ -187,22 +187,12 @@ fun NewSessionDialog(
187187 onSelect = { choice = DirChoice.Custom },
188188 )
189189 if (choice is DirChoice.Custom) {
190− // Autofocus the path field the moment the Custom row is chosen so the
191− // keyboard is ready without a second tap.
192− val customFocus = remember { FocusRequester() }
193− LaunchedEffect(Unit) { customFocus.requestFocus() }
194− OutlinedTextField(
195− value = customText,
196− onValueChange = { 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,
190+ CustomPathField(
191+ text = customText,
192+ onTextChange = { customText = it },
203193 enabled = !creating,
204− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
205− keyboardActions = KeyboardActions(onDone = { if (canCreate) onCreate(resolved, resolvedTitle) }),
194+ canCreate = canCreate,
195+ onCreate = { onCreate(resolved, resolvedTitle) },
206196 )
207197 }
208198
@@ -247,6 +237,43 @@ private fun TitleCharCounter(length: Int, limit: Int) {
247237 }
248238}
249239
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+
250277@Composable
251278private fun DirectoryRow(
252279 label: String,
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+4
@@ -200,6 +200,7 @@ fun SessionListScreen(
200200 // Close the open dropdown on back press instead of navigating away.
201201 BackHandler(enabled = showServerMenu) { showServerMenu = false }
202202 BackHandler(enabled = showSortMenu) { showSortMenu = false }
203+ BackHandler(enabled = showMainMenu) { showMainMenu = false }
203204
204205 // Dismiss the server switcher once an in-flight switch resolves (switchingId returns to
205206 // null), so its per-row spinner stays visible for the whole switch instead of the menu
@@ -1128,6 +1129,9 @@ private fun SessionCard(
11281129 // long list look busy. Swipe-to-delete and the overflow cover the same
11291130 // actions; swipe remains the gesture path, overflow the discovery path.
11301131 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 }
11311135 val renameLabel = stringResource(R.string.rename)
11321136 val deleteLabel = stringResource(R.string.delete)
11331137 // 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
2121import androidx.compose.material.icons.filled.BugReport
2222import androidx.compose.material.icons.filled.Close
2323import androidx.compose.material.icons.filled.ContentCopy
24+import androidx.activity.compose.BackHandler
2425import androidx.activity.compose.rememberLauncherForActivityResult
2526import androidx.compose.material.icons.filled.Delete
2627import androidx.compose.material.icons.filled.DeleteSweep
@@ -256,7 +257,11 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
256257 crashQuery = it
257258 }
258259 items(filtered, key = { it.fileName }) { report ->
259− var menuExpanded by remember { mutableStateOf(false) }
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.
263+ var menuExpanded by rememberSaveable(report.fileName) { mutableStateOf(false) }
264+ BackHandler(enabled = menuExpanded) { menuExpanded = false }
260265 // Swipe end-to-start reveals a delete affordance. The delete is
261266 // deferred for an undo window (matching the session/server lists);
262267 // confirmValueChange snaps back so the row remains while the undo
@@ -542,6 +547,7 @@ private fun filterCrashReports(
542547 * its cyclomatic complexity under the detekt threshold. */
543548@Composable
544549private fun CrashQueryField(query: String, onQueryChange: (String) -> Unit) {
550+ val keyboard = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
545551 OutlinedTextField(
546552 value = query,
547553 onValueChange = onQueryChange,
@@ -556,6 +562,12 @@ private fun CrashQueryField(query: String, onQueryChange: (String) -> Unit) {
556562 }
557563 }
558564 },
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() }),
559571 )
560572}
561573
app/src/main/res/values-es/strings.xml+20
@@ -681,4 +681,24 @@
681681 <item quantity="one">%1$d palabra oculta</item>
682682 <item quantity="other">%1$d palabras ocultas</item>
683683 </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>
684704</resources>
app/src/main/res/values/strings.xml+20
@@ -681,4 +681,24 @@
681681 <item quantity="one">%1$d word hidden</item>
682682 <item quantity="other">%1$d words hidden</item>
683683 </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>
684704</resources>
app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.kt+28
@@ -89,4 +89,32 @@ class ConversationExportTest {
8989 assertTrue(md.contains("**edit**"))
9090 assertTrue(md.contains("Editing src/main.kt"))
9191 }
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)
92120}
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+}