Settings as a screen with two tabs, and fields that cost one line
The settings dialog had outgrown a dialog: it scrolled inside itself, covered the session it is about, and had nowhere to put a second tab. It is a screen now, drawn over the session like the file explorer so the session under it stays composed, with the back gesture to leave it. 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. Every text field in the app goes through LabelledField: the label is a line above the box rather than a thing floating inside it, the hint says what leaving it blank means, 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. The value's own text is unchanged -- the framing was what cost. A session also gets a system prompt, which for llama.cpp is one entry in the params table and no app change: 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. ParamKind::Prose is new because a paragraph in a one-line box shows six words of itself.
This commit is contained in:
1 parent
386c1c4def
commit
7278a58387
17 files changed
+364
-176
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() } },
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, " +
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<ProviderView>>(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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
+89
-44
@@ -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") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<String, String>) -> Option<String> {
|
||||
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<Option<String>>,
|
||||
/// What this session tells the model it is, in front of every request.
|
||||
/// Live for the same reason again -- see [`SYSTEM_PROMPT`].
|
||||
system: Mutex<Option<String>>,
|
||||
/// 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<Message>, 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
|
||||
|
||||
Reference in new issue
Block a user