One control per kind of setting, and no paragraphs under any of them
Three explanatory paragraphs were left on the session settings screen: what auto-resume does, what Move costs, what changing the thinking level costs. The last two are consequences of an action, so they are `RestartDialog` now -- the same question the machines tab already asks before it reloads a model, and now in one file rather than private to that screen. The first is gone; the switch beside it says what it is. Thinking was two controls: a row of chips for a llama session, where it is a provider-declared choice, and a picker button for a Claude session, where it is the session's own effort. One kind of information drawn two ways, decided by which code path the value came down. A choice is `PickerRow` in a list of settings and `ChipGroup` on a form being filled in, and which of the two is the screen's to say rather than the provider's. `warnAboutRestart` went with them. It marked a control "(on restart)" and wrote a sentence under the form, and it had nothing to mark: every session param is `restart: false` and every model param is `restart: true`. The field now decides whether saving a model's settings stops to ask, which is the question it was always about. The wait's bar keeps the status row's own margin instead of running to the edges of the glass. Checked on the emulator against the sandbox: an echo, a claude-cli and a llama session, each with no paragraph left under a setting and thinking drawn the same way in all three; "Think high?", "Move to /tmp?" and the model settings dialog all stop to ask. ktfmt, compile, lint and the unit tests are clean.
This commit is contained in:
1 parent
5626a7d595
commit
3dbf04f5ec
7 files changed
+178
-132
No files matched your search
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -31,11 +30,11 @@ fun ProviderParamFields(
|
||||
values: Map<String, String>,
|
||||
onChange: (Map<String, String>) -> Unit,
|
||||
/**
|
||||
* Whether to say which settings wait for a restart. False on a spawn form, where nothing is
|
||||
* running yet and every setting is about to be read — saying it there would be a warning about
|
||||
* a state the reader cannot be in.
|
||||
* How a choice is drawn here, which is the screen's to decide rather than the setting's: chips
|
||||
* on a form somebody is filling in, a picker row in a list of settings. A provider's choices
|
||||
* have to look like the choices beside them, whichever screen that is.
|
||||
*/
|
||||
warnAboutRestart: Boolean,
|
||||
choices: ChoiceStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (specs.isEmpty()) return
|
||||
@@ -55,16 +54,22 @@ fun ProviderParamFields(
|
||||
// `ParamKind::Choice`. Without that the picker could show a default it could
|
||||
// not return to.
|
||||
val default = spec.options.firstOrNull().orEmpty()
|
||||
ChipGroup(
|
||||
label = spec.label + restartSuffix(spec, warnAboutRestart),
|
||||
options = spec.options,
|
||||
selected = values[spec.key] ?: default,
|
||||
onSelect = { chosen -> set(if (chosen == default) "" else chosen) },
|
||||
)
|
||||
val selected = values[spec.key] ?: default
|
||||
val pick = { chosen: String -> set(if (chosen == default) "" else chosen) }
|
||||
when (choices) {
|
||||
ChoiceStyle.Chips ->
|
||||
ChipGroup(
|
||||
label = spec.label,
|
||||
options = spec.options,
|
||||
selected = selected,
|
||||
onSelect = pick,
|
||||
)
|
||||
ChoiceStyle.Picker -> PickerRow(spec.label, selected, spec.options, pick)
|
||||
}
|
||||
}
|
||||
else ->
|
||||
LabelledField(
|
||||
label = spec.label + restartSuffix(spec, warnAboutRestart),
|
||||
label = spec.label,
|
||||
value = values[spec.key].orEmpty(),
|
||||
onValueChange = set,
|
||||
hint = spec.unset,
|
||||
@@ -76,25 +81,14 @@ fun ProviderParamFields(
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
if (warnAboutRestart && specs.any { it.restart }) {
|
||||
Text(
|
||||
"A setting marked “on restart” is saved now and read when this session's process " +
|
||||
"next starts.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a control whose value will not take effect yet.
|
||||
*
|
||||
* On the label rather than beside it, because the reader decides whether to change the thing before
|
||||
* they touch it — a note underneath is read after the decision.
|
||||
*/
|
||||
private fun restartSuffix(spec: ParamSpec, warn: Boolean): String =
|
||||
if (warn && spec.restart) " (on restart)" else ""
|
||||
/** Which control a [ParamKind.Choice] gets -- see [ProviderParamFields]'s `choices`. */
|
||||
enum class ChoiceStyle {
|
||||
Chips,
|
||||
Picker,
|
||||
}
|
||||
|
||||
/**
|
||||
* The keyboard for a value's shape. A number field that opens the letter keyboard is one every
|
||||
|
||||
@@ -376,30 +376,6 @@ private fun ServerCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a setting read at load time costs, asked before it is written.
|
||||
*
|
||||
* One shape for both of the questions on this screen — a model's settings and the server's —
|
||||
* because they are the same question: this is read when something starts again, and here is what
|
||||
* starting again costs. A paragraph under the control said the same thing and was read after the
|
||||
* decision, if at all.
|
||||
*/
|
||||
@Composable
|
||||
private fun RestartDialog(
|
||||
title: String,
|
||||
text: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = { Text(text) },
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text("Save") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelCard(
|
||||
model: ProviderModel,
|
||||
@@ -485,7 +461,10 @@ private fun ModelSettingsDialog(
|
||||
// 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) }
|
||||
val loaded = model.status == "loaded" || model.status == "sleeping"
|
||||
// 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 --
|
||||
@@ -501,14 +480,13 @@ private fun ModelSettingsDialog(
|
||||
specs = specs,
|
||||
values = params,
|
||||
onChange = { params = it },
|
||||
// Every one of these is read at load time, which is what Save stops to say --
|
||||
// marking each control "on restart" would repeat it six times over.
|
||||
warnAboutRestart = false,
|
||||
// Chips: this is a form of its own rather than a row in a list of settings.
|
||||
choices = ChoiceStyle.Chips,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { if (loaded) confirming = true else onSave(params) }) {
|
||||
TextButton(onClick = { if (reloads) confirming = true else onSave(params) }) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* What a setting costs, asked before it is written.
|
||||
*
|
||||
* Every setting in this app whose consequence is worth saying says it here rather than in a
|
||||
* paragraph beside the control: a sentence under a switch is read after the decision if it is read
|
||||
* at all, and a form of a dozen settings each carrying its own explanation is mostly explanation. A
|
||||
* modal interrupts at the moment the consequence becomes real, and it is also a way out.
|
||||
*
|
||||
* What they have in common, and why it is one dialog rather than four: each of them ends something
|
||||
* that is running — a process, a loaded model, a server — and says what starting it again costs.
|
||||
*/
|
||||
@Composable
|
||||
fun RestartDialog(
|
||||
title: String,
|
||||
text: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
/** The word on the button, which is the action rather than a bare "OK". */
|
||||
confirm: String = "Save",
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = { Text(text) },
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
@@ -2813,12 +2813,17 @@ private fun SessionStatusRow(
|
||||
) {
|
||||
DebugStats.count("status row recomposed")
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
// The wait's bar, the whole width and above the words. In the width left over beside
|
||||
// them it stood where the context figure goes, which is what a reader wants to keep
|
||||
// seeing while a turn is read. The height is held whether or not there is a bar: this
|
||||
// sits above the transcript and the box, and one that came and went would move both
|
||||
// under the reader's thumb every time a turn started.
|
||||
Box(Modifier.fillMaxWidth().height(PROGRESS_BAR_HEIGHT)) {
|
||||
// The wait's bar, above the words and across the width they have -- the row's own margin,
|
||||
// so it lines up with the rest of the screen rather than running to the edges of the
|
||||
// glass. In the width left over beside the words it stood where the context figure goes,
|
||||
// which is what a reader wants to keep seeing while a turn is read. The height is held
|
||||
// whether or not there is a bar: one that came and went would move the transcript and the
|
||||
// box under the reader's thumb every time a turn started.
|
||||
Box(
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(horizontal = STATUS_ROW_MARGIN)
|
||||
.height(PROGRESS_BAR_HEIGHT)
|
||||
) {
|
||||
when {
|
||||
// Indeterminate for a compaction, which is a statement rather than an omission:
|
||||
// the CLI says one has begun and then nothing at all until it has finished --
|
||||
@@ -2855,6 +2860,9 @@ private fun SessionStatusRow(
|
||||
/** How tall a wait's bar is, and the space kept for one when there is no wait. */
|
||||
private val PROGRESS_BAR_HEIGHT = 4.dp
|
||||
|
||||
/** What the status row keeps clear at each side, which its bar lines up with. */
|
||||
private val STATUS_ROW_MARGIN = 12.dp
|
||||
|
||||
/** The words of [SessionStatusRow]: what the session is doing, and what it is holding. */
|
||||
@Composable
|
||||
private fun StatusWords(
|
||||
@@ -2868,7 +2876,7 @@ private fun StatusWords(
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = STATUS_ROW_MARGIN, vertical = 4.dp),
|
||||
) {
|
||||
when (status) {
|
||||
// No spinner: a compaction's bar is above, and nothing arrives in the transcript
|
||||
@@ -3054,6 +3062,23 @@ private const val ONE_TAP_MS = 250L
|
||||
* The button *is* the current setting rather than a label beside one, so the row says what the
|
||||
* session is set to without spending a second line on saying it.
|
||||
*/
|
||||
/**
|
||||
* One setting that is a choice from a list: what it is on the left, what it is set to on the right.
|
||||
*
|
||||
* The one shape for a choice in a list of settings, wherever the choice comes from -- a session's
|
||||
* model, its permission mode, how hard it thinks, and every `ParamKind::Choice` a provider
|
||||
* declares. They were two controls until 2026-09-21, so how hard a llama session thought was a row
|
||||
* of chips and how hard a Claude session thought was this, which is two appearances for one kind of
|
||||
* information.
|
||||
*/
|
||||
@Composable
|
||||
fun PickerRow(label: String, current: String, options: List<String>, onPick: (String) -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(label, modifier = Modifier.weight(1f))
|
||||
PickerButton(current = current, options = options, onPick = onPick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PickerButton(
|
||||
current: String,
|
||||
|
||||
@@ -163,6 +163,10 @@ fun SessionSettingsScreen(
|
||||
var typedCwd by remember(sessionId) { mutableStateOf("") }
|
||||
var cwdError by remember { mutableStateOf<String?>(null) }
|
||||
var movingCwd by remember { mutableStateOf(false) }
|
||||
// The two settings on this screen that end the session's process, held while the reader is
|
||||
// asked whether that is what they meant. Null is nobody being asked.
|
||||
var askedCwd by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
var askedEffort by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(sessionId) {
|
||||
try {
|
||||
@@ -186,9 +190,15 @@ fun SessionSettingsScreen(
|
||||
/**
|
||||
* Moves the session, which ends the process that is in the old directory.
|
||||
*
|
||||
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
|
||||
* process, and a stopped session is a state this app already has a word and a button for.
|
||||
* Only ever reached through [RestartDialog], which is where what it costs is said -- see
|
||||
* `askToMove`.
|
||||
*/
|
||||
fun askToMove() {
|
||||
val chosen = typedCwd.trim()
|
||||
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
|
||||
askedCwd = chosen
|
||||
}
|
||||
|
||||
fun moveCwd() {
|
||||
val chosen = typedCwd.trim()
|
||||
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
|
||||
@@ -209,7 +219,8 @@ fun SessionSettingsScreen(
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses a thinking level, which ends the process the old level was launched with.
|
||||
* Chooses a thinking level, which ends the process the old level was launched with. Asked for
|
||||
* first, the same way a move is.
|
||||
*
|
||||
* Put back if the request is refused, for the reason the notification switch below gives: a
|
||||
* control that stays where it was put after a refusal is stating something untrue.
|
||||
@@ -416,24 +427,14 @@ fun SessionSettingsScreen(
|
||||
label = "Message to send",
|
||||
value = resumeMessage,
|
||||
onValueChange = { resumeMessage = it },
|
||||
// What an empty field means, said above it: the server's own word rather than
|
||||
// a session poked with nothing to read.
|
||||
// What an empty field means: the server's own word rather than a session
|
||||
// poked with nothing to read.
|
||||
hint = DEFAULT_RESUME_MESSAGE,
|
||||
enabled = autoResume == true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions =
|
||||
KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }),
|
||||
)
|
||||
// What it does and what it costs, in the order it happens. The last sentence is the
|
||||
// one that matters: the time below is when the server will *ask*, not a promise
|
||||
// about when the session speaks.
|
||||
Text(
|
||||
"When this session stops because the account is out of quota, the server " +
|
||||
"checks the limit and sends this message once it has lifted. It checks " +
|
||||
"again if the limit is still on.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Only where something is actually waiting. Absent is not a state worth a row: a
|
||||
// session that has not hit a limit has nothing scheduled, which the reader can see
|
||||
// from the switch.
|
||||
@@ -463,17 +464,16 @@ fun SessionSettingsScreen(
|
||||
label = "Working directory",
|
||||
value = typedCwd,
|
||||
onValueChange = { typedCwd = it },
|
||||
// What the field cannot say by being empty: a session that was never given
|
||||
// one starts wherever its launcher does, and this names that rather than
|
||||
// showing a path nobody chose.
|
||||
// What the field cannot say by being empty: a session that was never
|
||||
// given one starts wherever its launcher does.
|
||||
hint = "wherever the session was started",
|
||||
enabled = cwd != null && !movingCwd,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
|
||||
keyboardActions = KeyboardActions(onDone = { askToMove() }),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = { moveCwd() },
|
||||
onClick = { askToMove() },
|
||||
enabled =
|
||||
cwd != null &&
|
||||
!movingCwd &&
|
||||
@@ -483,15 +483,6 @@ fun SessionSettingsScreen(
|
||||
Text(if (movingCwd) "Moving..." else "Move")
|
||||
}
|
||||
}
|
||||
// The whole of what pressing Move does, where it is about to be pressed. A
|
||||
// directory is settled when the process is spawned, so it is ended and the next
|
||||
// thing said to the session starts it in the new place.
|
||||
Text(
|
||||
"Moving stops the session's process. It starts again in the new directory " +
|
||||
"with the next message, or with Start.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
cwdError?.let {
|
||||
Text(
|
||||
it,
|
||||
@@ -501,48 +492,23 @@ fun SessionSettingsScreen(
|
||||
}
|
||||
choices.forEach { choice ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(choice.label, modifier = Modifier.weight(1f))
|
||||
PickerButton(
|
||||
current = choice.current,
|
||||
options = choice.options,
|
||||
onPick = choice.onPick,
|
||||
)
|
||||
}
|
||||
PickerRow(choice.label, choice.current, choice.options, choice.onPick)
|
||||
}
|
||||
// Left out rather than disabled, the one place this dialog does that: a disabled
|
||||
// control teaches what the thing can do, and a llama session cannot do this at all
|
||||
// -- the row would be teaching something false about it.
|
||||
if (takesEffort) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Thinking", modifier = Modifier.weight(1f))
|
||||
PickerButton(
|
||||
current = effort ?: DEFAULT_EFFORT,
|
||||
// The level the CLI picks for itself is in the list as well as in the
|
||||
// button, so leaving a level is not a one-way trip -- the same
|
||||
// correction the model picker carries.
|
||||
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
||||
onPick = { chosen ->
|
||||
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
|
||||
},
|
||||
)
|
||||
PickerRow(
|
||||
"Thinking",
|
||||
effort ?: DEFAULT_EFFORT,
|
||||
// The level the CLI picks for itself is in the list as well as in the
|
||||
// button, so leaving a level is not a one-way trip -- the same correction
|
||||
// the model picker carries.
|
||||
listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
||||
) { chosen ->
|
||||
askedEffort = chosen
|
||||
}
|
||||
// What it costs, said where it is about to be pressed, like Move above: the
|
||||
// CLI reads the level when it launches and has no control request for
|
||||
// changing one.
|
||||
Text(
|
||||
"Changing this stops the session's process. It starts again with the " +
|
||||
"next message, or with Start.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
effortError?.let {
|
||||
Text(
|
||||
it,
|
||||
@@ -565,7 +531,9 @@ fun SessionSettingsScreen(
|
||||
specs = paramSpecs,
|
||||
values = params,
|
||||
onChange = onParamsChanged,
|
||||
warnAboutRestart = true,
|
||||
// The same picker the model and permission rows above use: how hard a
|
||||
// session thinks is one kind of setting, whichever provider declares it.
|
||||
choices = ChoiceStyle.Picker,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -649,6 +617,40 @@ fun SessionSettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A directory is settled when the process is spawned, so moving means ending it. Said here
|
||||
// rather than under the field, which is the rule the whole screen follows -- see
|
||||
// [RestartDialog].
|
||||
askedCwd?.let { chosen ->
|
||||
RestartDialog(
|
||||
title = "Move to $chosen?",
|
||||
text =
|
||||
"This stops the session's process. It starts again in the new directory with " +
|
||||
"the next message, or with Start.",
|
||||
confirm = "Move",
|
||||
onConfirm = {
|
||||
askedCwd = null
|
||||
moveCwd()
|
||||
},
|
||||
onDismiss = { askedCwd = null },
|
||||
)
|
||||
}
|
||||
// The same cost for the same reason: the CLI reads the level when it launches, and has no
|
||||
// control request for changing one.
|
||||
askedEffort?.let { chosen ->
|
||||
RestartDialog(
|
||||
title = "Think $chosen?",
|
||||
text =
|
||||
"This stops the session's process. It starts again with the next message, or " +
|
||||
"with Start.",
|
||||
confirm = "Change",
|
||||
onConfirm = {
|
||||
askedEffort = null
|
||||
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
|
||||
},
|
||||
onDismiss = { askedEffort = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -280,7 +280,8 @@ fun SpawnScreen(
|
||||
specs = current?.params.orEmpty(),
|
||||
values = params,
|
||||
onChange = { params = it },
|
||||
warnAboutRestart = false,
|
||||
// Chips, like every other choice on this form.
|
||||
choices = ChoiceStyle.Chips,
|
||||
)
|
||||
|
||||
// Every session whose tools act on files needs one, which is both kinds that have
|
||||
|
||||
Reference in new issue
Block a user