Phase 2 core: ClaudeDriver over stream-json, permissions and questions on the phone
The second driver behind the same trait: claude -p with stream-json both ways, the hidden --permission-prompt-tool stdio flag (without which no permission ever reaches a client), text deltas streamed from raw API events, tool_use/tool_result mapped to tool events, and can_use_tool control requests surfaced as Question events -- plain permissions as Allow/Deny, AskUserQuestion as one Question per sub-question with the chosen labels sent back in updatedInput.answers keyed by question text (wire shapes pinned by live probes against CLI 2.1.237, recorded in the module doc). The CLI session id is persisted per session dir, so a backend restart respawns with --resume and loses nothing. set_model rides the control protocol and persists through the manager; the spawn screen grows model/cwd/permission-mode fields. Also: the dev CA now carries proper keyUsage/basicConstraints extensions (strict verifiers reject it otherwise) -- regenerated and re-pinned before any real phone has installed the app. Verified: 20 unit tests + clippy clean; scripted end-to-end over the HTTP API (AskUserQuestion round trip, Bash permission allow, streaming, restart with --resume remembering earlier work, delete); and on the emulator, a live haiku session asking Tea-or-coffee and acknowledging the tapped answer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
f3cebeea78
commit
95d389e2b8
10 files changed
+882
-37
No files matched your search
@@ -1,5 +1,6 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -7,6 +8,8 @@ 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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -26,13 +29,19 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// The session kinds this build can spawn. Phase 2 adds "claude" (with
|
||||
// model / working directory / permission-mode fields), phase 4 "pi" --
|
||||
// extending this list and the per-kind fields, not adding a parallel
|
||||
// screen.
|
||||
private val KINDS = listOf("echo")
|
||||
// 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")
|
||||
|
||||
/** The spawn screen: kind, title, go. */
|
||||
// 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")
|
||||
|
||||
/** The spawn screen: kind, per-kind fields, go. */
|
||||
@Composable
|
||||
fun SpawnScreen(
|
||||
settings: ServerSettings,
|
||||
@@ -42,10 +51,13 @@ fun SpawnScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
var kind by remember { mutableStateOf(KINDS.first()) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var model by remember { mutableStateOf("default") }
|
||||
var cwd by remember { mutableStateOf("") }
|
||||
var permissionMode by remember { mutableStateOf("manual") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"New session",
|
||||
@@ -57,14 +69,13 @@ fun SpawnScreen(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text("Kind", style = MaterialTheme.typography.labelLarge)
|
||||
Row {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
KINDS.forEach { candidate ->
|
||||
FilterChip(
|
||||
selected = kind == candidate,
|
||||
onClick = { kind = candidate },
|
||||
label = { Text(candidate) },
|
||||
)
|
||||
Spacer(Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -73,10 +84,49 @@ fun SpawnScreen(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
label = { Text("Title") },
|
||||
placeholder = { Text("Echo session") },
|
||||
singleLine = true,
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = cwd,
|
||||
onValueChange = { cwd = it },
|
||||
label = { Text("Working directory") },
|
||||
placeholder = { Text("/home/…") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
error?.let {
|
||||
@@ -90,7 +140,14 @@ fun SpawnScreen(
|
||||
scope.launch {
|
||||
try {
|
||||
val spawned = withContext(Dispatchers.IO) {
|
||||
spawnSession(settings, kind, title.trim())
|
||||
spawnSession(
|
||||
settings,
|
||||
kind,
|
||||
title.trim(),
|
||||
model = model.takeIf { kind == "claude" && it != "default" },
|
||||
cwd = cwd.takeIf { kind == "claude" },
|
||||
permissionMode = permissionMode.takeIf { kind == "claude" },
|
||||
)
|
||||
}
|
||||
onSpawned(spawned)
|
||||
} catch (e: ApiException) {
|
||||
|
||||
Reference in new issue
Block a user