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