Make model and permission choices provider-specific
This commit is contained in:
1 parent
6a0202b1b5
commit
7ee88dfd9c
10 files changed
+269
-60
No files matched your search
@@ -330,7 +330,13 @@ fun deleteSubagents(settings: ServerSettings, sessionId: String, subagentIds: Li
|
||||
//
|
||||
// One list rather than two. A provider only exists on a machine that has it installed, so offering
|
||||
// machines and providers as independent choices would offer pairs that cannot work.
|
||||
data class Provider(val name: String, val kind: String, val models: List<String>)
|
||||
data class Provider(
|
||||
val name: String,
|
||||
val kind: String,
|
||||
val models: List<String>,
|
||||
val permissionModes: List<String>,
|
||||
val defaultPermissionMode: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* A machine, and what it can run. [address] is absent for the backend itself.
|
||||
@@ -345,13 +351,17 @@ data class Setup(
|
||||
val providers: List<Provider>,
|
||||
)
|
||||
|
||||
private fun parseProvider(provider: JSONObject) =
|
||||
Provider(
|
||||
private fun parseProvider(provider: JSONObject): Provider {
|
||||
val kind = provider.getString("kind")
|
||||
return Provider(
|
||||
name = provider.getString("name"),
|
||||
kind = provider.getString("kind"),
|
||||
kind = kind,
|
||||
// Omitted entirely when the provider offers none.
|
||||
models = provider.optJSONArray("models")?.strings().orEmpty(),
|
||||
permissionModes = provider.optJSONArray("permissionModes")?.strings().orEmpty(),
|
||||
defaultPermissionMode = provider.optString("defaultPermissionMode").ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSetup(setup: JSONObject) =
|
||||
Setup(
|
||||
@@ -1083,16 +1093,6 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String)
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common permission intents, in the order they give up asking. Each coding CLI maps these onto its
|
||||
* own flags: "manual" asks, "acceptEdits" and "auto" allow ordinary work, "bypassPermissions"
|
||||
* accepts everything, and "plan" makes no changes.
|
||||
*
|
||||
* One list for every screen that offers them -- spawn, import, and the session's own picker --
|
||||
* because three copies had already drifted: the import screen was missing "plan".
|
||||
*/
|
||||
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
||||
|
||||
/**
|
||||
* What a new session's thinking level is when nothing chose one, or null for the CLI's own.
|
||||
*
|
||||
@@ -1294,6 +1294,19 @@ fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel
|
||||
}
|
||||
}
|
||||
|
||||
/** The current model catalog for one CLI provider on the machine where it runs. */
|
||||
fun fetchProviderModels(
|
||||
settings: ServerSettings,
|
||||
setupId: String,
|
||||
provider: String,
|
||||
): List<String> =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${setupId.urlEncoded()}/providers/${provider.urlEncoded()}/models",
|
||||
) { connection ->
|
||||
JSONArray(connection.inputStream.bufferedReader().readText()).strings()
|
||||
}
|
||||
|
||||
fun fetchModels(settings: ServerSettings): Models =
|
||||
requestFromServer(settings, "/models") { connection ->
|
||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||
|
||||
@@ -100,9 +100,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
|
||||
// themselves, not a flag, so the dialog can say what it is about.
|
||||
var confirming by remember { mutableStateOf<List<Importable>?>(null) }
|
||||
// Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty
|
||||
// times.
|
||||
var permissionMode by remember { mutableStateOf("auto") }
|
||||
// Set from the selected Claude provider rather than repeated in the app.
|
||||
var permissionMode by remember { mutableStateOf("") }
|
||||
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
|
||||
// so a tap reading it needs no recomposition.
|
||||
val movedAt = remember { mutableMapOf<String, Long>() }
|
||||
@@ -208,6 +207,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
}
|
||||
|
||||
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
|
||||
LaunchedEffect(chosen?.id, provider?.name) {
|
||||
permissionMode = provider?.defaultPermissionMode.orEmpty()
|
||||
}
|
||||
|
||||
/** Continues [targets] in the background, leaving the screen where it is. */
|
||||
fun importAll(targets: List<Importable>) {
|
||||
@@ -375,7 +377,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
} else {
|
||||
ChipGroup(
|
||||
label = "Permissions",
|
||||
options = PERMISSION_MODES,
|
||||
options = provider?.permissionModes.orEmpty(),
|
||||
selected = permissionMode,
|
||||
onSelect = { permissionMode = it },
|
||||
)
|
||||
|
||||
@@ -305,6 +305,7 @@ fun SessionScreen(
|
||||
// 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()) }
|
||||
var offeredPermissionModes by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
// The resume cursor, written from the stream's IO thread.
|
||||
val lastSeq = remember { AtomicLong(0) }
|
||||
@@ -1017,22 +1018,28 @@ fun SessionScreen(
|
||||
|
||||
// Only for the model picker, which a subagent does not have.
|
||||
if (!isSubagent) {
|
||||
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.
|
||||
emptyList()
|
||||
LaunchedEffect(summary.setup, summary.provider) {
|
||||
val provider = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchSetups(settings)
|
||||
.firstOrNull { it.id == summary.setup }
|
||||
?.providers
|
||||
?.firstOrNull { it.name == summary.provider }
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
offeredPermissionModes = provider?.permissionModes.orEmpty()
|
||||
offeredModels =
|
||||
provider
|
||||
?.let {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchProviderModels(settings, summary.setup, summary.provider)
|
||||
}
|
||||
}
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1840,13 +1847,21 @@ fun SessionScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
PickerButton(
|
||||
current = permissionMode,
|
||||
options = PERMISSION_MODES,
|
||||
onPick = { chosen ->
|
||||
act { setSessionPermissionMode(settings, summary.id, chosen) }
|
||||
},
|
||||
)
|
||||
if (offeredPermissionModes.isNotEmpty()) {
|
||||
PickerButton(
|
||||
current = permissionMode,
|
||||
options = offeredPermissionModes,
|
||||
onPick = { chosen ->
|
||||
act {
|
||||
setSessionPermissionMode(
|
||||
settings,
|
||||
summary.id,
|
||||
chosen,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
// The same filled shape as the button beside it, not an outlined one: these
|
||||
// are two things you can do about the session, and weighting one as
|
||||
|
||||
@@ -57,10 +57,12 @@ fun SpawnScreen(
|
||||
var providerName by remember { mutableStateOf<String?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var model by remember { mutableStateOf("") }
|
||||
var providerModels by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var providerModelsLoading by remember { mutableStateOf(false) }
|
||||
var providerModelsError by remember { mutableStateOf<String?>(null) }
|
||||
var cwd by remember { mutableStateOf("") }
|
||||
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
|
||||
// answering "allow Bash?" dozens of times per task is what this app exists to avoid.
|
||||
var permissionMode by remember { mutableStateOf("auto") }
|
||||
// Set only after the selected provider reports its own default. An empty value is not sent.
|
||||
var permissionMode by remember { mutableStateOf("") }
|
||||
// Null until the server has been asked, and null again if it answers "no level chosen" -- the
|
||||
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer
|
||||
// arrives is one you can spawn at without having chosen it.
|
||||
@@ -70,10 +72,8 @@ fun SpawnScreen(
|
||||
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
|
||||
// form worth keeping, and that one leaves nothing to fill in.
|
||||
var spawnError by remember { mutableStateOf<String?>(null) }
|
||||
// The models on the *chosen machine*, for a llama provider to choose between. Kept separate
|
||||
// from the setups: a Claude session needs none, so failing to list them must not stop the
|
||||
// screen rendering. Refetched when the machine changes, because a model is a file on one
|
||||
// machine -- see [fetchSetupModels].
|
||||
// GGUFs on the chosen machine, for a llama provider to choose between. Kept separate from a
|
||||
// coding CLI's provider catalog and refetched when the machine changes.
|
||||
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
|
||||
var modelKey by remember { mutableStateOf<String?>(null) }
|
||||
var contextSize by remember { mutableStateOf("") }
|
||||
@@ -145,6 +145,28 @@ fun SpawnScreen(
|
||||
val isCodingCli = isClaude || isCodex
|
||||
val isLlama = current?.kind == "llama_cpp"
|
||||
|
||||
LaunchedEffect(setup?.id, current?.name) {
|
||||
model = ""
|
||||
providerModels = emptyList()
|
||||
providerModelsError = null
|
||||
permissionMode = current?.defaultPermissionMode.orEmpty()
|
||||
if (isCodingCli) {
|
||||
providerModelsLoading = true
|
||||
try {
|
||||
providerModels =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchProviderModels(settings, setup.id, current.name)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
providerModelsError = e.message
|
||||
} finally {
|
||||
providerModelsLoading = false
|
||||
}
|
||||
} else {
|
||||
providerModelsLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// The machine first, because it decides what can be run at all.
|
||||
ChipGroup(
|
||||
label = "Setup",
|
||||
@@ -240,14 +262,34 @@ fun SpawnScreen(
|
||||
}
|
||||
|
||||
if (isCodingCli) {
|
||||
if (current.models.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
ChipGroup(
|
||||
label = "Model",
|
||||
options = current.models,
|
||||
selected = model.ifEmpty { null },
|
||||
onSelect = { chosen -> model = if (model == chosen) "" else chosen },
|
||||
)
|
||||
when {
|
||||
providerModelsLoading ->
|
||||
Text(
|
||||
"Loading model choices…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
providerModelsError != null ->
|
||||
Text(
|
||||
"Model choices unavailable: $providerModelsError",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
providerModels.isEmpty() ->
|
||||
Text(
|
||||
"This setup reported no selectable models.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
ChipGroup(
|
||||
label = "Model",
|
||||
options = providerModels,
|
||||
selected = model.ifEmpty { null },
|
||||
onSelect = { chosen -> model = if (model == chosen) "" else chosen },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
@@ -271,7 +313,7 @@ fun SpawnScreen(
|
||||
|
||||
ChipGroup(
|
||||
label = "Permissions",
|
||||
options = PERMISSION_MODES,
|
||||
options = current.permissionModes,
|
||||
selected = permissionMode,
|
||||
onSelect = { permissionMode = it },
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user