package net.kentonc.si.ui import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar 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.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import net.kentonc.si.AgentViewModel import net.kentonc.si.agent.Config import net.kentonc.si.agent.LlmClient import net.kentonc.si.agent.ModelCatalog /** * Editable settings, the runtime equivalent of src/config.rs: server endpoint, * model (with live discovery — OpenAI `/v1/models` and native Ollama * `/api/tags`), sampling, and the approval policy. */ @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable fun SettingsScreen(vm: AgentViewModel) { val current = vm.store.config var baseUrl by rememberSaveable { mutableStateOf(current.baseUrl) } var apiKey by rememberSaveable { mutableStateOf(current.apiKey) } var model by rememberSaveable { mutableStateOf(current.model) } var temperature by rememberSaveable { mutableStateOf(current.temperature.toString()) } var maxTokens by rememberSaveable { mutableStateOf(current.maxTokens.toString()) } var autoApprove by rememberSaveable { mutableStateOf(current.autoApprove) } var error by rememberSaveable { mutableStateOf(null) } // Model discovery: list + dropdown, auto-loaded once per visit. val scope = rememberCoroutineScope() var models by remember { mutableStateOf>(emptyList()) } var fetching by remember { mutableStateOf(false) } var fetchError by remember { mutableStateOf(null) } var menuExpanded by remember { mutableStateOf(false) } fun loadCatalog() { if (fetching) return scope.launch { fetching = true fetchError = null try { models = ModelCatalog.fetch(baseUrl, apiKey, LlmClient.client) if (model.isBlank() && models.isNotEmpty()) model = models.first().id menuExpanded = true } catch (e: Exception) { models = emptyList() fetchError = "couldn't list models: ${e.message} — you can still type a name" } finally { fetching = false } } } LaunchedEffect(Unit) { loadCatalog() } Scaffold( topBar = { TopAppBar( title = { Text("Settings") }, navigationIcon = { IconButton(onClick = { vm.toggleSettings(false) }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, ) }, ) { padding -> Column( modifier = Modifier .padding(padding) .consumeWindowInsets(padding) .imePadding() .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), ) { Field("Server base URL", baseUrl, onChange = { baseUrl = it }, placeholder = "http://192.168.1.10:11434 (Ollama) or :8085 (llama-server)") Field("API key", apiKey, onChange = { apiKey = it }, placeholder = "none (llama-server & ollama ignore it)") // Model: type freely or pick from what the server actually serves. ExposedDropdownMenuBox( expanded = menuExpanded && models.isNotEmpty() && !fetching, onExpandedChange = { if (models.isNotEmpty()) menuExpanded = it }, ) { OutlinedTextField( value = model, onValueChange = { model = it }, label = { Text("Model") }, placeholder = { Text("type a name or load the list ↻") }, singleLine = true, trailingIcon = { if (fetching) { CircularProgressIndicator( strokeWidth = 2.dp, modifier = Modifier.size(18.dp), ) } else { Row { IconButton(onClick = { loadCatalog() }) { Icon(Icons.Filled.Refresh, contentDescription = "Load models from server") } IconButton(onClick = { menuExpanded = !menuExpanded }) { ExposedDropdownMenuDefaults.TrailingIcon(expanded = menuExpanded) } } } }, modifier = Modifier .menuAnchor() .fillMaxWidth() .padding(vertical = 6.dp), ) ExposedDropdownMenu( expanded = menuExpanded && models.isNotEmpty() && !fetching, onDismissRequest = { menuExpanded = false }, ) { Text( "models at ${baseUrl.trim().trimEnd('/')}", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), ) models.forEach { entry -> DropdownMenuItem( text = { Text("${entry.id}${entry.sizeHint}") }, onClick = { model = entry.id menuExpanded = false }, ) } } } fetchError?.let { Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } Field("Temperature", temperature, onChange = { temperature = it }, numeric = true) Field("Max tokens", maxTokens, onChange = { maxTokens = it }, numeric = true) Row( verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(top = 12.dp, bottom = 8.dp), ) { Column(Modifier.weight(1f)) { Text("Auto-approve mutating tools", style = MaterialTheme.typography.bodyLarge) Text( "Writes and shell commands run without asking (the CLI's --yes)", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(Modifier.width(8.dp)) Switch(checked = autoApprove, onCheckedChange = { autoApprove = it }) } Text( "Working directory (the tools' root):", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( vm.rootDir.absolutePath, fontFamily = FontFamily.Monospace, fontSize = 12.sp, ) error?.let { Text(it, color = MaterialTheme.colorScheme.error, fontSize = 13.sp) } Button( onClick = { val temp = temperature.toFloatOrNull() val tokens = maxTokens.toIntOrNull() when { temp == null || temp <= 0f || temp > 2f -> error = "Temperature must be a number between 0 and 2" tokens == null || tokens < 1 || tokens > 100_000 -> error = "Max tokens must be a number between 1 and 100000" baseUrl.trim().isEmpty() -> error = "Server base URL is required" model.trim().isEmpty() -> error = "Model is required" else -> { error = null vm.saveSettings( Config( baseUrl = baseUrl.trim(), apiKey = apiKey.trim().ifBlank { "none" }, model = model.trim(), temperature = temp, maxTokens = tokens, autoApprove = autoApprove, ), ) } } }, modifier = Modifier .padding(vertical = 16.dp) .fillMaxWidth(), ) { Text("Save") } } } } @Composable private fun Field( label: String, value: String, onChange: (String) -> Unit, modifier: Modifier = Modifier, placeholder: String = "", numeric: Boolean = false, ) { OutlinedTextField( value = value, onValueChange = onChange, label = { Text(label) }, placeholder = { Text(placeholder, fontSize = 12.sp) }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = if (numeric) KeyboardType.Number else KeyboardType.Uri, ), modifier = modifier .fillMaxWidth() .padding(vertical = 6.dp), ) }