diff --git a/AGENTS.md b/AGENTS.md index 1feff83..2522a17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,8 +185,19 @@ Module-by-module intent is in PLAN.md's "Backend layout". line's worth. Material's outlined field spends the height of three lines to hold one, which on a form of a dozen settings is a screen and a half of scrolling. What is *not* shrunk is the value -- the framing is what was - expensive. A field's `hint` is what leaving it blank means, above the box - with the label, because inside it is gone the moment anybody types. + expensive. A field's `hint` is what leaving it blank means, drawn inside the + empty box: **a setting is a title and a control and nothing else**, so no + explanatory line sits between them and no paragraph sits under them. + **What a setting costs is asked rather than written down** -- `RestartDialog` + is that question wherever it comes up (a model's settings, the shared + server's, moving a session's directory, changing its thinking level), because + each of them ends something that is running, and a sentence beside the control + is read after the decision if at all. + **One kind of information gets one control.** 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 (`ProviderParamFields`' `choices`) rather than the + provider's -- a llama session's thinking level and a Claude session's have to + look the same. **Session settings is a screen with two tabs** (`SessionSettingsScreen.kt`), drawn over the session like the file explorer so the session stays composed. The second tab is `ProviderScreen` itself -- the same composable the machines diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderParams.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderParams.kt index a976b4c..5465ad7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderParams.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderParams.kt @@ -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, onChange: (Map) -> 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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt index d9991fa..1aa8a48 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt @@ -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") } }, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/RestartDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/RestartDialog.kt new file mode 100644 index 0000000..1bd6ee6 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/RestartDialog.kt @@ -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") } }, + ) +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index f993e86..21cab85 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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, 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, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt index 466584d..fc27759 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt @@ -163,6 +163,10 @@ fun SessionSettingsScreen( var typedCwd by remember(sessionId) { mutableStateOf("") } var cwdError by remember { mutableStateOf(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(null) } + var askedEffort by remember(sessionId) { mutableStateOf(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 }, + ) + } } /** diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index d285ada..515bf19 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -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