git.iko.soy/opencode-android
on master help sign in
UX/UI: clear draft button, bold text support, empty state hint, paste button

Bug fixes: inline code background consistency, add missing M3 onSecondary/tertiary color roles, guard password onGo against empty credentials

commit61d16d070dc01ca1e7df9e98a83067001aa3eccd authoriko <mail@iko.soy> dateThu 9 Jul 2026, 04:04 (3 months ago) parent38cabc4
5 files changed+95−8
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+35−6 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+37−1 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+9−1 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+8 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+6
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+35−6
@@ -49,6 +49,7 @@ import androidx.compose.ui.text.TextLinkStyles
4949import androidx.compose.ui.text.buildAnnotatedString
5050import androidx.compose.ui.text.font.FontFamily
5151import androidx.compose.ui.text.font.FontStyle
52+import androidx.compose.ui.text.font.FontWeight
5253import androidx.compose.ui.text.withLink
5354import androidx.compose.ui.text.withStyle
5455import androidx.compose.ui.text.style.TextDecoration
@@ -209,21 +210,49 @@ private fun buildLinkedText(
209210 append(segment)
210211 }
211212 } else {
212− appendLinkedText(segment, linkColor, context)
213+ appendLinkedText(segment, linkColor, codeBackground, context)
213214 }
214215 }
215216 if (streaming) append(" ▌")
216217}
217218
218219/**
219− * Append [text] to the builder. Single-backtick pairs are rendered as inline code
220− * (monospace, backticks dropped); the remaining prose has each http(s) URL turned
221− * into a clickable link. Triple-backtick fences are already stripped upstream in
222− * [buildLinkedText], so only inline spans reach here.
220+ * Append [text] to the builder. Double-asterisk pairs are rendered bold; within each
221+ * segment, single-backtick pairs are rendered as inline code (monospace, backticks
222+ * dropped), and the remaining prose has each http(s) URL turned into a clickable link.
223+ * Triple-backtick fences are already stripped upstream in [buildLinkedText], so only
224+ * inline spans reach here.
223225 */
224226private fun AnnotatedString.Builder.appendLinkedText(
225227 text: String,
226228 linkColor: Color,
229+ codeBackground: Color,
230+ context: Context,
231+) {
232+ // Bold is the outermost inline layer: split on ** first so a bold span can wrap
233+ // inline code and links inside it. Odd-indexed segments sit between a pair of **
234+ // markers, so they're bold (markers dropped); even segments are ordinary prose.
235+ val segments = text.split("**")
236+ segments.forEachIndexed { index, segment ->
237+ if (index % 2 == 1) {
238+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
239+ appendInlineCode(segment, linkColor, codeBackground, context)
240+ }
241+ } else {
242+ appendInlineCode(segment, linkColor, codeBackground, context)
243+ }
244+ }
245+}
246+
247+/**
248+ * Append [text] to the builder, rendering single-backtick pairs as inline code
249+ * (monospace, backticks dropped) and turning each http(s) URL in the remaining prose
250+ * into a clickable link.
251+ */
252+private fun AnnotatedString.Builder.appendInlineCode(
253+ text: String,
254+ linkColor: Color,
255+ codeBackground: Color,
227256 context: Context,
228257) {
229258 // Odd-indexed segments sit between a pair of single backticks, so they're inline
@@ -231,7 +260,7 @@ private fun AnnotatedString.Builder.appendLinkedText(
231260 val segments = text.split("`")
232261 segments.forEachIndexed { index, segment ->
233262 if (index % 2 == 1) {
234− withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) {
263+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
235264 append(segment)
236265 }
237266 } else {
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+37−1
@@ -59,6 +59,7 @@ import androidx.compose.material3.OutlinedTextField
5959import androidx.compose.material3.Scaffold
6060import androidx.compose.material3.Surface
6161import androidx.compose.material3.Text
62+import androidx.compose.material3.TextButton
6263import androidx.compose.material3.TopAppBar
6364import androidx.compose.runtime.Composable
6465import androidx.compose.runtime.LaunchedEffect
@@ -72,6 +73,7 @@ import androidx.compose.ui.Alignment
7273import androidx.compose.ui.Modifier
7374import androidx.compose.ui.draw.clip
7475import androidx.compose.ui.hapticfeedback.HapticFeedbackType
76+import androidx.compose.ui.platform.LocalClipboardManager
7577import androidx.compose.ui.platform.LocalContext
7678import androidx.compose.ui.platform.LocalDensity
7779import androidx.compose.ui.platform.LocalHapticFeedback
@@ -455,9 +457,13 @@ private fun ChatInputBar(
455457 onStop: () -> Unit,
456458) {
457459 val haptic = LocalHapticFeedback.current
460+ val clipboardManager = LocalClipboardManager.current
458461 Surface(tonalElevation = 3.dp) {
459− Row(
462+ Column(
460463 modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
464+ ) {
465+ Row(
466+ modifier = Modifier.fillMaxWidth(),
461467 verticalAlignment = Alignment.Bottom,
462468 horizontalArrangement = Arrangement.spacedBy(8.dp),
463469 ) {
@@ -468,6 +474,24 @@ private fun ChatInputBar(
468474 enabled = !loading,
469475 placeholder = { Text("Message opencode…") },
470476 maxLines = 5,
477+ // Offer a quick way to wipe the draft once there's something to clear
478+ // and we're not mid-generation (where the field is a poor place to fuss).
479+ trailingIcon = if (draft.isNotEmpty() && !generating) {
480+ {
481+ IconButton(
482+ onClick = { onDraftChange("") },
483+ modifier = Modifier.size(20.dp),
484+ ) {
485+ Icon(
486+ Icons.Filled.Close,
487+ contentDescription = "Clear draft",
488+ modifier = Modifier.size(16.dp),
489+ )
490+ }
491+ }
492+ } else {
493+ null
494+ },
471495 // Surface a running count once the draft gets long, so the user has a
472496 // sense of scale before sending a wall of text.
473497 supportingText = if (draft.length > 500) {
@@ -510,5 +534,17 @@ private fun ChatInputBar(
510534 }
511535 }
512536 }
537+ // With nothing typed yet, offer a one-tap way to drop in clipboard contents.
538+ if (draft.isEmpty() && !generating && !loading) {
539+ TextButton(
540+ onClick = {
541+ clipboardManager.getText()?.text?.let { onDraftChange(it) }
542+ },
543+ contentPadding = PaddingValues(0.dp),
544+ ) {
545+ Text("Paste")
546+ }
547+ }
548+ }
513549 }
514550}
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+9−1
@@ -155,7 +155,15 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
155155 },
156156 modifier = Modifier.fillMaxWidth(),
157157 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
158− keyboardActions = KeyboardActions(onGo = { dispatch(Event.Connect) }),
158+ keyboardActions = KeyboardActions(onGo = {
159+ // Mirror the connect button's enabled condition so the keyboard's
160+ // Go action can't fire a request the form isn't ready for.
161+ if (!state.loading && state.serverUrl.isNotBlank() &&
162+ (!state.authRequired || state.username.isNotBlank() || state.password.isNotBlank())
163+ ) {
164+ dispatch(Event.Connect)
165+ }
166+ }),
159167 enabled = !state.loading,
160168 )
161169 Spacer(Modifier.height(4.dp))
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+8
@@ -60,6 +60,7 @@ import androidx.compose.ui.Modifier
6060import androidx.compose.ui.hapticfeedback.HapticFeedbackType
6161import androidx.compose.ui.platform.LocalContext
6262import androidx.compose.ui.platform.LocalHapticFeedback
63+import androidx.compose.ui.text.style.TextAlign
6364import androidx.compose.ui.text.style.TextOverflow
6465import androidx.compose.ui.unit.dp
6566import soy.iko.opencode.core.Event
@@ -371,5 +372,12 @@ private fun EmptySessions() {
371372 style = MaterialTheme.typography.bodyMedium,
372373 color = MaterialTheme.colorScheme.onSurfaceVariant,
373374 )
375+ Spacer(Modifier.height(8.dp))
376+ Text(
377+ text = "Sessions you create on this server will appear here.",
378+ style = MaterialTheme.typography.bodySmall,
379+ color = MaterialTheme.colorScheme.onSurfaceVariant,
380+ textAlign = TextAlign.Center,
381+ )
374382 }
375383}
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+6
@@ -17,8 +17,11 @@ private val DarkColors = darkColorScheme(
1717 primaryContainer = Color(0xFF1A3D2A),
1818 onPrimaryContainer = Color(0xFFA8E6C1),
1919 secondary = EmeraldDark,
20+ onSecondary = Color(0xFFE6E9EF),
2021 secondaryContainer = Color(0xFF1A2A3D),
2122 onSecondaryContainer = Color(0xFFA8C5E6),
23+ tertiary = Color(0xFF6B7280),
24+ onTertiary = Color(0xFFE6E9EF),
2225 tertiaryContainer = Color(0xFF2A2A3D),
2326 onTertiaryContainer = Color(0xFFC5C5E6),
2427 errorContainer = Color(0xFF4D2626),
@@ -35,8 +38,11 @@ private val LightColors = lightColorScheme(
3538 primaryContainer = Color(0xFFD7F5E3),
3639 onPrimaryContainer = Color(0xFF0A3D22),
3740 secondary = Emerald,
41+ onSecondary = Color(0xFF1A1C1F),
3842 secondaryContainer = Color(0xFFD7E8F5),
3943 onSecondaryContainer = Color(0xFF0A2A4D),
44+ tertiary = Color(0xFF6B7280),
45+ onTertiary = Color(0xFFFFFFFF),
4046 tertiaryContainer = Color(0xFFE8E8F5),
4147 onTertiaryContainer = Color(0xFF2A2A4D),
4248 errorContainer = Color(0xFFFFE0E0),