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.layout.size
12import androidx.compose.foundation.lazy.LazyColumn
13import androidx.compose.foundation.lazy.items
14import androidx.compose.foundation.lazy.rememberLazyListState
15import androidx.compose.foundation.text.KeyboardActions
16import androidx.compose.foundation.text.KeyboardOptions
17import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.filled.Check
19import androidx.compose.material.icons.filled.Close
20import androidx.compose.material.icons.filled.Search
21import androidx.compose.material3.CircularProgressIndicator
22import androidx.compose.material3.ExperimentalMaterial3Api
23import androidx.compose.material3.Icon
24import androidx.compose.material3.IconButton
25import androidx.compose.material3.MaterialTheme
26import androidx.compose.material3.ModalBottomSheet
27import androidx.compose.material3.OutlinedTextField
28import androidx.compose.material3.Text
29import androidx.compose.material3.TextButton
30import androidx.compose.material3.rememberModalBottomSheetState
31import androidx.compose.runtime.Composable
32import androidx.compose.runtime.LaunchedEffect
33import androidx.compose.runtime.getValue
34import androidx.compose.runtime.mutableStateOf
35import androidx.compose.runtime.remember
36import androidx.compose.runtime.rememberCoroutineScope
37import androidx.compose.runtime.saveable.rememberSaveable
38import androidx.compose.runtime.setValue
39import androidx.compose.ui.Alignment
40import androidx.compose.ui.Modifier
41import androidx.compose.ui.focus.FocusRequester
42import androidx.compose.ui.focus.focusRequester
43import androidx.compose.ui.hapticfeedback.HapticFeedbackType
44import androidx.compose.ui.platform.LocalHapticFeedback
45import androidx.compose.ui.platform.LocalSoftwareKeyboardController
46import androidx.compose.ui.res.stringResource
47import androidx.compose.ui.semantics.Role
48import androidx.compose.ui.semantics.contentDescription
49import androidx.compose.ui.semantics.role
50import androidx.compose.ui.semantics.selectableGroup
51import androidx.compose.ui.semantics.selected
52import androidx.compose.ui.semantics.semantics
53import androidx.compose.ui.text.input.ImeAction
54import androidx.compose.ui.unit.dp
55import androidx.compose.foundation.layout.Box
56import kotlinx.coroutines.launch
57import soy.iko.opencode.data.model.ModelOption
58import soy.iko.opencode.data.network.NetworkConfig
59import soy.iko.opencode.R
62 * Flat LazyColumn index of the selected model within the provider-grouped list (each group
63 * preceded by a header row), so the sheet can scroll it into view on open. -1 when nothing
64 * is selected or the selection isn't in the current list.
66private fun selectedModelIndex(
67 grouped: Map<String, List<ModelOption>>,
68 selected: ModelOption?,
70 if (selected == null) return -1
72 for ((_, opts) in grouped) {
73 idx++ // provider header
74 val distinct = opts.distinctBy { it.providerID to it.modelID }
75 val pos = distinct.indexOfFirst {
76 it.providerID == selected.providerID && it.modelID == selected.modelID
78 if (pos >= 0) return idx + pos
84/** Bottom sheet that lists every provider/model and lets the user pick one. */
85@OptIn(ExperimentalMaterial3Api::class)
88 options: List<ModelOption>,
89 selected: ModelOption?,
92 onSelect: (ModelOption) -> Unit,
94 onDismiss: () -> Unit,
95 preferredModelId: String = "",
96 onSetPreferredModel: (String) -> Unit = {},
97 recent: List<ModelOption> = emptyList(),
99 // Hoist SheetState + skipPartiallyExpanded so the sheet opens full-height and selection
100 // animates out (see AgentPickerSheet for the full rationale).
101 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
102 val scope = rememberCoroutineScope()
103 ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
105 stringResource(R.string.model),
106 style = MaterialTheme.typography.titleMedium,
107 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
110 val loadingLabel = stringResource(R.string.loading_models)
112 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(),
113 horizontalAlignment = Alignment.CenterHorizontally,
115 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
117 stringResource(R.string.loading_models),
118 style = MaterialTheme.typography.bodyMedium,
119 color = MaterialTheme.colorScheme.onSurfaceVariant,
120 modifier = Modifier.padding(top = 8.dp),
123 } else if (error && options.isEmpty()) {
125 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
126 horizontalAlignment = Alignment.CenterHorizontally,
129 stringResource(R.string.load_failed),
130 style = MaterialTheme.typography.bodyMedium,
131 color = MaterialTheme.colorScheme.error,
133 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
135 } else if (options.isEmpty()) {
137 stringResource(R.string.no_models),
138 style = MaterialTheme.typography.bodyMedium,
139 color = MaterialTheme.colorScheme.onSurfaceVariant,
140 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
143 var query by rememberSaveable { mutableStateOf("") }
144 val keyboardController = LocalSoftwareKeyboardController.current
145 val haptics = LocalHapticFeedback.current
146 // Auto-focus the search field (and raise the keyboard) when the sheet opens so the
147 // user can start typing to filter a long catalog without an extra tap. Skip it for a
148 // short catalog that's faster to scan by eye than to filter.
149 val searchFocus = remember { FocusRequester() }
150 LaunchedEffect(Unit) {
151 if (options.size > NetworkConfig.pickerSearchAutofocusThreshold) {
152 runCatching { searchFocus.requestFocus() }
157 onValueChange = { query = it },
158 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
159 placeholder = { Text(stringResource(R.string.search_models)) },
160 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
161 trailingIcon = if (query.isNotEmpty()) {
163 IconButton(onClick = { query = "" }) {
164 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
169 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
170 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
172 // Dedupe the source list once (keyed on `options`), then filter the already-distinct
173 // list, so distinctBy doesn't re-walk the catalog on every keystroke.
174 val distinct = remember(options) { options.distinctBy { it.providerID to it.modelID } }
175 val filtered = remember(distinct, query) {
177 if (q.isEmpty()) distinct
178 else distinct.filter {
179 it.modelLabel.contains(q, ignoreCase = true) ||
180 it.providerLabel.contains(q, ignoreCase = true)
183 if (filtered.isEmpty()) {
185 stringResource(R.string.no_models_match, query.trim()),
186 style = MaterialTheme.typography.bodyMedium,
187 color = MaterialTheme.colorScheme.onSurfaceVariant,
188 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
191 // Group by provider so a long catalog is scannable: a provider header
192 // followed by its models, instead of a flat list mixing providers.
193 val grouped = remember(filtered) { filtered.groupBy { it.providerID } }
194 val listState = rememberLazyListState()
195 // Flat LazyColumn index of the selected model (accounting for the provider
196 // header rows), so we can scroll it into view when the sheet opens. -1 when
197 // nothing is selected or the selection isn't in the current list.
198 val selectedIndex = remember(grouped, selected) { selectedModelIndex(grouped, selected) }
199 // Re-scroll on filter change so the selection comes back into view after the
200 // filter is cleared (mirrors AgentPickerSheet).
201 LaunchedEffect(grouped) {
202 if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
204 // Recents are only surfaced when the user hasn't filtered: a search query
205 // already scopes the list, so a duplicate "Recent" section would be noise.
206 // Entries are also pruned to models still present in the catalog (a recent
207 // pick may have been removed server-side) and ordered by recency.
208 val recentShown = remember(filtered, recent, query) {
209 if (query.isNotBlank()) emptyList()
211 val available = filtered.associateBy { it.providerID to it.modelID }
212 recent.mapNotNull { available[it.providerID to it.modelID] }
218 .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp)
220 .navigationBarsPadding()
221 .semantics { selectableGroup() },
223 if (recentShown.isNotEmpty()) {
224 item(key = "header_recent") {
226 stringResource(R.string.recent_models),
227 style = MaterialTheme.typography.labelMedium,
228 color = MaterialTheme.colorScheme.primary,
229 modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
232 items(recentShown, key = { "recent_${it.providerID}_${it.modelID}" }) { option ->
233 ModelRow(option, selected) {
234 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
235 scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
240 grouped.forEach { (providerID, opts) ->
241 item(key = "header_$providerID") {
243 opts.firstOrNull()?.providerLabel ?: providerID,
244 style = MaterialTheme.typography.labelMedium,
245 color = MaterialTheme.colorScheme.primary,
246 modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
249 // opts is already distinct (filtered derives from the distinct list),
250 // so no per-row distinctBy is needed here.
251 items(opts, key = { it.providerID to it.modelID }) { option ->
252 ModelRow(option, selected) {
253 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
254 scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
260 // Footer: a "set as default" toggle for the currently-selected model, so a user
261 // who always picks a specific model doesn't have to pick it every new session.
262 // Only shown when there's a selection; tapping clears the preference if it's
263 // already the default (toggle behavior).
264 if (selected != null) {
265 val isDefault = preferredModelId == selected.modelID
269 .padding(horizontal = 24.dp, vertical = 8.dp)
270 .navigationBarsPadding(),
271 verticalAlignment = Alignment.CenterVertically,
275 if (isDefault) R.string.default_model_current else R.string.set_as_default_model,
278 style = MaterialTheme.typography.bodySmall,
279 color = MaterialTheme.colorScheme.onSurfaceVariant,
280 modifier = Modifier.weight(1f),
284 onSetPreferredModel(if (isDefault) "" else selected.modelID)
288 stringResource(if (isDefault) R.string.clear_default else R.string.set_as_default),
298/** A single selectable model row in the picker. Shared by the "Recent" section and the
299 * provider-grouped list so the two stay visually identical. */
301private fun ModelRow(option: ModelOption, selected: ModelOption?, onClick: () -> Unit) {
302 val isSelected = option.providerID == selected?.providerID &&
303 option.modelID == selected?.modelID
307 .semantics(mergeDescendants = true) {
308 this.selected = isSelected
310 .clickable(role = Role.RadioButton, onClick = onClick)
311 .padding(horizontal = 24.dp, vertical = 12.dp),
313 Row(verticalAlignment = Alignment.CenterVertically) {
316 style = MaterialTheme.typography.bodyLarge,
317 color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
318 modifier = Modifier.weight(1f),
323 contentDescription = null,
324 tint = MaterialTheme.colorScheme.primary,
325 modifier = Modifier.size(20.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