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.
126 lines
5.4 KiB
Kotlin
126 lines
5.4 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.border
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.horizontalScroll
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.layout.ContentScale
|
|
import androidx.compose.ui.semantics.contentDescription
|
|
import androidx.compose.ui.semantics.semantics
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
|
|
/**
|
|
* What is about to be sent, directly above the box it will be sent from.
|
|
*
|
|
* The count on the "+" button was the whole of what said an image was attached, so the only way to
|
|
* find out *which* image was to send it. A control belongs with the thing it acts on, and what
|
|
* these are attached to is the message being typed -- which is why they sit here rather than
|
|
* anywhere else on the screen.
|
|
*
|
|
* Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is
|
|
* in it, so four attachments look like four of the same thing rather than four smaller ones.
|
|
*/
|
|
@Composable
|
|
fun PendingAttachments(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
refs: List<String>,
|
|
onRemove: (String) -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
if (refs.isEmpty()) return
|
|
Row(
|
|
modifier = modifier.horizontalScroll(rememberScrollState()).padding(bottom = 8.dp),
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
) {
|
|
refs.forEach { ref -> PendingThumbnail(settings, sessionId, ref) { onRemove(ref) } }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One attachment, square, tap to take it back off.
|
|
*
|
|
* Removal is here because there is nowhere else it could be: an image picked by mistake could
|
|
* otherwise only be dealt with by sending it. The whole thumbnail is the target rather than a
|
|
* corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip -- and
|
|
* the label is what says so, since nothing about the picture does.
|
|
*/
|
|
@Composable
|
|
private fun PendingThumbnail(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
ref: String,
|
|
onRemove: () -> Unit,
|
|
) {
|
|
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
|
val shape = RoundedCornerShape(8.dp)
|
|
Box(
|
|
Modifier.size(THUMBNAIL)
|
|
.clip(shape)
|
|
// An outline as well as a fill. Most of what gets attached here is a screenshot of a
|
|
// dark app, and cropped to a square its middle is often near-black -- against this
|
|
// background the tile then had no edge at all, and the only thing saying an image was
|
|
// attached was the cross drawn on top of nothing.
|
|
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
|
// Behind the picture as well as under a missing one, so the tile is a tile before
|
|
// anything has arrived to fill it.
|
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
.clickable(onClick = onRemove)
|
|
.semantics { contentDescription = "Attached image, tap to remove" },
|
|
contentAlignment = Alignment.Center,
|
|
) {
|
|
when (val image = bitmap) {
|
|
// The two are told apart for the same reason the transcript's images are: one of them
|
|
// is worth waiting for and the other never resolves.
|
|
null ->
|
|
Text(
|
|
if (failed) "!" else "…",
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
else ->
|
|
Image(
|
|
bitmap = image,
|
|
contentDescription = null,
|
|
contentScale = ContentScale.Crop,
|
|
modifier = Modifier.size(THUMBNAIL),
|
|
)
|
|
}
|
|
// The whole square removes it, and this only says so. A cross small enough to sit in
|
|
// the corner of a 64dp thumbnail is smaller than a fingertip, so making it the target
|
|
// would be a control drawn at a size nobody can hit.
|
|
//
|
|
// The disc is sized here and the mark centred inside it, rather than the glyph being
|
|
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box
|
|
// to the corner hung the visible mark over the edge and put its backing somewhere the
|
|
// eye reads as a second, misplaced square.
|
|
Box(
|
|
Modifier.align(Alignment.TopEnd)
|
|
.padding(2.dp)
|
|
.size(20.dp)
|
|
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f), CircleShape),
|
|
contentAlignment = Alignment.Center,
|
|
) {
|
|
Glyph(CLOSE_GLYPH, colour = MaterialTheme.colorScheme.onSurface, size = 12.sp)
|
|
}
|
|
}
|
|
}
|
|
|
|
private val THUMBNAIL = 64.dp
|