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.padding
9import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.items
12import androidx.compose.foundation.lazy.rememberLazyListState
13import androidx.compose.foundation.text.KeyboardActions
14import androidx.compose.foundation.text.KeyboardOptions
15import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.Check
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.TextButton
28import androidx.compose.runtime.Composable
29import androidx.compose.runtime.LaunchedEffect
30import androidx.compose.runtime.getValue
31import androidx.compose.runtime.mutableStateOf
32import androidx.compose.runtime.remember
33import androidx.compose.runtime.saveable.rememberSaveable
34import androidx.compose.runtime.setValue
35import androidx.compose.ui.Alignment
36import androidx.compose.ui.Modifier
37import androidx.compose.ui.focus.FocusRequester
38import androidx.compose.ui.focus.focusRequester
39import androidx.compose.ui.hapticfeedback.HapticFeedbackType
40import androidx.compose.ui.platform.LocalHapticFeedback
41import androidx.compose.ui.platform.LocalSoftwareKeyboardController
42import androidx.compose.ui.res.stringResource
43import androidx.compose.ui.semantics.Role
44import androidx.compose.ui.semantics.contentDescription
45import androidx.compose.ui.semantics.role
46import androidx.compose.ui.semantics.selected
47import androidx.compose.ui.semantics.semantics
48import androidx.compose.ui.text.input.ImeAction
49import androidx.compose.ui.unit.dp
50import androidx.compose.foundation.layout.Box
51import androidx.compose.foundation.layout.fillMaxHeight
52import androidx.compose.ui.platform.LocalConfiguration
53import soy.iko.opencode.data.model.ModelOption
54import soy.iko.opencode.data.network.NetworkConfig
55import soy.iko.opencode.R
58 * Flat LazyColumn index of the selected model within the provider-grouped list (each group
59 * preceded by a header row), so the sheet can scroll it into view on open. -1 when nothing
60 * is selected or the selection isn't in the current list.
62private fun selectedModelIndex(
63 grouped: Map<String, List<ModelOption>>,
64 selected: ModelOption?,
66 if (selected == null) return -1
68 for ((_, opts) in grouped) {
69 idx++ // provider header
70 val distinct = opts.distinctBy { it.providerID to it.modelID }
71 val pos = distinct.indexOfFirst {
72 it.providerID == selected.providerID && it.modelID == selected.modelID
74 if (pos >= 0) return idx + pos
80/** Bottom sheet that lists every provider/model and lets the user pick one. */
81@OptIn(ExperimentalMaterial3Api::class)
84 options: List<ModelOption>,
85 selected: ModelOption?,
88 onSelect: (ModelOption) -> Unit,
90 onDismiss: () -> Unit,
92 ModalBottomSheet(onDismissRequest = onDismiss) {
94 stringResource(R.string.model),
95 style = MaterialTheme.typography.titleMedium,
96 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
99 val loadingLabel = stringResource(R.string.loading)
101 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
102 contentAlignment = Alignment.Center,
104 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
106 } else if (error && options.isEmpty()) {
108 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
109 horizontalAlignment = Alignment.CenterHorizontally,
112 stringResource(R.string.load_failed),
113 style = MaterialTheme.typography.bodyMedium,
114 color = MaterialTheme.colorScheme.error,
116 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
118 } else if (options.isEmpty()) {
120 stringResource(R.string.no_models),
121 style = MaterialTheme.typography.bodyMedium,
122 color = MaterialTheme.colorScheme.onSurfaceVariant,
123 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
126 var query by rememberSaveable { mutableStateOf("") }
127 val keyboardController = LocalSoftwareKeyboardController.current
128 val haptics = LocalHapticFeedback.current
129 // Auto-focus the search field (and raise the keyboard) when the sheet opens so the
130 // user can start typing to filter a long catalog without an extra tap. Skip it for a
131 // short catalog that's faster to scan by eye than to filter.
132 val searchFocus = remember { FocusRequester() }
133 LaunchedEffect(Unit) {
134 if (options.size > NetworkConfig.pickerSearchAutofocusThreshold) {
135 runCatching { searchFocus.requestFocus() }
140 onValueChange = { query = it },
141 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
142 placeholder = { Text(stringResource(R.string.search_models)) },
143 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
144 trailingIcon = if (query.isNotEmpty()) {
146 IconButton(onClick = { query = "" }) {
147 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
152 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
153 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
155 val filtered = remember(options, query) {
157 if (q.isEmpty()) options
158 else options.filter {
159 it.modelLabel.contains(q, ignoreCase = true) ||
160 it.providerLabel.contains(q, ignoreCase = true)
163 if (filtered.isEmpty()) {
165 stringResource(R.string.no_models_match, query.trim()),
166 style = MaterialTheme.typography.bodyMedium,
167 color = MaterialTheme.colorScheme.onSurfaceVariant,
168 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
171 // Group by provider so a long catalog is scannable: a provider header
172 // followed by its models, instead of a flat list mixing providers.
173 val grouped = remember(filtered) { filtered.groupBy { it.providerID } }
174 val listState = rememberLazyListState()
175 // Flat LazyColumn index of the selected model (accounting for the provider
176 // header rows), so we can scroll it into view when the sheet opens. -1 when
177 // nothing is selected or the selection isn't in the current list.
178 val selectedIndex = remember(grouped, selected) { selectedModelIndex(grouped, selected) }
179 LaunchedEffect(Unit) {
180 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
182 // Responsive height: half the viewport rather than a hardcoded 420dp cap, so
183 // the sheet doesn't cramp in landscape or leave empty space on a tall phone.
184 LazyColumn(state = listState, modifier = Modifier.fillMaxHeight(0.5f)) {
185 grouped.forEach { (providerID, opts) ->
186 item(key = "header_$providerID") {
188 opts.firstOrNull()?.providerLabel ?: providerID,
189 style = MaterialTheme.typography.labelMedium,
190 color = MaterialTheme.colorScheme.primary,
191 modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
194 // distinctBy the (provider, model) pair so a duplicate entry from the
195 // server can't crash the list with LazyColumn's "Key was already used".
196 items(opts.distinctBy { it.providerID to it.modelID }, key = { it.providerID to it.modelID }) { option ->
197 val isSelected = option.providerID == selected?.providerID &&
198 option.modelID == selected?.modelID
202 .semantics(mergeDescendants = true) {
203 if (isSelected) this.selected = true
205 .clickable(role = Role.RadioButton) {
206 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
210 .padding(horizontal = 24.dp, vertical = 12.dp),
212 Row(verticalAlignment = Alignment.CenterVertically) {
215 style = MaterialTheme.typography.bodyLarge,
216 color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
217 modifier = Modifier.weight(1f),
222 contentDescription = null,
223 tint = MaterialTheme.colorScheme.primary,
224 modifier = Modifier.size(18.dp),
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