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:
iris committed 2026-08-31 20:29:07 -04:00
commit b172c464ea
100 files changed
+31795

No files matched your search

@@ -0,0 +1,186 @@
package com.example.aiapp
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
/**
* A session wanting attention, said over the app rather than through Android's drawer.
*
* Two places can carry the same fact and only one of them is right at a time. A row in the shade is
* for somebody looking at something else: it makes a sound, it waits however long it has to, and
* acting on it means leaving whatever they were doing. Somebody with this app open needs none of
* that -- they are already here, and what a tap on the notification would have done is what a tap
* on this does. So while these are on screen the stream is delivered here instead, which is
* arranged by the collection below and nothing else; see `NotificationService.forTheScreen`.
*
* A banner can go three ways, and each is somebody deciding something different: tapped, which
* opens the session; pushed off either side; or left alone, in which case it goes by itself when
* the bar across its foot runs out.
*/
@Composable
fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) {
val queue = remember { mutableStateListOf<SessionAlert>() }
// What tells two notifications about one session apart, and what a replaced banner gets a new
// one of so its timer starts again rather than inheriting the remains of the last one's.
var arrivals by remember { mutableIntStateOf(0) }
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
try {
NotificationService.forTheScreen.collect { notification ->
arrivals++
val alert = SessionAlert(notification, arrivals)
// One banner per session, replacing that session's own -- the same rule the
// drawer follows, and for the same reason: a session that finished and then
// asked a question is one thing to know about, the question. It keeps its
// place in the queue rather than moving to the end, because the reader may
// already be reaching for it.
val already = queue.indexOfFirst {
it.notification.sessionId == notification.sessionId
}
if (already >= 0) queue[already] = alert else queue.add(alert)
}
} finally {
// Leaving the app hands the job back to the drawer, so nothing arriving while it
// is away is lost. What would be lost is the truth of what is already up: these
// say a session wants somebody *now*, and one still sitting here on a return
// several minutes later is a claim nobody checked. Frozen, too -- Compose stops
// the clock with the window, so the timer that was going to retire it has been
// standing still the whole time.
queue.clear()
}
}
}
// Oldest at the top, so a new one appears below the ones already being read instead of
// shoving them down the screen mid-reach.
Column(modifier.fillMaxWidth().padding(8.dp)) {
queue.forEach { alert ->
key(alert.arrival) {
AlertBanner(
alert = alert,
onOpen = {
queue.remove(alert)
onOpen(SessionOpenRequest(alert.notification.sessionId, alert.arrival))
},
onGone = { queue.remove(alert) },
)
}
}
}
}
/** One notification queued for the screen, with the arrival that tells it from its predecessor. */
private data class SessionAlert(val notification: SessionNotification, val arrival: Int)
/**
* One banner: what wants attention, and how long this has left to say so.
*
* The bar and the going away are one value rather than a bar beside a timer, because two of them
* would be two accounts of the same countdown and only one can be the one that fires. What is drawn
* is therefore the thing that decides, which is the only arrangement where a bar that has emptied
* cannot be sitting under a banner that is still there.
*/
@Composable
private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) {
val swipe = rememberSwipeToDismissBoxState()
val life = remember { Animatable(1f) }
LaunchedEffect(Unit) {
life.animateTo(0f, animationSpec = tween(ALERT_LIFE_MS, easing = LinearEasing))
onGone()
}
// Settled is "still where it started"; anything else is a push that carried far enough for the
// gesture to commit, which the platform decides rather than this screen.
LaunchedEffect(swipe.currentValue) {
if (swipe.currentValue != SwipeToDismissBoxValue.Settled) onGone()
}
SwipeToDismissBox(
state = swipe,
// Nothing behind it. Pushing one of these away means the same thing whichever way it went,
// so a coloured ground with an icon would be drawing a distinction that isn't there.
backgroundContent = {},
modifier = Modifier.padding(bottom = 8.dp),
) {
Card(
onClick = onOpen,
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
),
// Outlined, because the step it needs to make is not one this palette can make with a
// tint: the card under a banner on the session list is the same surface, so a banner
// relying on colour alone reads as one more row that happens to be in the way. The
// border is the one cue, and the elevation beside it is the platform's shadow rather
// than a second tint -- Material draws no tonal overlay over a container stated here.
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
) {
Column(Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 10.dp)) {
Text(
alert.notification.title,
style = MaterialTheme.typography.titleSmall,
// One line, cut at the tail: a session is identified by the start of its
// name, and a banner that grew with the name would move the one below it.
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
attentionLine(alert.notification.kind),
style = MaterialTheme.typography.labelLarge,
// The list's own colour for a session waiting on a person, so the banner and
// the row behind it are saying one thing rather than two.
color =
if (alert.notification.kind == "awaitingInput") awaitingColor
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LinearProgressIndicator(
progress = { life.value },
// Blue because it is reporting how much of something is left rather than passing
// judgement on it -- the reason `progressColor` exists. Stated beside the track,
// which is the card's own colour so that the spent part reads as empty rather
// than as a second bar.
color = progressColor,
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
drawStopIndicator = {},
gapSize = 0.dp,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
/**
* How long a banner stays if nobody touches it.
*
* Long enough to read a session name and a line, short enough that a stack of them clears itself
* while somebody is still on the screen that produced them. The bar makes the number visible, so
* this is a duration the reader can watch rather than one they have to learn.
*/
private const val ALERT_LIFE_MS = 6_000