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.Close
15import androidx.compose.material.icons.filled.Search
16import androidx.compose.material3.CircularProgressIndicator
17import androidx.compose.material3.ExperimentalMaterial3Api
18import androidx.compose.material3.Icon
19import androidx.compose.material3.IconButton
20import androidx.compose.material3.MaterialTheme
21import androidx.compose.material3.ModalBottomSheet
22import androidx.compose.material3.OutlinedTextField
23import androidx.compose.material3.Text
24import androidx.compose.material3.AlertDialog
25import androidx.compose.material3.TextButton
26import androidx.compose.runtime.Composable
27import androidx.compose.runtime.LaunchedEffect
28import androidx.compose.runtime.getValue
29import androidx.compose.runtime.mutableStateOf
30import androidx.compose.runtime.remember
31import androidx.compose.runtime.saveable.rememberSaveable
32import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
35import androidx.compose.ui.focus.FocusRequester
36import androidx.compose.ui.focus.focusRequester
37import androidx.compose.ui.hapticfeedback.HapticFeedbackType
38import androidx.compose.ui.platform.LocalHapticFeedback
39import androidx.compose.ui.platform.LocalSoftwareKeyboardController
40import androidx.compose.ui.res.stringResource
41import androidx.compose.ui.semantics.Role
42import androidx.compose.ui.semantics.contentDescription
43import androidx.compose.ui.semantics.semantics
44import androidx.compose.ui.text.input.ImeAction
45import androidx.compose.ui.text.style.TextOverflow
46import androidx.compose.ui.unit.dp
47import androidx.compose.foundation.layout.Box
48import androidx.compose.foundation.layout.fillMaxHeight
49import androidx.compose.ui.platform.LocalConfiguration
50import soy.iko.opencode.data.model.Command
51import soy.iko.opencode.data.network.NetworkConfig
52import soy.iko.opencode.R
54/** Bottom sheet that lists slash-commands; selecting one invokes it. */
55@OptIn(ExperimentalMaterial3Api::class)
57fun CommandPickerSheet(
58 commands: List<Command>,
61 onSelect: (Command) -> Unit,
63 onDismiss: () -> Unit,
65 // Persist only the command *name* (a savable String), not the Command itself: a
66 // kotlinx @Serializable Command is neither Parcelable nor java.io.Serializable, so
67 // routing it through rememberSaveable crashes on rotation while the confirm dialog
68 // is open. Re-resolve the full Command from the current list for rendering.
69 var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
70 val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
71 ModalBottomSheet(onDismissRequest = onDismiss) {
73 stringResource(R.string.commands),
74 style = MaterialTheme.typography.titleMedium,
75 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
78 val loadingLabel = stringResource(R.string.loading)
80 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
81 contentAlignment = Alignment.Center,
83 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
85 } else if (error && commands.isEmpty()) {
87 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
88 horizontalAlignment = Alignment.CenterHorizontally,
91 stringResource(R.string.load_failed),
92 style = MaterialTheme.typography.bodyMedium,
93 color = MaterialTheme.colorScheme.error,
95 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
97 } else if (commands.isEmpty()) {
99 stringResource(R.string.no_commands),
100 style = MaterialTheme.typography.bodyMedium,
101 color = MaterialTheme.colorScheme.onSurfaceVariant,
102 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
105 var query by rememberSaveable { mutableStateOf("") }
106 val keyboardController = LocalSoftwareKeyboardController.current
107 val haptics = LocalHapticFeedback.current
108 // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
109 // it for a short list that's faster to scan by eye than to filter.
110 val searchFocus = remember { FocusRequester() }
111 LaunchedEffect(Unit) {
112 if (commands.size > NetworkConfig.pickerSearchAutofocusThreshold) {
113 runCatching { searchFocus.requestFocus() }
118 onValueChange = { query = it },
119 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
120 placeholder = { Text(stringResource(R.string.search_commands)) },
121 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
122 trailingIcon = if (query.isNotEmpty()) {
124 IconButton(onClick = { query = "" }) {
125 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
130 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
131 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
133 val filtered = remember(commands, query) {
135 if (q.isEmpty()) commands
136 else commands.filter {
137 it.name.contains(q, ignoreCase = true) ||
138 it.displayDescription.contains(q, ignoreCase = true)
141 if (filtered.isEmpty()) {
143 stringResource(R.string.no_commands_match, query.trim()),
144 style = MaterialTheme.typography.bodyMedium,
145 color = MaterialTheme.colorScheme.onSurfaceVariant,
146 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
149 // Responsive height: half the viewport rather than a hardcoded 420dp cap.
150 LazyColumn(modifier = Modifier.fillMaxHeight(0.5f)) {
151 // distinctBy the name so duplicate command names from the server can't
152 // crash the list with LazyColumn's "Key was already used".
153 items(filtered.distinctBy { it.name }, key = { it.name }) { cmd ->
157 .clickable(role = Role.Button) {
158 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
159 if (isDestructiveCommand(cmd.name)) {
160 pendingCommandName = cmd.name
166 .padding(horizontal = 24.dp, vertical = 12.dp),
168 androidx.compose.foundation.layout.Row(
169 verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
171 Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
174 style = MaterialTheme.typography.bodyLarge,
175 modifier = Modifier.padding(start = 8.dp),
179 cmd.displayDescription,
180 style = MaterialTheme.typography.bodySmall,
181 color = MaterialTheme.colorScheme.onSurfaceVariant,
183 overflow = TextOverflow.Ellipsis,
184 modifier = Modifier.padding(start = 28.dp, top = 2.dp),
193 // Confirmation for commands whose name suggests they may discard work or reset state,
194 // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
195 pendingCommand?.let { cmd ->
197 onDismissRequest = { pendingCommandName = null },
198 title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
199 text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
201 TextButton(onClick = {
202 pendingCommandName = null
205 }) { Text(stringResource(R.string.run)) }
208 TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
215 * Heuristic for commands that may be destructive (discard work / reset state) and so
216 * warrant a confirmation step before invocation. Matches common opencode slash-command
217 * names; non-matching commands invoke immediately.
219private fun isDestructiveCommand(name: String): Boolean {
220 val n = name.lowercase()
221 return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") ||
222 n.startsWith("clear") || n.startsWith("compact") || n.startsWith("reset")
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list