diff --git a/AGENTS.md b/AGENTS.md index fd3eee8..8229737 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,6 +180,19 @@ Module-by-module intent is in PLAN.md's "Backend layout". state and thread id, and reads subscription limits through the same CLI protocol. - `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions". + **Every text field is `LabelledField`** (`Field.kt`): the label is a line + above the box rather than a thing floating inside it, and the padding is one + 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. + **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 + tab opens -- so a provider's settings have two ways in and one + implementation; it takes `onBack = null` there, since the screen around it + has one. `AppRoot.kt` is the navigation `when`; `SidePanels.kt` the one drag that slides the whole main screen over a session from the left (`MainPanel.kt`) and what it has running beside the turn -- its background tasks over its diff --git a/PLAN.md b/PLAN.md index a6d4c69..67216ad 100644 --- a/PLAN.md +++ b/PLAN.md @@ -477,6 +477,18 @@ deliberate and easy to undo by accident: only a turn whose model was changed under it. The conversation is read *before* the message is announced, which is what makes "everything before this message" true rather than a race against the pump. +- **A session's system prompt is a param like the rest** (2026-09-21, + `systemPrompt`): it rides in front of the conversation on every request + rather than being recorded as the first thing in it, so changing it takes + effect on the next message instead of leaving a transcript that says + otherwise. One entry in `LLAMA_PARAMS` and no app change, which is the + property that table exists for -- and a new `ParamKind::Prose`, because a + paragraph in a one-line box shows six words of itself. Measured while + building it: the prompt reaches the model exactly where it should + (`/apply-template` puts it first, with the tools preamble after it), but a + 0.6B with seven tool definitions in front of it will ignore a short + instruction that it obeys with no tools. That is the model, not the + plumbing. - **An interrupt ends the wait, not the work** (2026-09-21, `awaiting`, `Cancel`). Everything a llama turn waits on is on the far side of something that cannot be told to stop -- a permission nobody has answered, a shell diff --git a/TODO.md b/TODO.md index 39e2907..ddb6aa9 100644 --- a/TODO.md +++ b/TODO.md @@ -22,33 +22,6 @@ one in place when it turns out to need a decision. ## Session settings -Asked for by Bryan on 2026-09-21, in one run while other work was in flight. -The first two are one change; the rest can land separately. - -- [ ] **A screen, not a modal.** The settings dialog has outgrown one: it - scrolls inside itself and covers the session it is about. - -- [ ] **Two tabs on that screen, the way the main screen has three.** One is - the session's own settings; the other is *the same provider screen* - reached from the machines tab (`ProviderScreen`), for this session's - provider -- two ways in, one screen, no second copy of the truth. - -- [ ] **Compact fields everywhere.** The label goes above the box rather than - floating inside it, and the padding around the value comes down. The - value's own text size does not change: what is costing a row its height - is the framing, not the text. - -- [ ] **A system prompt per session.** For llama.cpp it is a `system` message - on each request, so it is one entry in `DriverKind::params` and no app - change; whether the CLI drivers get one (`--append-system-prompt`) is a - separate question. - -- [ ] **Stop unloads the model where nothing else is using it.** Today - `Driver::stop` deliberately leaves it in memory, because the server is - the machine's and another session may be on the same model. The answer - is a claim per live session on the router, and an unload when the last - one goes -- not an unconditional unload. - - [ ] Autocompact belongs in session settings; empty disables it, which is the default. Iris chose "hand it to the driver" — only where a driver has auto-compaction of its own. **That option was offered on a false premise diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt index 6bf3d11..9342d58 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt @@ -20,7 +20,6 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -335,12 +334,11 @@ private fun OtherAnswer(text: String, onText: (String) -> Unit) { // No Send of its own: this is one more way to answer the question, and the card's Submit is // what sends it. A second send button beside the field made the shorter half of the card look // like the one that finishes it. - OutlinedTextField( + LabelledField( + label = "Other", value = text, onValueChange = onText, - label = { Text("Other") }, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + modifier = Modifier.padding(top = 8.dp), ) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Field.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Field.kt new file mode 100644 index 0000000..8b457d0 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Field.kt @@ -0,0 +1,126 @@ +package com.example.aiapp + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + +/** + * A text field whose label is a line above it rather than a thing floating inside it. + * + * Every field in this app goes through here, and the reason is vertical space. Material's outlined + * field reserves room for a label that animates into its own border and pads the value by half a + * line top and bottom, so one setting costs the height of three lines of text to hold one. A form + * of ten settings is then a screen and a half of scrolling to read ten short answers. + * + * What is *not* shrunk is the value itself: it stays at body size, because what is expensive here + * is the framing rather than the text, and a field whose contents are smaller than the text beside + * it is a field the reader has to lean in to check. See UI_RULES on never shrinking text to fit. + * + * [hint] is what leaving it blank means, and it goes above the box with the label for the same + * reason the label does: inside, it is gone the moment anybody types, which is exactly when a + * reader looks back to check what they are overriding. + */ +@Composable +fun LabelledField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + hint: String? = null, + enabled: Boolean = true, + /** + * How many lines the box is, at rest. One for a value; several for prose, where the reader is + * writing rather than filling in -- see `ParamKind::Prose`. + */ + lines: Int = 1, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + /** What the keyboard's own action key does, which is usually what the button beside it does. */ + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + Column(modifier.fillMaxWidth()) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 2.dp), + ) + hint?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 2.dp), + ) + } + FieldBox(value, onValueChange, enabled, lines, keyboardOptions, keyboardActions) + } +} + +/** The box itself: the border, the padding, and the text. Shared so the two fields agree. */ +@Composable +private fun FieldBox( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + lines: Int, + keyboardOptions: KeyboardOptions, + keyboardActions: KeyboardActions, +) { + val interactions = remember { MutableInteractionSource() } + val focused by interactions.collectIsFocusedAsState() + // The focused border is the accent at the same width as the resting one. Growing it instead + // would move the text inside by a pixel on every focus, which is a whole form twitching as the + // reader moves down it. + val edge = + when { + !enabled -> MaterialTheme.colorScheme.outlineVariant + focused -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.outline + } + val shape = RoundedCornerShape(8.dp) + val style = + LocalTextStyle.current.merge( + TextStyle( + color = + if (enabled) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + singleLine = lines == 1, + minLines = lines, + textStyle = style, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactions, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = + Modifier.fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHighest, shape) + .border(1.dp, edge, shape) + .padding(horizontal = 10.dp, vertical = 8.dp), + decorationBox = { field -> Box { field() } }, + ) +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt index 99eb9a7..fe3eff7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -693,13 +692,11 @@ private fun CreateDialog( title = { Text("Create in ${baseName(directory)}") }, text = { Column { - OutlinedTextField( + LabelledField( + label = "Name", value = name, onValueChange = { name = it }, - label = { Text("Name") }, - singleLine = true, enabled = !busy, - modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MachineModels.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MachineModels.kt index 820541a..5c0a73b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MachineModels.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MachineModels.kt @@ -13,7 +13,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -213,11 +212,10 @@ fun LazyListScope.modelSearch(state: MachineModelsState) { ) Spacer(Modifier.height(8.dp)) val keyboard = LocalSoftwareKeyboardController.current - OutlinedTextField( + LabelledField( + label = "Search HuggingFace", value = state.query, onValueChange = { state.query = it }, - label = { Text("Search HuggingFace") }, - singleLine = true, // The keyboard's own key searches, and puts itself away to show what it found. The // button below this is under the keyboard while it is up, so without this the only // way to press it is to dismiss the keyboard first -- which nothing on screen says. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MachinesScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MachinesScreen.kt index 805efe1..da8dea1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MachinesScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MachinesScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -350,41 +349,36 @@ private fun AddMachineDialog( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name") }, - singleLine = true, - ) - OutlinedTextField( - value = address, - onValueChange = { address = it }, + LabelledField(label = "Name", value = name, onValueChange = { name = it }) + Spacer(Modifier.height(8.dp)) + LabelledField( // Just the shape. What a blank one means is said once, in the text above this // form -- repeating it here wrapped the label onto a second line. - label = { Text("user@host[:port]") }, - singleLine = true, + label = "user@host[:port]", + value = address, + onValueChange = { address = it }, ) - OutlinedTextField( + Spacer(Modifier.height(8.dp)) + LabelledField( + label = "Key path on the backend", value = identity, onValueChange = { identity = it }, - label = { Text("Key path on the backend") }, - singleLine = true, ) - // Where a file attached from the phone lands on that machine. Blank means the - // session's own directory, which is what most people want. - OutlinedTextField( + Spacer(Modifier.height(8.dp)) + LabelledField( + // Where a file attached from the phone lands on that machine. + label = "Folder for attached files", value = attachmentsDir, onValueChange = { attachmentsDir = it }, - label = { Text("Folder for attached files (optional)") }, - singleLine = true, + hint = "the session's own directory", ) - // Where that machine's GGUFs are, for a llama.cpp session on it. Blank means - // the same place this backend keeps its own downloads, read on that machine. - OutlinedTextField( + Spacer(Modifier.height(8.dp)) + LabelledField( + // Where that machine's GGUFs are, for a llama.cpp session on it. + label = "Folder for models", value = modelsDir, onValueChange = { modelsDir = it }, - label = { Text("Folder for models (optional)") }, - singleLine = true, + hint = "the same place this backend keeps its own downloads", ) tested?.let { Spacer(Modifier.height(8.dp)) @@ -439,12 +433,7 @@ private fun RenameDialog(machine: Machine, onDismiss: () -> Unit, onRename: (Str title = { Text("Rename") }, text = { Column { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name") }, - singleLine = true, - ) + LabelledField(label = "Name", value = name, onValueChange = { name = it }) Spacer(Modifier.height(8.dp)) Text( "Sessions already running on it keep working -- they refer to the machine, " + diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderLoginDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderLoginDialog.kt index 189541c..48b8888 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderLoginDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderLoginDialog.kt @@ -3,12 +3,10 @@ package com.example.aiapp import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -146,12 +144,10 @@ fun ProviderLoginDialog( ) { Text("Open authorization page") } - OutlinedTextField( + LabelledField( + label = "Authorization code", value = code, onValueChange = { code = it }, - label = { Text("Authorization code") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), ) current.detail?.let { Text(it, color = MaterialTheme.colorScheme.error) 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 9483533..a976b4c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderParams.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderParams.kt @@ -6,7 +6,6 @@ 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.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -64,17 +63,18 @@ fun ProviderParamFields( ) } else -> - OutlinedTextField( + LabelledField( + label = spec.label + restartSuffix(spec, warnAboutRestart), value = values[spec.key].orEmpty(), onValueChange = set, - label = { Text(spec.label + restartSuffix(spec, warnAboutRestart)) }, - placeholder = { Text(spec.unset) }, - singleLine = true, + hint = spec.unset, + // Prose is written rather than filled in, so it gets the room to be read + // back -- see `ParamKind::Prose`. + lines = if (spec.kind == "prose") 4 else 1, keyboardOptions = KeyboardOptions(keyboardType = keyboardFor(spec.kind)), - modifier = Modifier.fillMaxWidth(), ) } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(12.dp)) } if (warnAboutRestart && specs.any { it.restart }) { Text( 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 14a2372..5fe972b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -57,7 +56,12 @@ fun ProviderScreen( settings: ServerSettings, machineId: String, provider: String, - onBack: () -> Unit, + /** + * 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.Loading) } @@ -111,8 +115,13 @@ fun ProviderScreen( // 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)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - TextButton(onClick = onBack) { Text("Back") } + onBack?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + TextButton(onClick = it) { Text("Back") } + } } when (val current = state) { is LoadState.Loading -> CircularProgressIndicator() @@ -331,14 +340,12 @@ private fun ServerCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(8.dp)) - OutlinedTextField( + LabelledField( + label = "Models loaded at once", value = typed, onValueChange = { typed = it.filter(Char::isDigit) }, - label = { Text("Models loaded at once") }, - placeholder = { Text("one -- a second model replaces the first") }, - singleLine = true, + hint = "one -- a second model replaces the first", keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), ) Row(verticalAlignment = Alignment.CenterVertically) { // Shown whether or not it is running, and disabled when there is nothing to stop: 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 178eac9..b2d4900 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -2467,9 +2467,11 @@ fun SessionScreen( LaunchedEffect(summary.id, epoch) { cachedBytes = withContext(Dispatchers.IO) { source.cache.bytes() } } - SessionSettingsDialog( + SessionSettingsScreen( settings = settings, sessionId = summary.id, + machineId = summary.machine, + provider = summary.provider, title = title, effort = effort.takeIf { summary.takesEffort }, takesEffort = summary.takesEffort, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt similarity index 85% rename from app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt rename to app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt index 39aabe3..239feb8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsScreen.kt @@ -1,26 +1,31 @@ package com.example.aiapp +import androidx.activity.compose.BackHandler 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.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow 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 @@ -38,12 +43,20 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * What can be changed about one session, as opposed to about this app. + * What can be changed about one session, and about the provider serving it. * - * Over the session rather than a step down from it: everything here is about the conversation - * behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a - * screen of its own until 2026-08-30, which put a page transition and a back stack around two - * controls and hid the thing they act on. + * A screen rather than a dialog, again, and for the reason the dialog was chosen in the first place + * turned around: it has outgrown one. A Material dialog constrains its own height and scrolls + * inside itself, so a form of a dozen settings is read through a letterbox that also covers the + * conversation it is about -- and there is nowhere in it to put a second tab. Drawn over the + * session rather than as a `Screen` of its own, so the session under it stays composed and its + * stream keeps flowing; the back gesture closes it. + * + * **Two tabs, and the second is not a copy.** It is [ProviderScreen] -- the same composable the + * machines tab opens, for this session's machine and provider. A session's settings and its + * provider's are different things with different owners (one rides on a request, one decides how a + * model is loaded for everybody), and this is the second way in rather than a second version of + * them. * * The model and the permission mode are on the session's own bar as well, because those are changed * *while* reading a turn -- "not this model, try that one". They are here too because that bar is @@ -55,9 +68,12 @@ import kotlinx.coroutines.withContext * and Reload do, because what those two take away is not visible from here. */ @Composable -fun SessionSettingsDialog( +fun SessionSettingsScreen( settings: ServerSettings, sessionId: String, + /** Which machine and provider the second tab is about. */ + machineId: String, + provider: String, /** * What the session is called now, as the screen behind this knows it -- see the rename below. */ @@ -278,24 +294,61 @@ fun SessionSettingsDialog( } } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Session settings") }, - text = { - // Scrollable, because this dialog grew past a screenful: a Material dialog constrains - // its own height and clips what does not fit, so the last control on the list is one - // large system font away from being unreachable with nothing on screen to say so. - Column(Modifier.verticalScroll(rememberScrollState())) { - OutlinedTextField( + // The platform's own way back out of a layer: without it, back falls through to whatever is + // under this and closes the session -- which reads as a crash to somebody who meant to return + // to what they were reading. + BackHandler(onBack = onDismiss) + var tab by remember(sessionId) { mutableIntStateOf(0) } + Surface(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + GlyphButton(BACK_GLYPH, "Back", onDismiss) + Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) + Text( + "Settings", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + // Disabled rather than absent while there is nothing to save: a button that comes + // and goes makes its own presence the signal, and its absence cannot say why. + TextButton(onClick = { save() }, enabled = changed && !saving) { + Text(if (saving) "Saving..." else "Save") + } + } + // The same two-tab shape the main screen uses for its three, so a reader who has + // learned one has learned the other. + TabRow(selectedTabIndex = tab) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Session") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(provider) }) + } + if (tab == 1) { + // The machines tab's own screen, with its back control left off: this one has a + // header of its own, and two ways out stacked above each other is a reader asking + // which of them goes where. + ProviderScreen( + settings = settings, + machineId = machineId, + provider = provider, + onBack = null, + ) + return@Column + } + Column( + Modifier.verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 12.dp, bottom = 16.dp) + ) { + LabelledField( + label = "Name", value = name, onValueChange = { name = it }, - label = { Text("Name") }, - singleLine = true, enabled = !saving, - modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), // The keyboard's own action does what the button does: a one-field form where // the return key does nothing is a form people press return at anyway. - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { save() }), ) Spacer(Modifier.height(8.dp)) @@ -351,16 +404,14 @@ fun SessionSettingsDialog( // makes its own presence the signal, and a visible one teaches what the switch will // do. Committed on the keyboard's Done rather than on every keystroke, so typing a // sentence is one request instead of one per letter. - OutlinedTextField( + LabelledField( + label = "Message to send", value = resumeMessage, onValueChange = { resumeMessage = it }, - label = { Text("Message to send") }, - // What an empty field means, in the field: the server's own word rather than a - // session poked with nothing to read. - placeholder = { Text(DEFAULT_RESUME_MESSAGE) }, - singleLine = true, + // What an empty field means, said above it: the server's own word rather than + // a session poked with nothing to read. + hint = DEFAULT_RESUME_MESSAGE, enabled = autoResume == true, - modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }), @@ -393,23 +444,25 @@ fun SessionSettingsDialog( ) } Spacer(Modifier.height(8.dp)) + // The button sits at the bottom of the row rather than centred on it: the field + // beside it is a label above a box, and a control centred against the pair lands + // beside the label rather than beside the thing it acts on. Row( - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxWidth(), ) { - OutlinedTextField( + LabelledField( + label = "Working directory", value = typedCwd, onValueChange = { typedCwd = it }, - label = { Text("Working directory") }, // 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. - placeholder = { Text("wherever the session was started") }, - singleLine = true, + hint = "wherever the session was started", enabled = cwd != null && !movingCwd, - modifier = Modifier.weight(1f), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { moveCwd() }), + modifier = Modifier.weight(1f), ) TextButton( onClick = { moveCwd() }, @@ -571,16 +624,8 @@ fun SessionSettingsDialog( TextButton(onClick = onCopyRenderReport) { Text("Copy") } } } - }, - // Disabled rather than absent while there is nothing to save: a button that comes and goes - // makes its own presence the signal, and its absence cannot say why. - confirmButton = { - TextButton(onClick = { save() }, enabled = changed && !saving) { - Text(if (saving) "Saving..." else "Save") - } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } }, - ) + } + } } /** diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt index b067e57..7b12b89 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -125,28 +124,14 @@ fun SettingsScreen( } Spacer(Modifier.height(16.dp)) - OutlinedTextField( - value = host, - onValueChange = { host = it }, - label = { Text("Host") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + LabelledField(label = "Host", value = host, onValueChange = { host = it }) Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = port, - onValueChange = { port = it }, - label = { Text("Port") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + LabelledField(label = "Port", value = port, onValueChange = { port = it }) Spacer(Modifier.height(8.dp)) - OutlinedTextField( + LabelledField( + label = if (existing != null) "Token (unchanged if left blank)" else "Token", value = token, onValueChange = { token = it }, - label = { Text(if (existing != null) "Token (unchanged if left blank)" else "Token") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(24.dp)) 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 868ff05..d285ada 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -211,13 +210,7 @@ fun SpawnScreen( Spacer(Modifier.height(16.dp)) - OutlinedTextField( - value = title, - onValueChange = { title = it }, - label = { Text("Title") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + LabelledField(label = "Title", value = title, onValueChange = { title = it }) if (offersModels) { when { @@ -272,12 +265,11 @@ fun SpawnScreen( if (isCodingCli) { // Free text as well as the chips above: the catalog is a shortcut, and a CLI will // take a name it did not list. - OutlinedTextField( + LabelledField( + label = "Model", value = model, onValueChange = { model = it }, - label = { Text("Model (blank = the CLI's default)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), + hint = "the CLI's default", ) Spacer(Modifier.height(16.dp)) } @@ -294,13 +286,11 @@ fun SpawnScreen( // Every session whose tools act on files needs one, which is both kinds that have // tools -- a llama session's built-in tools run in it exactly as a CLI's do. if (takesCwd) { - OutlinedTextField( + LabelledField( + label = "Working directory", value = cwd, onValueChange = { cwd = it }, - label = { Text("Working directory") }, - placeholder = { Text("/home/…") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), + hint = "wherever the session's process starts", ) Spacer(Modifier.height(16.dp)) } diff --git a/server/src/config.rs b/server/src/config.rs index 467c1c7..b5dc850 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -427,6 +427,12 @@ pub enum ParamKind { Integer, Decimal, Text, + /// Text that is a paragraph rather than a value: several lines of it, and + /// the reader is writing rather than filling in. Its own kind because the + /// control genuinely differs -- a system prompt in a one-line box shows + /// six words of itself -- and because that is a fact about the setting + /// rather than a styling choice for the phone to guess at. + Prose, /// A fixed set. **The first option is what leaving it unset means**, and /// choosing it clears the setting rather than storing a value -- so the /// default is a state the picker can return to, and the stored config @@ -473,6 +479,19 @@ const LLAMA_PARAMS: &[ParamSpec] = &[ }, restart: false, }, + ParamSpec { + key: "systemPrompt", + label: "System prompt", + // In front of the conversation on every request rather than recorded + // in it: it is a setting, so changing it takes effect on the next + // message rather than leaving a transcript that says otherwise. The + // model's own template still supplies whatever it supplies; this is + // added to that, which is why leaving it blank is not "no system + // prompt" but "nothing of ours". + unset: "nothing beyond what the model's own template says", + kind: ParamKind::Prose, + restart: false, + }, ParamSpec { key: "temperature", label: "Temperature", diff --git a/server/src/session/llama/mod.rs b/server/src/session/llama/mod.rs index be0b688..2515eab 100644 --- a/server/src/session/llama/mod.rs +++ b/server/src/session/llama/mod.rs @@ -156,6 +156,27 @@ const THINKING_OFF: &str = "off"; /// window: 2,181 tokens against 698 with none, measured 2026-09-19. const TOOLS: &str = "tools"; +/// The session's own system prompt, in [`params`](crate::config::LLAMA_PARAMS). +/// +/// In front of the conversation on every request rather than recorded as the +/// first thing in it: it is a setting, and a setting that had been written +/// into the transcript would go on saying whatever it said when the session +/// was young. +const SYSTEM_PROMPT: &str = "systemPrompt"; + +/// The session's system prompt as a message, or `None` where it has none. +/// +/// Trimmed, so a field somebody emptied is the same as one never filled in -- +/// a prompt of one newline is not a prompt, and it would cost a turn's tokens +/// to say nothing. +fn system_message(params: &std::collections::BTreeMap) -> Option { + params + .get(SYSTEM_PROMPT) + .map(|prompt| prompt.trim()) + .filter(|prompt| !prompt.is_empty()) + .map(str::to_string) +} + /// How many times one message may go round the call-a-tool loop. /// /// A bound rather than a budget: a small model that has decided to read the @@ -463,6 +484,9 @@ struct Shared { /// `None` for the model's own default. Live like the sampling settings, /// and for the same reason -- it rides on the next request. thinking: Mutex>, + /// What this session tells the model it is, in front of every request. + /// Live for the same reason again -- see [`SYSTEM_PROMPT`]. + system: Mutex>, /// What the loaded model's chat template actually takes, asked of the /// server that loaded it (see [`thinking_options`]). /// @@ -560,6 +584,7 @@ impl LlamaDriver { tools_wanted: Mutex::new(Chosen::from(meta.params.get(TOOLS).map(String::as_str))), watching: AtomicBool::new(false), thinking: Mutex::new(chosen_thinking(&meta.params)), + system: Mutex::new(system_message(&meta.params)), thinking_options: Mutex::new(None), vision: Mutex::new(None), }), @@ -1683,6 +1708,7 @@ impl Driver for LlamaDriver { *self.shared.thinking.lock().unwrap() = chosen_thinking(params); *self.shared.tools_wanted.lock().unwrap() = Chosen::from(params.get(TOOLS).map(String::as_str)); + *self.shared.system.lock().unwrap() = system_message(params); self.shared.note_unusable_thinking(); } @@ -2326,11 +2352,23 @@ fn generate( shared: &Shared, cancel: &Cancel, ) -> Result<(Vec, Reply)> { + // The session's system prompt goes in front of the conversation rather + // than into it -- see `SYSTEM_PROMPT`. Borrowed into the request rather + // than pushed onto `messages`, which is handed back for the next round and + // would otherwise collect one per tool call. + let system = shared + .system + .lock() + .unwrap() + .as_deref() + .map(|prompt| Message::new("system", prompt)); + let asked: Vec<&Message> = system.iter().chain(messages.iter()).collect(); + let mut body = json!({ // Which model, because one `llama-server` is serving every model this // machine has loaded and this is how a request says which it means. "model": serves.model, - "messages": messages, + "messages": asked, "stream": true, "stream_options": {"include_usage": true}, // What it has got through of the prompt, which is the wait