package soy.iko.opencode.ui.chat import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistPlay import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.AlertDialog import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.ui.platform.LocalConfiguration import soy.iko.opencode.data.model.Command import soy.iko.opencode.data.network.NetworkConfig import soy.iko.opencode.R /** Bottom sheet that lists slash-commands; selecting one invokes it. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun CommandPickerSheet( commands: List, loading: Boolean, error: Boolean, onSelect: (Command) -> Unit, onRetry: () -> Unit, onDismiss: () -> Unit, ) { // Persist only the command *name* (a savable String), not the Command itself: a // kotlinx @Serializable Command is neither Parcelable nor java.io.Serializable, so // routing it through rememberSaveable crashes on rotation while the confirm dialog // is open. Re-resolve the full Command from the current list for rendering. var pendingCommandName by rememberSaveable { mutableStateOf(null) } val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } } ModalBottomSheet(onDismissRequest = onDismiss) { Text( stringResource(R.string.commands), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp), ) if (loading) { val loadingLabel = stringResource(R.string.loading) Box( modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center, ) { CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel }) } } else if (error && commands.isEmpty()) { Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( stringResource(R.string.load_failed), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, ) TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) } } } else if (commands.isEmpty()) { Text( stringResource(R.string.no_commands), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), ) } else { var query by rememberSaveable { mutableStateOf("") } val keyboardController = LocalSoftwareKeyboardController.current val haptics = LocalHapticFeedback.current // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip // it for a short list that's faster to scan by eye than to filter. val searchFocus = remember { FocusRequester() } LaunchedEffect(Unit) { if (commands.size > NetworkConfig.pickerSearchAutofocusThreshold) { runCatching { searchFocus.requestFocus() } } } OutlinedTextField( value = query, onValueChange = { query = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus), placeholder = { Text(stringResource(R.string.search_commands)) }, leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, trailingIcon = if (query.isNotEmpty()) { { IconButton(onClick = { query = "" }) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search)) } } } else null, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }), ) val filtered = remember(commands, query) { val q = query.trim() if (q.isEmpty()) commands else commands.filter { it.name.contains(q, ignoreCase = true) || it.displayDescription.contains(q, ignoreCase = true) } } if (filtered.isEmpty()) { Text( stringResource(R.string.no_commands_match, query.trim()), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), ) } else { // Responsive height: half the viewport rather than a hardcoded 420dp cap. LazyColumn(modifier = Modifier.fillMaxHeight(0.5f)) { // distinctBy the name so duplicate command names from the server can't // crash the list with LazyColumn's "Key was already used". items(filtered.distinctBy { it.name }, key = { it.name }) { cmd -> Column( modifier = Modifier .fillMaxWidth() .clickable(role = Role.Button) { haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) if (isDestructiveCommand(cmd.name)) { pendingCommandName = cmd.name } else { onSelect(cmd) onDismiss() } } .padding(horizontal = 24.dp, vertical = 12.dp), ) { androidx.compose.foundation.layout.Row( verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, ) { Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null) Text( "/${cmd.name}", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = 8.dp), ) } Text( cmd.displayDescription, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(start = 28.dp, top = 2.dp), ) } } } } } } // Confirmation for commands whose name suggests they may discard work or reset state, // so a tap doesn't irrevocably run e.g. /clear or /compact without consent. pendingCommand?.let { cmd -> AlertDialog( onDismissRequest = { pendingCommandName = null }, title = { Text(stringResource(R.string.run_command_title, cmd.name)) }, text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) }, confirmButton = { TextButton(onClick = { pendingCommandName = null onSelect(cmd) onDismiss() }) { Text(stringResource(R.string.run)) } }, dismissButton = { TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) } }, ) } } /** * Heuristic for commands that may be destructive (discard work / reset state) and so * warrant a confirmation step before invocation. Matches common opencode slash-command * names; non-matching commands invoke immediately. */ private fun isDestructiveCommand(name: String): Boolean { val n = name.lowercase() return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") || n.startsWith("clear") || n.startsWith("compact") || n.startsWith("reset") }