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<String?>(null) }
// Model discovery: list + dropdown, auto-loaded once per visit.
val scope = rememberCoroutineScope()
var models by remember { mutableStateOf<List<ModelCatalog.Entry>>(emptyList()) }
var fetching by remember { mutableStateOf(false) }
var fetchError by remember { mutableStateOf<String?>(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),
)
}