package soy.iko.opencode.ui.chat import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding 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.material3.rememberModalBottomSheetState 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.rememberCoroutineScope 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.semantics.stateDescription 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 kotlinx.coroutines.launch 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 } } // Hoist SheetState + skipPartiallyExpanded (see AgentPickerSheet for the full rationale). val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { 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_commands) Column( modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel }) Text( stringResource(R.string.loading_commands), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp), ) } } 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 // Resolved once (not per-row): the semantics lambda below isn't a composable scope, // so stringResource can't be called inside it. val destructiveStateLabel = stringResource(R.string.command_confirmation_required) // 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() }), ) // Dedupe the source list once, then filter the distinct list. val distinct = remember(commands) { commands.distinctBy { it.name } } val filtered = remember(distinct, query) { val q = query.trim() if (q.isEmpty()) distinct else distinct.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 .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp) .imePadding() .navigationBarsPadding(), ) { items(filtered, key = { it.name }) { cmd -> val destructive = isDestructiveCommand(cmd.name) Column( modifier = Modifier .fillMaxWidth() .clickable(role = Role.Button) { haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) if (destructive) { pendingCommandName = cmd.name } else { scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() } onSelect(cmd) } } .semantics { if (destructive) stateDescription = destructiveStateLabel } .padding(horizontal = 24.dp, vertical = 12.dp), ) { Row( verticalAlignment = 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 scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() } onSelect(cmd) }) { 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-") }