ai-app: a phone interface to Claude Code and llama.cpp sessions
A Rust backend that owns the sessions and an Android app that reads them. The server spawns and adopts CLI processes, normalises everything they emit into one event model, keeps the transcript, and serves it over pinned TLS on a WireGuard interface; the phone streams that, replies, sends images, and imports conversations the machine already has. `AGENTS.md` is the working guide -- what runs where, what has been measured, and the faults that were expensive to find. `PLAN.md` is the design record. History before this point was squashed away. It was a personal project's running commentary and carried a name and a couple of machine paths that have no business in a public repository; the tree is what mattered and the tree is here.
This commit is contained in:
commit
b172c464ea
100 files changed
+31795
No files matched your search
@@ -0,0 +1,234 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
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.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Every question one tool call is waiting on.
|
||||
*
|
||||
* All of it comes from the question events themselves -- what each option means, what picking it
|
||||
* would produce, whether several may be picked at once. None of it is read out of the call's own
|
||||
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
|
||||
* app, where no other provider can reach it and where it drifts the first time the schema moves.
|
||||
*/
|
||||
@Composable
|
||||
fun AskUserQuestionBody(
|
||||
asks: List<TranscriptItem.QuestionCard>,
|
||||
onAnswer: (questionId: String, answers: List<String>) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
asks.forEach { ask ->
|
||||
Spacer(Modifier.height(12.dp))
|
||||
AskedQuestion(ask) { answers -> onAnswer(ask.id, answers) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One question: what is being asked, what can be answered, and what was.
|
||||
*
|
||||
* The same body wherever a question appears -- on the call that asked it, or as a card of its own
|
||||
* when nothing did. A question is the same thing either way, and two renderings of it would be two
|
||||
* places for an answer to go missing.
|
||||
*/
|
||||
@Composable
|
||||
fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) -> Unit) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
ask.header?.let { header ->
|
||||
// Its own line rather than beside the question, because it is a label *for* the
|
||||
// question and the question is the thing to read.
|
||||
Text(
|
||||
header.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(ask.prompt, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (ask.answers.isNotEmpty()) {
|
||||
// Joined for reading only: they arrived as a list and stay one everywhere else.
|
||||
Text(
|
||||
"Answered: ${ask.answers.joinToString(", ")}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
if (ask.multiSelect) {
|
||||
MultipleChoice(ask.options, onAnswer)
|
||||
} else if (ask.options.all { it.description == null && it.preview == null }) {
|
||||
// Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words
|
||||
// do not need a card each.
|
||||
AnswerOptions(ask.options, onAnswer)
|
||||
} else {
|
||||
ask.options.forEach { option ->
|
||||
OptionCard(option, selected = false) { onAnswer(listOf(option.label)) }
|
||||
}
|
||||
}
|
||||
OtherAnswer(onAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options that can be chosen together, with one button to send them.
|
||||
*
|
||||
* The answer goes back as the list it is. What a provider makes of several answers is decided where
|
||||
* that provider is spoken to -- Claude Code's answers map holds a string, so they are joined there
|
||||
* -- and nothing on this side has to know that.
|
||||
*/
|
||||
@Composable
|
||||
private fun MultipleChoice(options: List<QuestionOption>, onAnswer: (List<String>) -> Unit) {
|
||||
var chosen by remember { mutableStateOf(setOf<String>()) }
|
||||
options.forEach { option ->
|
||||
OptionCard(option, selected = option.label in chosen) {
|
||||
chosen = if (option.label in chosen) chosen - option.label else chosen + option.label
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
// In the order they were offered rather than the order they were tapped: the reader is
|
||||
// answering a list, and it should read back as that list.
|
||||
onClick = { onAnswer(options.map { it.label }.filter { it in chosen }) },
|
||||
enabled = chosen.isNotEmpty(),
|
||||
) {
|
||||
Text(if (chosen.size <= 1) "Send answer" else "Send ${chosen.size} answers")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One option: what it is called, what it means, and what it would produce.
|
||||
*
|
||||
* Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was
|
||||
* indistinguishable from the card behind it -- three paragraphs of text where three things to press
|
||||
* should have been, which is the failure a tint step routinely produces on a dark theme. A border
|
||||
* is one cue and it is unambiguous.
|
||||
*/
|
||||
@Composable
|
||||
private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) {
|
||||
OutlinedCard(
|
||||
onClick = onPick,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
colors =
|
||||
CardDefaults.outlinedCardColors(
|
||||
containerColor =
|
||||
if (selected) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surface
|
||||
),
|
||||
// Picked shows in the border as well as the fill, because the fill alone is a colour
|
||||
// difference somebody has to have seen the unpicked version to notice.
|
||||
border =
|
||||
BorderStroke(
|
||||
if (selected) 2.dp else 1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(option.label, style = MaterialTheme.typography.titleSmall)
|
||||
option.description?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
option.preview?.let { Preview(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An option's worked example, shown as written.
|
||||
*
|
||||
* On its own surface, because it is a different kind of thing from the sentence above it: that
|
||||
* describes the option, this is a sample of what the option produces, and monospace alone reads as
|
||||
* a description that happens to be in code font.
|
||||
*/
|
||||
@Composable
|
||||
private fun Preview(preview: String) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
preview,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines
|
||||
// of the thing being previewed.
|
||||
softWrap = false,
|
||||
modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The choice the asker always leaves open, and the app has to as well.
|
||||
*
|
||||
* Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words
|
||||
* rather than pick. Leaving it out narrows a question that was never that narrow, and the reader
|
||||
* cannot tell that it was ever open.
|
||||
*/
|
||||
@Composable
|
||||
private fun OtherAnswer(onAnswer: (List<String>) -> Unit) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text("Other") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { onAnswer(listOf(text.trim())) }, enabled = text.isNotBlank()) {
|
||||
Text("Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare options, wrapped rather than in a row.
|
||||
*
|
||||
* A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question
|
||||
* with four options showed the first one or two and dropped the rest off the side of the screen.
|
||||
* That does not read as a bug: it reads as those having been the only choices, which is the worst
|
||||
* way for a list of choices to be wrong.
|
||||
*/
|
||||
@Composable
|
||||
fun AnswerOptions(options: List<QuestionOption>, onAnswer: (List<String>) -> Unit) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
options.forEach { option ->
|
||||
OutlinedButton(onClick = { onAnswer(listOf(option.label)) }) { Text(option.label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user