Stop choosing a model, and keep an imported session up to date

**Why the model became fable.** `spawn_session` fell back to the
provider's first listed model when none was given. That list is a shortcut
for the spawn screen, written in whatever order somebody typed it, and its
first entry is `fable` -- so every session spawned without a model, which
is every import, silently became a fable session. It looked like a default
and was an artefact of list order. Absent now means absent: no `--model`
flag, and the CLI uses whatever the person configured for themselves.

**Model and permission mode are now visible and changeable** from the
session, as buttons that read as their current value rather than labels
beside one. The mode was spawn-only; the CLI turns out to accept
`control_request{subtype:set_permission_mode}` and echo the mode back,
probed against 2.1.237 the same way the rest of the protocol record was.
Both default to `auto` -- on a phone every ask is a round trip to a
question card, which is how "allow Bash?" became the most-answered
question in the app.

The mode is reported by the API so the picker shows what the session is
actually set to, and it is kept in the live session beside the model for
the reason the model already was: `meta` is the shape a session was
*launched* with, so reporting from it shows the value a change replaced.

**And an imported session keeps itself level with its source file**, so
work done at a terminal arrives without a button. `--resume` appends to
the same transcript rather than forking -- measured, not assumed -- so the
only hard question is which new lines came from here.

Answered by counting the events this session has recorded. Status is the
obvious signal and is wrong, which cost a round trip to find: a turn that
starts and finishes between two polls reads as idle at both, so its output
is replayed on top of itself. It showed up on screen as `donedone`, and
only because the reply was one word -- with a longer answer it would have
looked like the model repeating itself.

Verified against both halves: text appended to the source file the way a
terminal writes it appears within one interval, and a message sent through
the app appears exactly once, before and after a turn.
This commit is contained in:
iris committed 2026-08-28 22:44:41 -04:00
1 parent c3e7f07a5d
commit a9ea84c96c
9 files changed
+381 -3

No files matched your search

@@ -24,6 +24,8 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -147,6 +149,13 @@ fun SessionScreen(
var expandedTools by remember { mutableStateOf(setOf<String>()) }
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
// What this session is set to now, seeded from the row that opened it and
// then owned here, because changing either is something this screen does.
var model by remember { mutableStateOf(summary.model) }
var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") }
// The models this provider actually offers, asked of the server rather
// than listed here: a hardcoded list is a claim about a machine.
var offeredModels by remember { mutableStateOf<List<String>>(emptyList()) }
val context = LocalContext.current
// The resume cursor, written from the stream's IO thread.
val lastSeq = remember { AtomicLong(0) }
@@ -211,6 +220,24 @@ fun SessionScreen(
}
}
LaunchedEffect(summary.setupName, summary.provider) {
offeredModels =
try {
withContext(Dispatchers.IO) {
fetchSetups(settings)
.firstOrNull { it.name == summary.setupName }
?.providers
?.firstOrNull { it.name == summary.provider }
?.models
.orEmpty()
}
} catch (_: Exception) {
// Not worth reporting: the picker simply has nothing to
// offer, which is visible, and the session is unaffected.
emptyList()
}
}
fun act(action: () -> Unit) {
scope.launch {
try {
@@ -371,6 +398,28 @@ fun SessionScreen(
) {
Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}")
}
// Beside the field they govern, and showing their current
// value rather than a label: what this session is set to is
// the thing worth reading at a glance, and the control for
// changing it is the same object.
if (offeredModels.isNotEmpty()) {
PickerButton(
current = model ?: "default",
options = offeredModels,
onPick = { chosen ->
model = chosen
act { setSessionModel(settings, summary.id, chosen) }
},
)
}
PickerButton(
current = permissionMode,
options = PERMISSION_MODES,
onPick = { chosen ->
permissionMode = chosen
act { setSessionPermissionMode(settings, summary.id, chosen) }
},
)
Spacer(Modifier.weight(1f))
if (status == "running" || status == "compacting") {
OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) {
@@ -494,3 +543,33 @@ private fun QuestionRow(question: TranscriptItem.QuestionCard, onAnswer: (String
}
}
}
/** The modes the CLI accepts, in the order they give up asking. */
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
/**
* A control that reads as its own value.
*
* 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.
*/
@Composable
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
var open by remember { mutableStateOf(false) }
Box {
TextButton(onClick = { open = true }) {
Text(current, style = MaterialTheme.typography.bodySmall)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
open = false
if (option != current) onPick(option)
},
)
}
}
}
}