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)
47fun CommandPickerSheet(
48 commands: List<Command>,
51 onSelect: (Command) -> Unit,
53 onDismiss: () -> Unit,
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) {
63 stringResource(R.string.commands),
64 style = MaterialTheme.typography.titleMedium,
65 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
68 val loadingLabel = stringResource(R.string.loading)
70 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
71 contentAlignment = Alignment.Center,
73 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
75 } else if (error && commands.isEmpty()) {
77 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
78 horizontalAlignment = Alignment.CenterHorizontally,
81 stringResource(R.string.load_failed),
82 style = MaterialTheme.typography.bodyMedium,
83 color = MaterialTheme.colorScheme.error,
85 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
87 } else if (commands.isEmpty()) {
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),
95 var query by rememberSaveable { mutableStateOf("") }
96 val keyboardController = LocalSoftwareKeyboardController.current
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) },
104 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
105 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
107 val filtered = remember(commands, query) {
109 if (q.isEmpty()) commands
110 else commands.filter {
111 it.name.contains(q, ignoreCase = true) ||
112 it.displayDescription.contains(q, ignoreCase = true)
115 if (filtered.isEmpty()) {
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),
123 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
124 items(filtered, key = { it.name }) { cmd ->
128 .clickable(role = Role.Button) {
129 if (isDestructiveCommand(cmd.name)) {
130 pendingCommandName = cmd.name
136 .padding(horizontal = 24.dp, vertical = 12.dp),
138 androidx.compose.foundation.layout.Row(
139 verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
141 Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
144 style = MaterialTheme.typography.bodyLarge,
145 modifier = Modifier.padding(start = 8.dp),
149 cmd.displayDescription,
150 style = MaterialTheme.typography.bodySmall,
151 color = MaterialTheme.colorScheme.onSurfaceVariant,
153 overflow = TextOverflow.Ellipsis,
154 modifier = Modifier.padding(start = 28.dp, top = 2.dp),
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 ->
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}")) },
171 TextButton(onClick = {
172 pendingCommandName = null
175 }) { Text(stringResource(R.string.run)) }
178 TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
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.
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")
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