The Claude model list was four words in machines.rs, copied into every
machine's config.ron at discovery -- so a model the CLI had gained was
unreachable from the phone, which is how Opus 5.5 came to be invisible.
Every catalog is now read from the provider itself when a picker opens:
Claude Code over its control channel (control_request{subtype:list_models},
the same channel the driver sends set_model down, since the CLI has no
listing command), Codex over app-server, llama.cpp from the GGUFs on its
machine. Echo is the one case config.ron still answers, having nothing to
ask. A row the CLI marks disabled -- a model the installed version is too
old to run -- is dropped rather than offered as a chip that fails.
The reading of each catalog lives in that driver's own module, and
provider_models is one line per driver: core code says which driver to ask
and never what an answer looks like. parse_codex_models moved out for the
same reason.
Beside it, a provider now reports the version of its program and can be
told to update it. The version comes from --version on its own machine,
read from whichever stream carried it but only from a run that exited 0 --
llama-server prints its version to stderr, and so does "command not found".
It is never compared against a latest release, which nothing here can know.
Update runs the driver's own updater, or an updateCommand the config file
names for an install those will not touch; its whole output comes back,
because every install on these machines is package-managed and the
updater's refusal is the sentence worth reading. Nothing on its stdin, so a
password prompt fails rather than hangs.
A catalog that cannot be read no longer fails the whole provider view: that
is exactly the provider somebody came there to update, and refusing the
screen took the version and the Update button away with it.
Verified against a scratch server and on the emulator: the live catalog
(1.06s, disabled row absent), all four providers' versions including the
unknown state, the confirmation and output dialogs, a configured command
returning in 87ms with its stdin closed, and Update correctly disabled for
llama.cpp.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
606 lines
27 KiB
Kotlin
606 lines
27 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.imePadding
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
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.window.DialogProperties
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/**
|
|
* One provider on one machine: what it is, what its shared server is holding, and how each of its
|
|
* models is loaded.
|
|
*
|
|
* This is where a setting that belongs to a *machine* lives, as opposed to one that belongs to a
|
|
* session. The two were one list until llama.cpp sessions came to share one server per machine: how
|
|
* a model is loaded stopped being anything a single session could decide, because one copy of it in
|
|
* memory is what several sessions are talking to.
|
|
*
|
|
* It is also the only place a loaded model is taken out of memory. Nothing does that on its own —
|
|
* closing a session leaves the model loaded on purpose, since the next one to want it would
|
|
* otherwise pay the load again — so the memory is freed here, where what it costs everybody is
|
|
* visible.
|
|
*/
|
|
@Composable
|
|
fun ProviderScreen(
|
|
settings: ServerSettings,
|
|
machineId: String,
|
|
provider: String,
|
|
/**
|
|
* The way back, or null where this is drawn inside something that has one of its own -- the
|
|
* session settings screen's second tab. Two ways out stacked above each other is a reader
|
|
* asking which of them goes where.
|
|
*/
|
|
onBack: (() -> Unit)?,
|
|
) {
|
|
val scope = rememberCoroutineScope()
|
|
var state by remember { mutableStateOf<LoadState<ProviderView>>(LoadState.Loading) }
|
|
var reload by remember { mutableIntStateOf(0) }
|
|
var editing by remember { mutableStateOf<ProviderModel?>(null) }
|
|
var confirmingStop by remember { mutableStateOf(false) }
|
|
// What is being done to the server or to one of its models, in a word, and what went wrong
|
|
// when it did. Both here rather than per row: these act on the whole machine.
|
|
var busy by remember { mutableStateOf<String?>(null) }
|
|
var actionError by remember { mutableStateOf<String?>(null) }
|
|
var confirmingDelete by remember { mutableStateOf<ProviderModel?>(null) }
|
|
var confirmingUpdate by remember { mutableStateOf(false) }
|
|
// What the updater said, kept until it is read: it is the whole point of pressing the button —
|
|
// an updater that declines names what to do instead — and there is nowhere else to see it.
|
|
var updateSaid by remember { mutableStateOf<String?>(null) }
|
|
|
|
// The machine's own models and what is being fetched onto it. Only for a provider that serves
|
|
// files off that machine's disk -- everything else names its models rather than holding them,
|
|
// and a search for a GGUF under the Claude CLI would be an offer that leads nowhere.
|
|
val kind = (state as? LoadState.Loaded)?.value?.kind
|
|
val machineModels =
|
|
rememberMachineModels(
|
|
settings = settings,
|
|
machineId = machineId,
|
|
enabled = kind == "llama_cpp",
|
|
// A download that became a model is a model this screen has no settings for yet, so
|
|
// the view it is drawing is now one model short of the truth.
|
|
onLocalChange = { reload++ },
|
|
)
|
|
|
|
LaunchedEffect(reload) {
|
|
state =
|
|
try {
|
|
withContext(Dispatchers.IO) {
|
|
LoadState.Loaded(fetchProvider(settings, machineId, provider))
|
|
}
|
|
} catch (e: ApiException) {
|
|
LoadState.failed(e)
|
|
}
|
|
}
|
|
|
|
// Say what is happening, do it, say what went wrong, refetch: every action on this screen
|
|
// changes what it is showing.
|
|
val act = { what: String, action: suspend () -> Unit ->
|
|
scope.launch {
|
|
busy = what
|
|
actionError =
|
|
runCatching { withContext(Dispatchers.IO) { action() } }.exceptionOrNull()?.message
|
|
busy = null
|
|
reload++
|
|
}
|
|
Unit
|
|
}
|
|
|
|
// The models search at the bottom takes the keyboard, and everything below the field it is
|
|
// typed in -- the Search button, the results -- is behind it without this.
|
|
Column(Modifier.fillMaxSize().imePadding().padding(16.dp)) {
|
|
onBack?.let {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
TextButton(onClick = it) { Text("Back") }
|
|
}
|
|
}
|
|
when (val current = state) {
|
|
is LoadState.Loading -> CircularProgressIndicator()
|
|
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
|
is LoadState.Loaded -> {
|
|
val view = current.value
|
|
Text(view.name, style = MaterialTheme.typography.titleMedium)
|
|
Text(
|
|
"on ${view.machine}",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
view.command?.let {
|
|
Text(
|
|
it,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
fontFamily = FontFamily.Monospace,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
// Under the program it is the version of, and above the control that changes it:
|
|
// the provider's own binary, as opposed to everything below, which is models. A
|
|
// kind with no program to ask draws neither.
|
|
view.version?.let { version ->
|
|
Text(
|
|
when (version) {
|
|
is ProviderVersion.Known -> version.text
|
|
// Words, not a colour: there is no way to tell a version this app
|
|
// failed to read from one that happens to look odd.
|
|
is ProviderVersion.Unknown -> "Version unknown — ${version.why}"
|
|
},
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
// Its own row rather than beside the line above, which is a short string
|
|
// when the version is known and a sentence wrapping to four lines when it is
|
|
// not — and the second is the state this button matters most in.
|
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
|
// Disabled rather than absent, so a provider nothing here can update says
|
|
// so instead of leaving a reader to wonder where the control went.
|
|
TextButton(
|
|
onClick = { confirmingUpdate = true },
|
|
enabled = view.updatable && busy == null,
|
|
) {
|
|
Text("Update")
|
|
}
|
|
}
|
|
}
|
|
Spacer(Modifier.height(12.dp))
|
|
actionError?.let {
|
|
Text(it, color = MaterialTheme.colorScheme.error)
|
|
Spacer(Modifier.height(8.dp))
|
|
}
|
|
busy?.let {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp))
|
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
}
|
|
|
|
LazyColumn(Modifier.fillMaxSize()) {
|
|
view.server?.let { server ->
|
|
item("server") {
|
|
ServerCard(
|
|
server = server,
|
|
maxLoaded = view.maxLoaded,
|
|
enabled = busy == null,
|
|
onStop = { confirmingStop = true },
|
|
onMaxLoaded = { chosen ->
|
|
act("Saving…") {
|
|
setProviderSettings(
|
|
settings,
|
|
machineId,
|
|
provider,
|
|
chosen,
|
|
)
|
|
}
|
|
},
|
|
)
|
|
Spacer(Modifier.height(12.dp))
|
|
}
|
|
}
|
|
if (view.models.isNotEmpty() && view.modelParams.isNotEmpty()) {
|
|
item("models-heading") {
|
|
Text("Models", style = MaterialTheme.typography.titleSmall)
|
|
Spacer(Modifier.height(8.dp))
|
|
}
|
|
}
|
|
// Where the models would be, because that is what is missing. It is not the
|
|
// whole screen's failure: the version above it was read, and the control that
|
|
// might fix this is up there too.
|
|
view.modelsError?.let { failure ->
|
|
item("models-failed") {
|
|
Text(
|
|
"Models unavailable: $failure",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.error,
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
}
|
|
}
|
|
machineModels.actionError?.let { failure ->
|
|
item("models-error") {
|
|
Text(failure, color = MaterialTheme.colorScheme.error)
|
|
}
|
|
}
|
|
// Above the models: this is what is about to be one of them.
|
|
downloadCards(machineModels)
|
|
val sizes = machineModels.sizes
|
|
uniqueItems(view.models, key = { it.id }) { model ->
|
|
ModelCard(
|
|
model = model,
|
|
specs = view.modelParams,
|
|
bytes = sizes[model.id],
|
|
onDelete =
|
|
if (model.id in sizes) ({ confirmingDelete = model }) else null,
|
|
// Tapping opens the settings; a provider whose models take none has
|
|
// nothing to open, so the row is not a control.
|
|
onEdit =
|
|
if (view.modelParams.isEmpty()) null else ({ editing = model }),
|
|
onUnload =
|
|
if (model.status == "loaded" || model.status == "sleeping") {
|
|
{
|
|
act("Unloading ${model.label}…") {
|
|
unloadProviderModel(
|
|
settings,
|
|
machineId,
|
|
provider,
|
|
model.id,
|
|
)
|
|
}
|
|
}
|
|
} else null,
|
|
enabled = busy == null,
|
|
)
|
|
}
|
|
if (kind == "llama_cpp") modelSearch(machineModels)
|
|
if (view.mcpServers.isNotEmpty()) {
|
|
item("mcp") {
|
|
Spacer(Modifier.height(12.dp))
|
|
Text("Tool servers", style = MaterialTheme.typography.titleSmall)
|
|
Text(
|
|
view.mcpServers.joinToString(", ") +
|
|
" — configured on the backend, in its config file.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
editing?.let { model ->
|
|
val view = (state as? LoadState.Loaded)?.value
|
|
ModelSettingsDialog(
|
|
model = model,
|
|
specs = view?.modelParams.orEmpty(),
|
|
onDismiss = { editing = null },
|
|
onSave = { params ->
|
|
editing = null
|
|
act("Saving ${model.label}…") {
|
|
setModelSettings(settings, machineId, provider, model.id, params)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
confirmingDelete?.let { model ->
|
|
AlertDialog(
|
|
onDismissRequest = { confirmingDelete = null },
|
|
title = { Text("Delete ${model.label}?") },
|
|
text = {
|
|
Text(
|
|
"The file is removed from ${(state as? LoadState.Loaded)?.value?.machine ?: "this machine"}. " +
|
|
"Nothing here can get it back -- downloading it again is the whole file again. " +
|
|
"Sessions using it keep their conversations and cannot start it."
|
|
)
|
|
},
|
|
confirmButton = {
|
|
TextButton(
|
|
onClick = {
|
|
confirmingDelete = null
|
|
machineModels.remove(model.id)
|
|
}
|
|
) {
|
|
Text("Delete")
|
|
}
|
|
},
|
|
dismissButton = {
|
|
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
|
|
},
|
|
)
|
|
}
|
|
|
|
if (confirmingUpdate) {
|
|
AlertDialog(
|
|
onDismissRequest = { confirmingUpdate = false },
|
|
title = {
|
|
Text("Update ${(state as? LoadState.Loaded)?.value?.name ?: "this provider"}?")
|
|
},
|
|
text = {
|
|
Text(
|
|
"The program is updated on ${(state as? LoadState.Loaded)?.value?.machine ?: "its machine"}, " +
|
|
"which every session there uses. Sessions already running keep the version they " +
|
|
"started with; the next one to start gets the new one. Nothing here can answer a " +
|
|
"password prompt, so an update needing one stops rather than waits."
|
|
)
|
|
},
|
|
confirmButton = {
|
|
TextButton(
|
|
onClick = {
|
|
confirmingUpdate = false
|
|
act("Updating…") {
|
|
updateSaid = updateProvider(settings, machineId, provider).said
|
|
}
|
|
}
|
|
) {
|
|
Text("Update")
|
|
}
|
|
},
|
|
dismissButton = {
|
|
TextButton(onClick = { confirmingUpdate = false }) { Text("Cancel") }
|
|
},
|
|
)
|
|
}
|
|
|
|
updateSaid?.let { said ->
|
|
AlertDialog(
|
|
onDismissRequest = { updateSaid = null },
|
|
title = { Text("The updater said") },
|
|
text = {
|
|
// Its own words, monospace and scrollable: this is a program's output, and the
|
|
// sentence that matters is as often "use your package manager" as it is a version.
|
|
Text(
|
|
said,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
fontFamily = FontFamily.Monospace,
|
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
|
)
|
|
},
|
|
confirmButton = { TextButton(onClick = { updateSaid = null }) { Text("Done") } },
|
|
)
|
|
}
|
|
|
|
if (confirmingStop) {
|
|
AlertDialog(
|
|
onDismissRequest = { confirmingStop = false },
|
|
title = { Text("Stop this server?") },
|
|
text = {
|
|
// Said plainly rather than hidden: this is the only thing that frees the memory,
|
|
// and what it costs is that every session on this machine reloads its model.
|
|
Text(
|
|
"Every model it is holding is unloaded. Sessions using it will show as " +
|
|
"exited, and the next message to one loads its model again — which is " +
|
|
"the slow part, not the sending."
|
|
)
|
|
},
|
|
confirmButton = {
|
|
TextButton(
|
|
onClick = {
|
|
confirmingStop = false
|
|
act("Stopping…") { stopProviderServer(settings, machineId, provider) }
|
|
}
|
|
) {
|
|
Text("Stop")
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = { confirmingStop = false }) { Text("Cancel") } },
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ServerCard(
|
|
server: ServerState,
|
|
maxLoaded: Int?,
|
|
enabled: Boolean,
|
|
onStop: () -> Unit,
|
|
onMaxLoaded: (Int?) -> Unit,
|
|
) {
|
|
// The saved value is what this starts at and what Save is compared against, so a field left
|
|
// half-typed is visibly not saved rather than quietly either way.
|
|
val saved = maxLoaded?.toString().orEmpty()
|
|
var typed by remember(saved) { mutableStateOf(saved) }
|
|
var confirming by remember { mutableStateOf(false) }
|
|
Card(Modifier.fillMaxWidth()) {
|
|
Column(Modifier.padding(12.dp)) {
|
|
Text("Model server", style = MaterialTheme.typography.titleSmall)
|
|
Text(
|
|
if (server.running) {
|
|
"Running" + (server.port?.let { ", reached on port $it" } ?: "")
|
|
} else {
|
|
// Not a fault: nothing is loaded because nothing has asked. Saying it in
|
|
// words rather than colouring the row, since "stopped" and "we could not
|
|
// ask" would otherwise look the same.
|
|
"Not running. A session starts it when it needs a model."
|
|
},
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
LabelledField(
|
|
label = "Models loaded at once",
|
|
value = typed,
|
|
onValueChange = { typed = it.filter(Char::isDigit) },
|
|
hint = "one -- a second model replaces the first",
|
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
)
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
// Shown whether or not it is running, and disabled when there is nothing to stop:
|
|
// a button that comes and goes makes its own absence the message.
|
|
TextButton(enabled = enabled && server.running, onClick = onStop) { Text("Stop") }
|
|
Spacer(Modifier.weight(1f))
|
|
TextButton(
|
|
enabled = enabled && typed != saved,
|
|
// Saving this while the server is up changes nothing until it comes down
|
|
// again, which is asked rather than written underneath -- see [RestartDialog].
|
|
onClick = {
|
|
if (server.running) confirming = true else onMaxLoaded(typed.toIntOrNull())
|
|
},
|
|
) {
|
|
Text("Save")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (confirming) {
|
|
RestartDialog(
|
|
title = "Save for the next start?",
|
|
text =
|
|
"This server is running, and how many models it keeps loaded was decided when it " +
|
|
"started. Saving now changes what it does the next time it starts -- stop it " +
|
|
"here to have that be now.",
|
|
onConfirm = {
|
|
confirming = false
|
|
onMaxLoaded(typed.toIntOrNull())
|
|
},
|
|
onDismiss = { confirming = false },
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ModelCard(
|
|
model: ProviderModel,
|
|
specs: List<ParamSpec>,
|
|
/** How big the file is on the machine, for a provider whose models are files. */
|
|
bytes: Long?,
|
|
onEdit: (() -> Unit)?,
|
|
onUnload: (() -> Unit)?,
|
|
onDelete: (() -> Unit)?,
|
|
enabled: Boolean,
|
|
) {
|
|
Card(
|
|
Modifier.fillMaxWidth()
|
|
.padding(vertical = 4.dp)
|
|
.then(if (onEdit != null && enabled) Modifier.clickable(onClick = onEdit) else Modifier)
|
|
) {
|
|
Column(Modifier.padding(12.dp)) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
model.label,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
bytes?.let {
|
|
Text(
|
|
gigabytes(it),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
// What the server is doing with it, in its own word. Absent means nobody could ask --
|
|
// the server is not running -- and the line is left out rather than guessed at.
|
|
model.status?.let {
|
|
Text(
|
|
it,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
if (model.settings.isNotEmpty()) {
|
|
Text(
|
|
// In the words the dialog uses, and in the order it draws them: a summary
|
|
// naming `contextSize` is a summary of a different screen than the one it
|
|
// sits under.
|
|
specs
|
|
.mapNotNull { spec ->
|
|
model.settings[spec.key]?.let { "${spec.label} $it" }
|
|
}
|
|
.joinToString(", "),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
if (onUnload != null || onDelete != null) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
// Both shown whenever this kind of model has them, disabled rather than
|
|
// absent: unloading frees memory and deleting frees disk, and a button that
|
|
// comes and goes makes its own absence the message.
|
|
onUnload?.let { TextButton(enabled = enabled, onClick = it) { Text("Unload") } }
|
|
Spacer(Modifier.weight(1f))
|
|
onDelete?.let { TextButton(enabled = enabled, onClick = it) { Text("Delete") } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How one model is loaded.
|
|
*
|
|
* Saved on Save rather than as it is typed, unlike the session settings dialog: writing this
|
|
* unloads the model for everybody using it, which is not something to do once per keystroke.
|
|
*/
|
|
@Composable
|
|
private fun ModelSettingsDialog(
|
|
model: ProviderModel,
|
|
specs: List<ParamSpec>,
|
|
onDismiss: () -> Unit,
|
|
onSave: (Map<String, String>) -> Unit,
|
|
) {
|
|
var params by remember(model.id) { mutableStateOf(model.settings) }
|
|
// Asked over this dialog rather than instead of it, so Cancel comes back to the edits rather
|
|
// than throwing them away.
|
|
var confirming by remember(model.id) { mutableStateOf(false) }
|
|
// Whether saving costs anything worth asking about: something has to be in memory, and at
|
|
// least one of these settings has to be one it read on the way in.
|
|
val reloads =
|
|
(model.status == "loaded" || model.status == "sleeping") && specs.any { it.restart }
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
// Every control here is a number, so the keyboard is up for most of this dialog's life --
|
|
// and a dialog that keeps its own size under the keyboard puts Save off the bottom of the
|
|
// screen, where nothing on screen says it is there. Taking the insets ourselves is what
|
|
// lets `imePadding` shrink it instead.
|
|
properties = DialogProperties(decorFitsSystemWindows = false),
|
|
modifier = Modifier.imePadding(),
|
|
title = { Text(model.label) },
|
|
text = {
|
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
|
ProviderParamFields(
|
|
specs = specs,
|
|
values = params,
|
|
onChange = { params = it },
|
|
// Chips: this is a form of its own rather than a row in a list of settings.
|
|
choices = ChoiceStyle.Chips,
|
|
)
|
|
}
|
|
},
|
|
confirmButton = {
|
|
TextButton(onClick = { if (reloads) confirming = true else onSave(params) }) {
|
|
Text("Save")
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
|
)
|
|
if (confirming) {
|
|
RestartDialog(
|
|
title = "Unload ${model.label}?",
|
|
text =
|
|
"It is in memory now, and these are read when it is loaded. Saving takes it out " +
|
|
"of memory; the sessions using it load it again with these settings on their " +
|
|
"next message.",
|
|
onConfirm = {
|
|
confirming = false
|
|
onSave(params)
|
|
},
|
|
onDismiss = { confirming = false },
|
|
)
|
|
}
|
|
}
|