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)
62fun CommandPickerSheet(
63 commands: List<Command>,
66 onSelect: (Command) -> Unit,
68 onDismiss: () -> Unit,
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) {
81 stringResource(R.string.commands),
82 style = MaterialTheme.typography.titleMedium,
83 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
86 val loadingLabel = stringResource(R.string.loading_commands)
88 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(),
89 horizontalAlignment = Alignment.CenterHorizontally,
91 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
93 stringResource(R.string.loading_commands),
94 style = MaterialTheme.typography.bodyMedium,
95 color = MaterialTheme.colorScheme.onSurfaceVariant,
96 modifier = Modifier.padding(top = 8.dp),
99 } else if (error && commands.isEmpty()) {
101 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
102 horizontalAlignment = Alignment.CenterHorizontally,
105 stringResource(R.string.load_failed),
106 style = MaterialTheme.typography.bodyMedium,
107 color = MaterialTheme.colorScheme.error,
109 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
111 } else if (commands.isEmpty()) {
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),
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() }
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()) {
141 IconButton(onClick = { query = "" }) {
142 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
147 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
148 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
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) {
154 if (q.isEmpty()) distinct
155 else distinct.filter {
156 it.name.contains(q, ignoreCase = true) ||
157 it.displayDescription.contains(q, ignoreCase = true)
160 if (filtered.isEmpty()) {
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),
170 .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp)
172 .navigationBarsPadding(),
174 items(filtered, key = { it.name }) { cmd ->
175 val destructive = isDestructiveCommand(cmd.name)
179 .clickable(role = Role.Button) {
180 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
182 pendingCommandName = cmd.name
184 scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
189 if (destructive) stateDescription = destructiveStateLabel
191 .padding(horizontal = 24.dp, vertical = 12.dp),
194 verticalAlignment = Alignment.CenterVertically,
196 Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
199 style = MaterialTheme.typography.bodyLarge,
200 modifier = Modifier.padding(start = 8.dp),
204 cmd.displayDescription,
205 style = MaterialTheme.typography.bodySmall,
206 color = MaterialTheme.colorScheme.onSurfaceVariant,
208 overflow = TextOverflow.Ellipsis,
209 modifier = Modifier.padding(start = 28.dp, top = 2.dp),
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 ->
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}")) },
226 TextButton(onClick = {
227 pendingCommandName = null
228 scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
230 }) { Text(stringResource(R.string.run)) }
233 TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
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.
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-")
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