git.iko.soy/opencode-android
on 61cdc75 help sign in
248 lines 12 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.Row
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.heightIn
8import androidx.compose.foundation.layout.imePadding
9import androidx.compose.foundation.layout.navigationBarsPadding
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.lazy.LazyColumn
12import androidx.compose.foundation.lazy.items
13import androidx.compose.foundation.text.KeyboardActions
14import androidx.compose.foundation.text.KeyboardOptions
15import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
17import androidx.compose.material.icons.filled.Close
18import androidx.compose.material.icons.filled.Search
19import androidx.compose.material3.CircularProgressIndicator
20import androidx.compose.material3.ExperimentalMaterial3Api
21import androidx.compose.material3.Icon
22import androidx.compose.material3.IconButton
23import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.ModalBottomSheet
25import androidx.compose.material3.OutlinedTextField
26import androidx.compose.material3.Text
27import androidx.compose.material3.AlertDialog
28import androidx.compose.material3.TextButton
29import androidx.compose.material3.rememberModalBottomSheetState
30import androidx.compose.runtime.Composable
31import androidx.compose.runtime.LaunchedEffect
32import androidx.compose.runtime.getValue
33import androidx.compose.runtime.mutableStateOf
34import androidx.compose.runtime.remember
35import androidx.compose.runtime.rememberCoroutineScope
36import androidx.compose.runtime.saveable.rememberSaveable
37import androidx.compose.runtime.setValue
38import androidx.compose.ui.Alignment
39import androidx.compose.ui.Modifier
40import androidx.compose.ui.focus.FocusRequester
41import androidx.compose.ui.focus.focusRequester
42import androidx.compose.ui.hapticfeedback.HapticFeedbackType
43import androidx.compose.ui.platform.LocalHapticFeedback
44import androidx.compose.ui.platform.LocalSoftwareKeyboardController
45import androidx.compose.ui.res.stringResource
46import androidx.compose.ui.semantics.Role
47import androidx.compose.ui.semantics.contentDescription
48import androidx.compose.ui.semantics.semantics
49import androidx.compose.ui.semantics.stateDescription
50import androidx.compose.ui.text.input.ImeAction
51import androidx.compose.ui.text.style.TextOverflow
52import androidx.compose.ui.unit.dp
53import androidx.compose.foundation.layout.Box
54import kotlinx.coroutines.launch
55import soy.iko.opencode.data.model.Command
56import soy.iko.opencode.data.network.NetworkConfig
57import soy.iko.opencode.R
59/** Bottom sheet that lists slash-commands; selecting one invokes it. */
60@OptIn(ExperimentalMaterial3Api::class)
61@Composable
62fun CommandPickerSheet(
63 commands: List<Command>,
64 loading: Boolean,
65 error: Boolean,
66 onSelect: (Command) -> Unit,
67 onRetry: () -> Unit,
68 onDismiss: () -> Unit,
69) {
70 // Persist only the command *name* (a savable String), not the Command itself: a
71 // kotlinx @Serializable Command is neither Parcelable nor java.io.Serializable, so
72 // routing it through rememberSaveable crashes on rotation while the confirm dialog
73 // is open. Re-resolve the full Command from the current list for rendering.
74 var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
75 val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
76 // Hoist SheetState + skipPartiallyExpanded (see AgentPickerSheet for the full rationale).
77 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
78 val scope = rememberCoroutineScope()
79 ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
80 Text(
81 stringResource(R.string.commands),
82 style = MaterialTheme.typography.titleMedium,
83 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
84 )
85 if (loading) {
86 val loadingLabel = stringResource(R.string.loading_commands)
87 Column(
88 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(),
89 horizontalAlignment = Alignment.CenterHorizontally,
90 ) {
91 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
92 Text(
93 stringResource(R.string.loading_commands),
94 style = MaterialTheme.typography.bodyMedium,
95 color = MaterialTheme.colorScheme.onSurfaceVariant,
96 modifier = Modifier.padding(top = 8.dp),
97 )
98 }
99 } else if (error && commands.isEmpty()) {
100 Column(
101 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
102 horizontalAlignment = Alignment.CenterHorizontally,
103 ) {
104 Text(
105 stringResource(R.string.load_failed),
106 style = MaterialTheme.typography.bodyMedium,
107 color = MaterialTheme.colorScheme.error,
108 )
109 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
110 }
111 } else if (commands.isEmpty()) {
112 Text(
113 stringResource(R.string.no_commands),
114 style = MaterialTheme.typography.bodyMedium,
115 color = MaterialTheme.colorScheme.onSurfaceVariant,
116 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
117 )
118 } else {
119 var query by rememberSaveable { mutableStateOf("") }
120 val keyboardController = LocalSoftwareKeyboardController.current
121 val haptics = LocalHapticFeedback.current
122 // Resolved once (not per-row): the semantics lambda below isn't a composable scope,
123 // so stringResource can't be called inside it.
124 val destructiveStateLabel = stringResource(R.string.command_confirmation_required)
125 // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
126 // it for a short list that's faster to scan by eye than to filter.
127 val searchFocus = remember { FocusRequester() }
128 LaunchedEffect(Unit) {
129 if (commands.size > NetworkConfig.pickerSearchAutofocusThreshold) {
130 runCatching { searchFocus.requestFocus() }
131 }
132 }
133 OutlinedTextField(
134 value = query,
135 onValueChange = { query = it },
136 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
137 placeholder = { Text(stringResource(R.string.search_commands)) },
138 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
139 trailingIcon = if (query.isNotEmpty()) {
140 {
141 IconButton(onClick = { query = "" }) {
142 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
143 }
144 }
145 } else null,
146 singleLine = true,
147 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
148 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
149 )
150 // Dedupe the source list once, then filter the distinct list.
151 val distinct = remember(commands) { commands.distinctBy { it.name } }
152 val filtered = remember(distinct, query) {
153 val q = query.trim()
154 if (q.isEmpty()) distinct
155 else distinct.filter {
156 it.name.contains(q, ignoreCase = true) ||
157 it.displayDescription.contains(q, ignoreCase = true)
158 }
159 }
160 if (filtered.isEmpty()) {
161 Text(
162 stringResource(R.string.no_commands_match, query.trim()),
163 style = MaterialTheme.typography.bodyMedium,
164 color = MaterialTheme.colorScheme.onSurfaceVariant,
165 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
166 )
167 } else {
168 LazyColumn(
169 modifier = Modifier
170 .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp)
171 .imePadding()
172 .navigationBarsPadding(),
173 ) {
174 items(filtered, key = { it.name }) { cmd ->
175 val destructive = isDestructiveCommand(cmd.name)
176 Column(
177 modifier = Modifier
178 .fillMaxWidth()
179 .clickable(role = Role.Button) {
180 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
181 if (destructive) {
182 pendingCommandName = cmd.name
183 } else {
184 scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
185 onSelect(cmd)
186 }
187 }
188 .semantics {
189 if (destructive) stateDescription = destructiveStateLabel
190 }
191 .padding(horizontal = 24.dp, vertical = 12.dp),
192 ) {
193 Row(
194 verticalAlignment = Alignment.CenterVertically,
195 ) {
196 Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
197 Text(
198 "/${cmd.name}",
199 style = MaterialTheme.typography.bodyLarge,
200 modifier = Modifier.padding(start = 8.dp),
201 )
202 }
203 Text(
204 cmd.displayDescription,
205 style = MaterialTheme.typography.bodySmall,
206 color = MaterialTheme.colorScheme.onSurfaceVariant,
207 maxLines = 2,
208 overflow = TextOverflow.Ellipsis,
209 modifier = Modifier.padding(start = 28.dp, top = 2.dp),
210 )
211 }
212 }
213 }
214 }
215 }
216 }
218 // Confirmation for commands whose name suggests they may discard work or reset state,
219 // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
220 pendingCommand?.let { cmd ->
221 AlertDialog(
222 onDismissRequest = { pendingCommandName = null },
223 title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
224 text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
225 confirmButton = {
226 TextButton(onClick = {
227 pendingCommandName = null
228 scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
229 onSelect(cmd)
230 }) { Text(stringResource(R.string.run)) }
231 },
232 dismissButton = {
233 TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
234 },
235 )
236 }
239/**
240 * Heuristic for commands that may be destructive (discard work / reset state) and so
241 * warrant a confirmation step before invocation. Matches common opencode slash-command
242 * names; non-matching commands invoke immediately.
243 */
244private fun isDestructiveCommand(name: String): Boolean {
245 val n = name.lowercase()
246 return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") ||
247 n.startsWith("clear-") || n.startsWith("compact-") || n.startsWith("reset-")