git.iko.soy/opencode-android
on 8bc2cce help sign in
193 lines 9.2 kB history raw
1package soy.iko.opencode.ui.chat
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
10import androidx.compose.foundation.text.KeyboardActions
11import androidx.compose.foundation.text.KeyboardOptions
12import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
14import androidx.compose.material.icons.filled.Search
15import androidx.compose.material3.CircularProgressIndicator
16import androidx.compose.material3.ExperimentalMaterial3Api
17import androidx.compose.material3.Icon
18import androidx.compose.material3.MaterialTheme
19import androidx.compose.material3.ModalBottomSheet
20import androidx.compose.material3.OutlinedTextField
21import androidx.compose.material3.Text
22import androidx.compose.material3.AlertDialog
23import androidx.compose.material3.TextButton
24import androidx.compose.runtime.Composable
25import androidx.compose.runtime.getValue
26import androidx.compose.runtime.mutableStateOf
27import androidx.compose.runtime.remember
28import androidx.compose.runtime.saveable.rememberSaveable
29import androidx.compose.runtime.setValue
30import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
32import androidx.compose.ui.platform.LocalSoftwareKeyboardController
33import androidx.compose.ui.res.stringResource
34import androidx.compose.ui.semantics.Role
35import androidx.compose.ui.semantics.contentDescription
36import androidx.compose.ui.semantics.semantics
37import androidx.compose.ui.text.input.ImeAction
38import androidx.compose.ui.text.style.TextOverflow
39import androidx.compose.ui.unit.dp
40import androidx.compose.foundation.layout.Box
41import soy.iko.opencode.data.model.Command
42import soy.iko.opencode.R
44/** Bottom sheet that lists slash-commands; selecting one invokes it. */
45@OptIn(ExperimentalMaterial3Api::class)
46@Composable
47fun CommandPickerSheet(
48 commands: List<Command>,
49 loading: Boolean,
50 error: Boolean,
51 onSelect: (Command) -> Unit,
52 onRetry: () -> Unit,
53 onDismiss: () -> Unit,
54) {
55 // Persist only the command *name* (a savable String), not the Command itself: a
56 // kotlinx @Serializable Command is neither Parcelable nor java.io.Serializable, so
57 // routing it through rememberSaveable crashes on rotation while the confirm dialog
58 // is open. Re-resolve the full Command from the current list for rendering.
59 var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
60 val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
61 ModalBottomSheet(onDismissRequest = onDismiss) {
62 Text(
63 stringResource(R.string.commands),
64 style = MaterialTheme.typography.titleMedium,
65 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
66 )
67 if (loading) {
68 val loadingLabel = stringResource(R.string.loading)
69 Box(
70 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
71 contentAlignment = Alignment.Center,
72 ) {
73 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
74 }
75 } else if (error && commands.isEmpty()) {
76 Column(
77 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
78 horizontalAlignment = Alignment.CenterHorizontally,
79 ) {
80 Text(
81 stringResource(R.string.load_failed),
82 style = MaterialTheme.typography.bodyMedium,
83 color = MaterialTheme.colorScheme.error,
84 )
85 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
86 }
87 } else if (commands.isEmpty()) {
88 Text(
89 stringResource(R.string.no_commands),
90 style = MaterialTheme.typography.bodyMedium,
91 color = MaterialTheme.colorScheme.onSurfaceVariant,
92 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
93 )
94 } else {
95 var query by rememberSaveable { mutableStateOf("") }
96 val keyboardController = LocalSoftwareKeyboardController.current
97 OutlinedTextField(
98 value = query,
99 onValueChange = { query = it },
100 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
101 placeholder = { Text(stringResource(R.string.search_commands)) },
102 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
103 singleLine = true,
104 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
105 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
106 )
107 val filtered = remember(commands, query) {
108 val q = query.trim()
109 if (q.isEmpty()) commands
110 else commands.filter {
111 it.name.contains(q, ignoreCase = true) ||
112 it.displayDescription.contains(q, ignoreCase = true)
113 }
114 }
115 if (filtered.isEmpty()) {
116 Text(
117 stringResource(R.string.no_commands_match, query.trim()),
118 style = MaterialTheme.typography.bodyMedium,
119 color = MaterialTheme.colorScheme.onSurfaceVariant,
120 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
121 )
122 } else {
123 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
124 items(filtered, key = { it.name }) { cmd ->
125 Column(
126 modifier = Modifier
127 .fillMaxWidth()
128 .clickable(role = Role.Button) {
129 if (isDestructiveCommand(cmd.name)) {
130 pendingCommandName = cmd.name
131 } else {
132 onSelect(cmd)
133 onDismiss()
134 }
135 }
136 .padding(horizontal = 24.dp, vertical = 12.dp),
137 ) {
138 androidx.compose.foundation.layout.Row(
139 verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
140 ) {
141 Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
142 Text(
143 "/${cmd.name}",
144 style = MaterialTheme.typography.bodyLarge,
145 modifier = Modifier.padding(start = 8.dp),
146 )
147 }
148 Text(
149 cmd.displayDescription,
150 style = MaterialTheme.typography.bodySmall,
151 color = MaterialTheme.colorScheme.onSurfaceVariant,
152 maxLines = 2,
153 overflow = TextOverflow.Ellipsis,
154 modifier = Modifier.padding(start = 28.dp, top = 2.dp),
155 )
156 }
157 }
158 }
159 }
160 }
161 }
163 // Confirmation for commands whose name suggests they may discard work or reset state,
164 // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
165 pendingCommand?.let { cmd ->
166 AlertDialog(
167 onDismissRequest = { pendingCommandName = null },
168 title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
169 text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
170 confirmButton = {
171 TextButton(onClick = {
172 pendingCommandName = null
173 onSelect(cmd)
174 onDismiss()
175 }) { Text(stringResource(R.string.run)) }
176 },
177 dismissButton = {
178 TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
179 },
180 )
181 }
184/**
185 * Heuristic for commands that may be destructive (discard work / reset state) and so
186 * warrant a confirmation step before invocation. Matches common opencode slash-command
187 * names; non-matching commands invoke immediately.
188 */
189private fun isDestructiveCommand(name: String): Boolean {
190 val n = name.lowercase()
191 return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") ||
192 n.startsWith("clear") || n.startsWith("compact") || n.startsWith("reset")