git.iko.soy/opencode-android
on master help sign in
Add copy button to assistant messages, search filters to picker sheets, and make session cards fully clickable
a40d3a2iko
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+28−1
@@ -9,15 +9,23 @@ import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.items
11import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.text.KeyboardOptions
12import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.filled.Check
14import androidx.compose.material.icons.filled.Check
15+import androidx.compose.material.icons.filled.Search
14import androidx.compose.material3.CircularProgressIndicator
16import androidx.compose.material3.CircularProgressIndicator
15import androidx.compose.material3.ExperimentalMaterial3Api
17import androidx.compose.material3.ExperimentalMaterial3Api
16import androidx.compose.material3.Icon
18import androidx.compose.material3.Icon
17import androidx.compose.material3.MaterialTheme
19import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.ModalBottomSheet
20import androidx.compose.material3.ModalBottomSheet
21+import androidx.compose.material3.OutlinedTextField
19import androidx.compose.material3.Text
22import androidx.compose.material3.Text
20import androidx.compose.runtime.Composable
23import androidx.compose.runtime.Composable
24+import androidx.compose.runtime.getValue
25+import androidx.compose.runtime.mutableStateOf
26+import androidx.compose.runtime.remember
27+import androidx.compose.runtime.saveable.rememberSaveable
28+import androidx.compose.runtime.setValue
21import androidx.compose.ui.Alignment
29import androidx.compose.ui.Alignment
22import androidx.compose.ui.Modifier
30import androidx.compose.ui.Modifier
23import androidx.compose.ui.res.stringResource
31import androidx.compose.ui.res.stringResource
@@ -26,6 +34,7 @@ import androidx.compose.ui.semantics.contentDescription
26import androidx.compose.ui.semantics.role
34import androidx.compose.ui.semantics.role
27import androidx.compose.ui.semantics.selected
35import androidx.compose.ui.semantics.selected
28import androidx.compose.ui.semantics.semantics
36import androidx.compose.ui.semantics.semantics
37+import androidx.compose.ui.text.input.ImeAction
29import androidx.compose.ui.unit.dp
38import androidx.compose.ui.unit.dp
30import androidx.compose.foundation.layout.Box
39import androidx.compose.foundation.layout.Box
31import soy.iko.opencode.data.model.Agent
40import soy.iko.opencode.data.model.Agent
@@ -75,6 +84,24 @@ fun AgentPickerSheet(
75 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
84 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
76 )
85 )
77 } else {
86 } else {
87+ var query by rememberSaveable { mutableStateOf("") }
88+ OutlinedTextField(
89+ value = query,
90+ onValueChange = { query = it },
91+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
92+ placeholder = { Text(stringResource(R.string.search_agents)) },
93+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
94+ singleLine = true,
95+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
96+ )
97+ val filtered = remember(agents, query) {
98+ val q = query.trim()
99+ if (q.isEmpty()) agents
100+ else agents.filter {
101+ it.name.contains(q, ignoreCase = true) ||
102+ it.displayDescription.contains(q, ignoreCase = true)
103+ }
104+ }
78 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
105 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
79 item(key = "__default") {
106 item(key = "__default") {
80 Column(
107 Column(
@@ -109,7 +136,7 @@ fun AgentPickerSheet(
109 )
136 )
110 }
137 }
111 }
138 }
112− items(agents, key = { it.name }) { agent ->
139+ items(filtered, key = { it.name }) { agent ->
113 val isSelected = agent.name == selected
140 val isSelected = agent.name == selected
114 Column(
141 Column(
115 modifier = Modifier
142 modifier = Modifier
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt−1
@@ -486,7 +486,6 @@ private fun ChatInputBar(
486 }
486 }
487 },
487 },
488 placeholder = { Text(stringResource(R.string.message_placeholder)) },
488 placeholder = { Text(stringResource(R.string.message_placeholder)) },
489− label = { Text(stringResource(R.string.message_placeholder)) },
490 enabled = enabled,
489 enabled = enabled,
491 maxLines = 6,
490 maxLines = 6,
492 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
491 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+54−8
@@ -6,7 +6,12 @@ import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.shape.RoundedCornerShape
10import androidx.compose.foundation.shape.RoundedCornerShape
11+import androidx.compose.material.icons.Icons
12+import androidx.compose.material.icons.filled.ContentCopy
13+import androidx.compose.material3.Icon
14+import androidx.compose.material3.IconButton
10import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.Text
16import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
@@ -14,15 +19,18 @@ import androidx.compose.runtime.remember
14import androidx.compose.ui.Alignment
19import androidx.compose.ui.Alignment
15import androidx.compose.ui.Modifier
20import androidx.compose.ui.Modifier
16import androidx.compose.ui.draw.clip
21import androidx.compose.ui.draw.clip
22+import androidx.compose.ui.platform.LocalContext
17import androidx.compose.ui.res.stringResource
23import androidx.compose.ui.res.stringResource
18import androidx.compose.ui.unit.dp
24import androidx.compose.ui.unit.dp
19import soy.iko.opencode.R
25import soy.iko.opencode.R
20import soy.iko.opencode.data.model.AssistantMessage
26import soy.iko.opencode.data.model.AssistantMessage
21import soy.iko.opencode.data.model.MessageWithParts
27import soy.iko.opencode.data.model.MessageWithParts
28+import soy.iko.opencode.data.model.TextPart
22import soy.iko.opencode.data.model.Tokens
29import soy.iko.opencode.data.model.Tokens
23import soy.iko.opencode.data.model.UserMessage
30import soy.iko.opencode.data.model.UserMessage
24import soy.iko.opencode.data.network.NetworkConfig
31import soy.iko.opencode.data.network.NetworkConfig
25import soy.iko.opencode.ui.components.ImageLoadContext
32import soy.iko.opencode.ui.components.ImageLoadContext
33+import soy.iko.opencode.ui.components.copyToClipboard
26import soy.iko.opencode.ui.components.rememberRelativeTime
34import soy.iko.opencode.ui.components.rememberRelativeTime
27
35
28/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
36/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
@@ -87,20 +95,53 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageC
87 cost?.takeIf { it > 0 }?.let { add(formatCost(it)) }
95 cost?.takeIf { it > 0 }?.let { add(formatCost(it)) }
88 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
96 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
89 }
97 }
98+ Row(
99+ modifier = Modifier.fillMaxWidth(),
100+ horizontalArrangement = Arrangement.SpaceBetween,
101+ verticalAlignment = Alignment.CenterVertically,
102+ ) {
103+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
90− costSummary?.let {
104+ costSummary?.let {
91− Text(
105+ Text(
92− it,
106+ it,
93− style = MaterialTheme.typography.labelSmall,
107+ style = MaterialTheme.typography.labelSmall,
94− color = MaterialTheme.colorScheme.onSurfaceVariant,
108+ color = MaterialTheme.colorScheme.onSurfaceVariant,
109+ )
110+ }
111+ MessageTimestampText(message.info)
112+ }
113+ val context = LocalContext.current
114+ val copyLabel = stringResource(R.string.copy)
115+ // Collect text from all TextParts for copying. Memoized so a
116+ // scroll-induced recomposition doesn't re-scan the parts list.
117+ val textToCopy = remember(message.parts) {
118+ message.parts
119+ .filterIsInstance<TextPart>()
120+ .joinToString("\n\n") { it.text }
121+ .takeIf { it.isNotBlank() }
122+ }
123+ if (textToCopy != null) {
124+ IconButton(
125+ onClick = { copyToClipboard(context, "message", textToCopy) },
126+ modifier = Modifier.size(28.dp),
127+ ) {
128+ Icon(
129+ Icons.Filled.ContentCopy,
130+ contentDescription = copyLabel,
131+ modifier = Modifier.size(14.dp),
132+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
95− )
133+ )
134+ }
135+ }
96 }
136 }
137+ } else {
138+ MessageTimestampText(message.info)
97 }
139 }
98− MessageTimestamp(message.info)
99 }
140 }
100}
141}
101
142
102@Composable
143@Composable
103−private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
144+private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
104 val t = rememberRelativeTime(info.time?.created)
145 val t = rememberRelativeTime(info.time?.created)
105 if (t.isNotEmpty()) {
146 if (t.isNotEmpty()) {
106 Text(
147 Text(
@@ -111,6 +152,11 @@ private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
111 }
152 }
112}
153}
113
154
155+@Composable
156+private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
157+ MessageTimestampText(info)
158+}
159+
114// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
160// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
115// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
161// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
116// when the message list re-seeds after a reconnect and every visible assistant bubble
162// when the message list re-seeds after a reconnect and every visible assistant bubble
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+38−2
@@ -9,15 +9,23 @@ import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.items
11import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.text.KeyboardOptions
12import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.filled.Check
14import androidx.compose.material.icons.filled.Check
15+import androidx.compose.material.icons.filled.Search
14import androidx.compose.material3.CircularProgressIndicator
16import androidx.compose.material3.CircularProgressIndicator
15import androidx.compose.material3.ExperimentalMaterial3Api
17import androidx.compose.material3.ExperimentalMaterial3Api
16import androidx.compose.material3.Icon
18import androidx.compose.material3.Icon
17import androidx.compose.material3.MaterialTheme
19import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.ModalBottomSheet
20import androidx.compose.material3.ModalBottomSheet
21+import androidx.compose.material3.OutlinedTextField
19import androidx.compose.material3.Text
22import androidx.compose.material3.Text
20import androidx.compose.runtime.Composable
23import androidx.compose.runtime.Composable
24+import androidx.compose.runtime.getValue
25+import androidx.compose.runtime.mutableStateOf
26+import androidx.compose.runtime.remember
27+import androidx.compose.runtime.saveable.rememberSaveable
28+import androidx.compose.runtime.setValue
21import androidx.compose.ui.Alignment
29import androidx.compose.ui.Alignment
22import androidx.compose.ui.Modifier
30import androidx.compose.ui.Modifier
23import androidx.compose.ui.res.stringResource
31import androidx.compose.ui.res.stringResource
@@ -26,6 +34,7 @@ import androidx.compose.ui.semantics.contentDescription
26import androidx.compose.ui.semantics.role
34import androidx.compose.ui.semantics.role
27import androidx.compose.ui.semantics.selected
35import androidx.compose.ui.semantics.selected
28import androidx.compose.ui.semantics.semantics
36import androidx.compose.ui.semantics.semantics
37+import androidx.compose.ui.text.input.ImeAction
29import androidx.compose.ui.unit.dp
38import androidx.compose.ui.unit.dp
30import androidx.compose.foundation.layout.Box
39import androidx.compose.foundation.layout.Box
31import soy.iko.opencode.data.model.ModelOption
40import soy.iko.opencode.data.model.ModelOption
@@ -75,8 +84,34 @@ fun ModelPickerSheet(
75 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
84 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
76 )
85 )
77 } else {
86 } else {
87+ var query by rememberSaveable { mutableStateOf("") }
88+ OutlinedTextField(
89+ value = query,
90+ onValueChange = { query = it },
91+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
92+ placeholder = { Text(stringResource(R.string.search_models)) },
93+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
94+ singleLine = true,
95+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
96+ )
97+ val filtered = remember(options, query) {
98+ val q = query.trim()
99+ if (q.isEmpty()) options
100+ else options.filter {
101+ it.modelLabel.contains(q, ignoreCase = true) ||
102+ it.providerLabel.contains(q, ignoreCase = true)
103+ }
104+ }
105+ if (filtered.isEmpty()) {
106+ Text(
107+ stringResource(R.string.no_models_match),
108+ style = MaterialTheme.typography.bodyMedium,
109+ color = MaterialTheme.colorScheme.onSurfaceVariant,
110+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
111+ )
112+ } else {
78− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
113+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
79− items(options, key = { it.providerID to it.modelID }) { option ->
114+ items(filtered, key = { it.providerID to it.modelID }) { option ->
80 val isSelected = option.providerID == selected?.providerID &&
115 val isSelected = option.providerID == selected?.providerID &&
81 option.modelID == selected?.modelID
116 option.modelID == selected?.modelID
82 Column(
117 Column(
@@ -116,5 +151,6 @@ fun ModelPickerSheet(
116 }
151 }
117 }
152 }
118 }
153 }
154+ }
119 }
155 }
120}
156}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+4−10
@@ -353,21 +353,16 @@ private fun SessionCard(
353 modifier: Modifier = Modifier,
353 modifier: Modifier = Modifier,
354) {
354) {
355 Card(
355 Card(
356− modifier = modifier.fillMaxWidth(),
356+ modifier = modifier
357+ .fillMaxWidth()
358+ .clickable(role = Role.Button) { onClick() },
357 ) {
359 ) {
358 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
360 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
359 Row(
361 Row(
360 modifier = Modifier.fillMaxWidth(),
362 modifier = Modifier.fillMaxWidth(),
361 verticalAlignment = Alignment.CenterVertically,
363 verticalAlignment = Alignment.CenterVertically,
362 ) {
364 ) {
363− // Title area is the primary click target; the action buttons below
364− // remain separate accessibility nodes so screen readers can activate
365− // them independently.
366− Column(
367− modifier = Modifier
365+ Column(modifier = Modifier.weight(1f)) {
368− .weight(1f)
369− .clickable(role = Role.Button) { onClick() },
370− ) {
371 Row(verticalAlignment = Alignment.CenterVertically) {
366 Row(verticalAlignment = Alignment.CenterVertically) {
372 if (unread) {
367 if (unread) {
373 val unreadLabel = stringResource(R.string.unread)
368 val unreadLabel = stringResource(R.string.unread)
@@ -413,7 +408,6 @@ private fun SessionCard(
413 color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
408 color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
414 maxLines = 2,
409 maxLines = 2,
415 overflow = TextOverflow.Ellipsis,
410 overflow = TextOverflow.Ellipsis,
416− modifier = Modifier.clickable(role = Role.Button) { onClick() },
417 )
411 )
418 }
412 }
419 }
413 }
app/src/main/res/values/strings.xml+3
@@ -132,6 +132,9 @@
132 <string name="load_failed">Failed to load. Tap retry.</string>
132 <string name="load_failed">Failed to load. Tap retry.</string>
133 <string name="saving">Saving…</string>
133 <string name="saving">Saving…</string>
134 <string name="image_failed">Failed to load image</string>
134 <string name="image_failed">Failed to load image</string>
135+ <string name="search_models">Search models…</string>
136+ <string name="search_agents">Search agents…</string>
137+ <string name="no_models_match">No models match "%1$s".</string>
135
138
136 <!-- File browser -->
139 <!-- File browser -->
137 <string name="search_files">Search files…</string>
140 <string name="search_files">Search files…</string>