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.Search import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon 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.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.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 soy.iko.opencode.data.model.Command 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 OutlinedTextField( value = query, onValueChange = { query = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp), placeholder = { Text(stringResource(R.string.search_commands)) }, leadingIcon = { Icon(Icons.Filled.Search, contentDescription = 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 { LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) { items(filtered, key = { it.name }) { cmd -> Column( modifier = Modifier .fillMaxWidth() .clickable(role = Role.Button) { 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") }