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.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.filled.Check
16import androidx.compose.material.icons.filled.Search
17import androidx.compose.material3.CircularProgressIndicator
18import androidx.compose.material3.ExperimentalMaterial3Api
19import androidx.compose.material3.Icon
20import androidx.compose.material3.MaterialTheme
21import androidx.compose.material3.ModalBottomSheet
22import androidx.compose.material3.OutlinedTextField
23import androidx.compose.material3.Text
24import androidx.compose.material3.TextButton
25import androidx.compose.runtime.Composable
26import androidx.compose.runtime.getValue
27import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.remember
29import androidx.compose.runtime.saveable.rememberSaveable
30import androidx.compose.runtime.setValue
31import androidx.compose.ui.Alignment
32import androidx.compose.ui.Modifier
33import androidx.compose.ui.platform.LocalSoftwareKeyboardController
34import androidx.compose.ui.res.stringResource
35import androidx.compose.ui.semantics.Role
36import androidx.compose.ui.semantics.contentDescription
37import androidx.compose.ui.semantics.role
38import androidx.compose.ui.semantics.selected
39import androidx.compose.ui.semantics.semantics
40import androidx.compose.ui.text.input.ImeAction
41import androidx.compose.ui.unit.dp
42import androidx.compose.foundation.layout.Box
43import soy.iko.opencode.data.model.ModelOption
44import soy.iko.opencode.R
46/** Bottom sheet that lists every provider/model and lets the user pick one. */
47@OptIn(ExperimentalMaterial3Api::class)
50 options: List<ModelOption>,
51 selected: ModelOption?,
54 onSelect: (ModelOption) -> Unit,
56 onDismiss: () -> Unit,
58 ModalBottomSheet(onDismissRequest = onDismiss) {
60 stringResource(R.string.model),
61 style = MaterialTheme.typography.titleMedium,
62 modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
65 val loadingLabel = stringResource(R.string.loading)
67 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
68 contentAlignment = Alignment.Center,
70 CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
72 } else if (error && options.isEmpty()) {
74 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
75 horizontalAlignment = Alignment.CenterHorizontally,
78 stringResource(R.string.load_failed),
79 style = MaterialTheme.typography.bodyMedium,
80 color = MaterialTheme.colorScheme.error,
82 TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
84 } else if (options.isEmpty()) {
86 stringResource(R.string.no_models),
87 style = MaterialTheme.typography.bodyMedium,
88 color = MaterialTheme.colorScheme.onSurfaceVariant,
89 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
92 var query by rememberSaveable { mutableStateOf("") }
93 val keyboardController = LocalSoftwareKeyboardController.current
96 onValueChange = { query = it },
97 modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
98 placeholder = { Text(stringResource(R.string.search_models)) },
99 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
101 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
102 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
104 val filtered = remember(options, query) {
106 if (q.isEmpty()) options
107 else options.filter {
108 it.modelLabel.contains(q, ignoreCase = true) ||
109 it.providerLabel.contains(q, ignoreCase = true)
112 if (filtered.isEmpty()) {
114 stringResource(R.string.no_models_match, query.trim()),
115 style = MaterialTheme.typography.bodyMedium,
116 color = MaterialTheme.colorScheme.onSurfaceVariant,
117 modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
120 // Group by provider so a long catalog is scannable: a provider header
121 // followed by its models, instead of a flat list mixing providers.
122 val grouped = remember(filtered) { filtered.groupBy { it.providerID } }
123 LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
124 grouped.forEach { (providerID, opts) ->
125 item(key = "header_$providerID") {
127 opts.firstOrNull()?.providerLabel ?: providerID,
128 style = MaterialTheme.typography.labelMedium,
129 color = MaterialTheme.colorScheme.primary,
130 modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
133 items(opts, key = { it.providerID to it.modelID }) { option ->
134 val isSelected = option.providerID == selected?.providerID &&
135 option.modelID == selected?.modelID
139 .semantics(mergeDescendants = true) {
140 if (isSelected) this.selected = true
142 .clickable(role = Role.RadioButton) {
146 .padding(horizontal = 24.dp, vertical = 12.dp),
148 Row(verticalAlignment = Alignment.CenterVertically) {
151 style = MaterialTheme.typography.bodyLarge,
152 color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
153 modifier = Modifier.weight(1f),
158 contentDescription = null,
159 tint = MaterialTheme.colorScheme.primary,
160 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