Providers and hosts: what runs, and where, as independent choices
A session now names a provider (what: driver kind, command, models) and optionally a host (where: an ssh target). Keeping them independent is what the real setup needs -- the backend runs where the phone can reach it, which isn't where the CLI is installed -- and it means any provider can be sent to any host rather than a machine being baked into one. The first provider is claude-cli, named for the CLI rather than bare "claude", which would suggest the credit-billed API. A fresh config is seeded with it so a new install has something to spawn and a worked example to edit; echo stays a built-in provider needing no config. ssh.rs builds the child process either way: locally, or `ssh -T` with BatchMode and keepalives, every argument single-quoted for the remote shell (a working directory that tries to close the quote and start a command is covered by a test), and `exec` so dropping the connection takes the CLI down instead of orphaning it. App: the spawn screen reads /providers and /hosts instead of hardcoded lists, so config changes need no rebuild. Chip rows are FlowRow, fixing the reported bug where a row of models that didn't fit wrapped *inside* each chip -- one letter of "haiku" per line -- rather than onto a second line. Verified: 29 tests, clippy clean; the same claude-cli provider run once locally and once over ssh, with the remote one visibly in a different environment; an unknown host name refused with the configured list; and the spawn screen on the emulator showing server-driven providers, hosts, and models that wrap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
91bbc73ae5
commit
fff1fb49e8
12 files changed
+833
-170
No files matched your search
@@ -2,6 +2,8 @@ package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -11,12 +13,14 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -29,19 +33,21 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// The session kinds this build can spawn; phase 4 adds "pi". A new kind is
|
||||
// another entry here plus its fields below -- never a parallel screen.
|
||||
private val KINDS = listOf("claude", "echo")
|
||||
|
||||
// Claude Code 2.x permission modes. "manual" asks for everything (each ask
|
||||
// arrives on the phone as a question card); the others are the CLI's own
|
||||
// escalating levels of autonomy.
|
||||
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
||||
|
||||
// Model shortcuts the CLI accepts; free text is also fine (full model ids).
|
||||
private val CLAUDE_MODELS = listOf("default", "fable", "opus", "sonnet", "haiku")
|
||||
/** Runs on the backend machine itself -- the "no host" case. */
|
||||
private const val LOCAL_HOST_LABEL = "backend"
|
||||
|
||||
/** The spawn screen: kind, per-kind fields, go. */
|
||||
/**
|
||||
* The spawn screen: what to run, where to run it, and the per-kind fields.
|
||||
*
|
||||
* Providers and hosts both come from the server, so adding either to its
|
||||
* config.json shows up here with no app rebuild -- and because they are
|
||||
* independent, any provider can be sent to any host.
|
||||
*/
|
||||
@Composable
|
||||
fun SpawnScreen(
|
||||
settings: ServerSettings,
|
||||
@@ -49,14 +55,39 @@ fun SpawnScreen(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var kind by remember { mutableStateOf(KINDS.first()) }
|
||||
var providers by remember { mutableStateOf<List<Provider>>(emptyList()) }
|
||||
var hosts by remember { mutableStateOf<List<RemoteHost>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
|
||||
var provider by remember { mutableStateOf<Provider?>(null) }
|
||||
var host by remember { mutableStateOf<String?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var model by remember { mutableStateOf("default") }
|
||||
var model by remember { mutableStateOf("") }
|
||||
var cwd by remember { mutableStateOf("") }
|
||||
var permissionMode by remember { mutableStateOf("manual") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
val loaded = withContext(Dispatchers.IO) {
|
||||
fetchProviders(settings) to fetchHosts(settings)
|
||||
}
|
||||
providers = loaded.first
|
||||
hosts = loaded.second
|
||||
provider = providers.firstOrNull()
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
val current = provider
|
||||
// Only the Claude CLI has models, a working directory, and permission
|
||||
// modes; keying the extra fields on the kind rather than the provider
|
||||
// name keeps a second Claude provider from needing anything here.
|
||||
val isClaude = current?.kind == "claude-cli"
|
||||
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
@@ -68,13 +99,32 @@ fun SpawnScreen(
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text("Kind", style = MaterialTheme.typography.labelLarge)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
KINDS.forEach { candidate ->
|
||||
FilterChip(
|
||||
selected = kind == candidate,
|
||||
onClick = { kind = candidate },
|
||||
label = { Text(candidate) },
|
||||
if (loading) {
|
||||
CircularProgressIndicator()
|
||||
return@Column
|
||||
}
|
||||
|
||||
ChipGroup(
|
||||
label = "Provider",
|
||||
options = providers.map { it.name },
|
||||
selected = current?.name,
|
||||
onSelect = { name -> provider = providers.first { it.name == name } },
|
||||
)
|
||||
|
||||
// Always offered, whatever the provider: where a session runs is
|
||||
// independent of what runs it.
|
||||
ChipGroup(
|
||||
label = "Run on",
|
||||
options = listOf(LOCAL_HOST_LABEL) + hosts.map { it.name },
|
||||
selected = host ?: LOCAL_HOST_LABEL,
|
||||
onSelect = { name -> host = name.takeIf { it != LOCAL_HOST_LABEL } },
|
||||
)
|
||||
host?.let { chosen ->
|
||||
hosts.firstOrNull { it.name == chosen }?.let {
|
||||
Text(
|
||||
it.address,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -88,18 +138,24 @@ fun SpawnScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (kind == "claude") {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Model", style = MaterialTheme.typography.labelLarge)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
CLAUDE_MODELS.forEach { candidate ->
|
||||
FilterChip(
|
||||
selected = model == candidate,
|
||||
onClick = { model = candidate },
|
||||
label = { Text(candidate) },
|
||||
)
|
||||
}
|
||||
if (isClaude) {
|
||||
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 },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = model,
|
||||
onValueChange = { model = it },
|
||||
label = { Text("Model (blank = the CLI's default)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
@@ -112,20 +168,12 @@ fun SpawnScreen(
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text("Permissions", style = MaterialTheme.typography.labelLarge)
|
||||
// Two rows rather than horizontal scroll: all the choices stay
|
||||
// visible, and bypassPermissions shouldn't be pickable blind.
|
||||
for (chunk in PERMISSION_MODES.chunked(3)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
chunk.forEach { candidate ->
|
||||
FilterChip(
|
||||
selected = permissionMode == candidate,
|
||||
onClick = { permissionMode = candidate },
|
||||
label = { Text(candidate) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ChipGroup(
|
||||
label = "Permissions",
|
||||
options = PERMISSION_MODES,
|
||||
selected = permissionMode,
|
||||
onSelect = { permissionMode = it },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
@@ -136,17 +184,19 @@ fun SpawnScreen(
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val chosen = current ?: return@Button
|
||||
busy = true
|
||||
scope.launch {
|
||||
try {
|
||||
val spawned = withContext(Dispatchers.IO) {
|
||||
spawnSession(
|
||||
settings,
|
||||
kind,
|
||||
title.trim(),
|
||||
model = model.takeIf { kind == "claude" && it != "default" },
|
||||
cwd = cwd.takeIf { kind == "claude" },
|
||||
permissionMode = permissionMode.takeIf { kind == "claude" },
|
||||
provider = chosen.name,
|
||||
title = title.trim(),
|
||||
host = host,
|
||||
model = model.trim().takeIf { isClaude },
|
||||
cwd = cwd.trim().takeIf { isClaude },
|
||||
permissionMode = permissionMode.takeIf { isClaude },
|
||||
)
|
||||
}
|
||||
onSpawned(spawned)
|
||||
@@ -156,7 +206,38 @@ fun SpawnScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !busy,
|
||||
enabled = !busy && current != null,
|
||||
) { Text(if (busy) "Spawning..." else "Spawn") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A labeled row of choices that wraps onto as many lines as it needs.
|
||||
*
|
||||
* FlowRow rather than Row: a plain Row gives every chip an equal share of
|
||||
* a single line, so once the options don't fit, the text inside each one
|
||||
* wraps to one character per line instead of the row wrapping.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ChipGroup(
|
||||
label: String,
|
||||
options: List<String>,
|
||||
selected: String?,
|
||||
onSelect: (String) -> Unit,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.labelLarge)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
options.forEach { option ->
|
||||
FilterChip(
|
||||
selected = selected == option,
|
||||
onClick = { onSelect(option) },
|
||||
label = { Text(option) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user