git.iko.soy/opencode-android
on master help sign in
UX/UI: clear draft button, bold text support, empty state hint, paste button
61d16d0iko
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+35−6
@@ -49,6 +49,7 @@ import androidx.compose.ui.text.TextLinkStyles
49import androidx.compose.ui.text.buildAnnotatedString
49import androidx.compose.ui.text.buildAnnotatedString
50import androidx.compose.ui.text.font.FontFamily
50import androidx.compose.ui.text.font.FontFamily
51import androidx.compose.ui.text.font.FontStyle
51import androidx.compose.ui.text.font.FontStyle
52+import androidx.compose.ui.text.font.FontWeight
52import androidx.compose.ui.text.withLink
53import androidx.compose.ui.text.withLink
53import androidx.compose.ui.text.withStyle
54import androidx.compose.ui.text.withStyle
54import androidx.compose.ui.text.style.TextDecoration
55import androidx.compose.ui.text.style.TextDecoration
@@ -209,21 +210,49 @@ private fun buildLinkedText(
209 append(segment)
210 append(segment)
210 }
211 }
211 } else {
212 } else {
212− appendLinkedText(segment, linkColor, context)
213+ appendLinkedText(segment, linkColor, codeBackground, context)
213 }
214 }
214 }
215 }
215 if (streaming) append(" ▌")
216 if (streaming) append(" ▌")
216}
217}
217
218
218/**
219/**
220+ * Append [text] to the builder. Double-asterisk pairs are rendered bold; within each
219− * Append [text] to the builder. Single-backtick pairs are rendered as inline code
221+ * segment, single-backtick pairs are rendered as inline code (monospace, backticks
220− * (monospace, backticks dropped); the remaining prose has each http(s) URL turned
222+ * dropped), and the remaining prose has each http(s) URL turned into a clickable link.
221− * into a clickable link. Triple-backtick fences are already stripped upstream in
223+ * Triple-backtick fences are already stripped upstream in [buildLinkedText], so only
222− * [buildLinkedText], so only inline spans reach here.
224+ * inline spans reach here.
223 */
225 */
224private fun AnnotatedString.Builder.appendLinkedText(
226private fun AnnotatedString.Builder.appendLinkedText(
225 text: String,
227 text: String,
226 linkColor: Color,
228 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,
227 context: Context,
256 context: Context,
228) {
257) {
229 // Odd-indexed segments sit between a pair of single backticks, so they're inline
258 // Odd-indexed segments sit between a pair of single backticks, so they're inline
@@ -231,7 +260,7 @@ private fun AnnotatedString.Builder.appendLinkedText(
231 val segments = text.split("`")
260 val segments = text.split("`")
232 segments.forEachIndexed { index, segment ->
261 segments.forEachIndexed { index, segment ->
233 if (index % 2 == 1) {
262 if (index % 2 == 1) {
234− withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) {
263+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
235 append(segment)
264 append(segment)
236 }
265 }
237 } else {
266 } else {
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+37−1
@@ -59,6 +59,7 @@ import androidx.compose.material3.OutlinedTextField
59import androidx.compose.material3.Scaffold
59import androidx.compose.material3.Scaffold
60import androidx.compose.material3.Surface
60import androidx.compose.material3.Surface
61import androidx.compose.material3.Text
61import androidx.compose.material3.Text
62+import androidx.compose.material3.TextButton
62import androidx.compose.material3.TopAppBar
63import androidx.compose.material3.TopAppBar
63import androidx.compose.runtime.Composable
64import androidx.compose.runtime.Composable
64import androidx.compose.runtime.LaunchedEffect
65import androidx.compose.runtime.LaunchedEffect
@@ -72,6 +73,7 @@ import androidx.compose.ui.Alignment
72import androidx.compose.ui.Modifier
73import androidx.compose.ui.Modifier
73import androidx.compose.ui.draw.clip
74import androidx.compose.ui.draw.clip
74import androidx.compose.ui.hapticfeedback.HapticFeedbackType
75import androidx.compose.ui.hapticfeedback.HapticFeedbackType
76+import androidx.compose.ui.platform.LocalClipboardManager
75import androidx.compose.ui.platform.LocalContext
77import androidx.compose.ui.platform.LocalContext
76import androidx.compose.ui.platform.LocalDensity
78import androidx.compose.ui.platform.LocalDensity
77import androidx.compose.ui.platform.LocalHapticFeedback
79import androidx.compose.ui.platform.LocalHapticFeedback
@@ -455,9 +457,13 @@ private fun ChatInputBar(
455 onStop: () -> Unit,
457 onStop: () -> Unit,
456) {
458) {
457 val haptic = LocalHapticFeedback.current
459 val haptic = LocalHapticFeedback.current
460+ val clipboardManager = LocalClipboardManager.current
458 Surface(tonalElevation = 3.dp) {
461 Surface(tonalElevation = 3.dp) {
459− Row(
462+ Column(
460 modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
463 modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
464+ ) {
465+ Row(
466+ modifier = Modifier.fillMaxWidth(),
461 verticalAlignment = Alignment.Bottom,
467 verticalAlignment = Alignment.Bottom,
462 horizontalArrangement = Arrangement.spacedBy(8.dp),
468 horizontalArrangement = Arrangement.spacedBy(8.dp),
463 ) {
469 ) {
@@ -468,6 +474,24 @@ private fun ChatInputBar(
468 enabled = !loading,
474 enabled = !loading,
469 placeholder = { Text("Message opencode…") },
475 placeholder = { Text("Message opencode…") },
470 maxLines = 5,
476 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+ },
471 // Surface a running count once the draft gets long, so the user has a
495 // Surface a running count once the draft gets long, so the user has a
472 // sense of scale before sending a wall of text.
496 // sense of scale before sending a wall of text.
473 supportingText = if (draft.length > 500) {
497 supportingText = if (draft.length > 500) {
@@ -510,5 +534,17 @@ private fun ChatInputBar(
510 }
534 }
511 }
535 }
512 }
536 }
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+ }
513 }
549 }
514}
550}
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+9−1
@@ -155,7 +155,15 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
155 },
155 },
156 modifier = Modifier.fillMaxWidth(),
156 modifier = Modifier.fillMaxWidth(),
157 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
157 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+ }),
159 enabled = !state.loading,
167 enabled = !state.loading,
160 )
168 )
161 Spacer(Modifier.height(4.dp))
169 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
60import androidx.compose.ui.hapticfeedback.HapticFeedbackType
60import androidx.compose.ui.hapticfeedback.HapticFeedbackType
61import androidx.compose.ui.platform.LocalContext
61import androidx.compose.ui.platform.LocalContext
62import androidx.compose.ui.platform.LocalHapticFeedback
62import androidx.compose.ui.platform.LocalHapticFeedback
63+import androidx.compose.ui.text.style.TextAlign
63import androidx.compose.ui.text.style.TextOverflow
64import androidx.compose.ui.text.style.TextOverflow
64import androidx.compose.ui.unit.dp
65import androidx.compose.ui.unit.dp
65import soy.iko.opencode.core.Event
66import soy.iko.opencode.core.Event
@@ -371,5 +372,12 @@ private fun EmptySessions() {
371 style = MaterialTheme.typography.bodyMedium,
372 style = MaterialTheme.typography.bodyMedium,
372 color = MaterialTheme.colorScheme.onSurfaceVariant,
373 color = MaterialTheme.colorScheme.onSurfaceVariant,
373 )
374 )
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+ )
374 }
382 }
375}
383}
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+6
@@ -17,8 +17,11 @@ private val DarkColors = darkColorScheme(
17 primaryContainer = Color(0xFF1A3D2A),
17 primaryContainer = Color(0xFF1A3D2A),
18 onPrimaryContainer = Color(0xFFA8E6C1),
18 onPrimaryContainer = Color(0xFFA8E6C1),
19 secondary = EmeraldDark,
19 secondary = EmeraldDark,
20+ onSecondary = Color(0xFFE6E9EF),
20 secondaryContainer = Color(0xFF1A2A3D),
21 secondaryContainer = Color(0xFF1A2A3D),
21 onSecondaryContainer = Color(0xFFA8C5E6),
22 onSecondaryContainer = Color(0xFFA8C5E6),
23+ tertiary = Color(0xFF6B7280),
24+ onTertiary = Color(0xFFE6E9EF),
22 tertiaryContainer = Color(0xFF2A2A3D),
25 tertiaryContainer = Color(0xFF2A2A3D),
23 onTertiaryContainer = Color(0xFFC5C5E6),
26 onTertiaryContainer = Color(0xFFC5C5E6),
24 errorContainer = Color(0xFF4D2626),
27 errorContainer = Color(0xFF4D2626),
@@ -35,8 +38,11 @@ private val LightColors = lightColorScheme(
35 primaryContainer = Color(0xFFD7F5E3),
38 primaryContainer = Color(0xFFD7F5E3),
36 onPrimaryContainer = Color(0xFF0A3D22),
39 onPrimaryContainer = Color(0xFF0A3D22),
37 secondary = Emerald,
40 secondary = Emerald,
41+ onSecondary = Color(0xFF1A1C1F),
38 secondaryContainer = Color(0xFFD7E8F5),
42 secondaryContainer = Color(0xFFD7E8F5),
39 onSecondaryContainer = Color(0xFF0A2A4D),
43 onSecondaryContainer = Color(0xFF0A2A4D),
44+ tertiary = Color(0xFF6B7280),
45+ onTertiary = Color(0xFFFFFFFF),
40 tertiaryContainer = Color(0xFFE8E8F5),
46 tertiaryContainer = Color(0xFFE8E8F5),
41 onTertiaryContainer = Color(0xFF2A2A4D),
47 onTertiaryContainer = Color(0xFF2A2A4D),
42 errorContainer = Color(0xFFFFE0E0),
48 errorContainer = Color(0xFFFFE0E0),