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,68 @@
package com.example.aiapp
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* The mark a compaction leaves in the transcript.
*
* A divider rather than something anybody said: everything above it is out of the session's context
* now, and that is a fact about the conversation, not a turn in it. It has no collapsed form -- it
* is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which
* a clear also uses, so the two marks cannot drift apart.
*
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it,
* which is the same thing the status line says while the compaction runs.
*/
@Composable
fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) {
TranscriptDivider(compactionSummary(item), commandColor, modifier)
}
/**
* What to say about a compaction: the two sizes, and nothing else.
*
* The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer
* to why the wait was worth it -- and they are all this says, because a divider is read in passing.
* When they were not reported this says only that a compaction happened, rather than filling in a
* plausible number or explaining at length what was missing.
*/
fun compactionSummary(item: TranscriptItem.CompactedNote): String {
val pre = item.preTokens
val post = item.postTokens
return if (pre != null && post != null) {
"Compacted • ${tokens(pre)}${tokens(post)} tok"
} else {
"Compacted"
}
}
/**
* A token count as a reader reads one.
*
* Shared with the status row rather than formatted at each: the divider and the row report the same
* quantity about the same moment, and one of them grouping its thousands while the other did not
* read as two different measurements.
*/
fun tokens(count: Long): String = "%,d".format(count)
/**
* What the working indicator says while a compaction is running.
*
* Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a
* compaction has begun and then says nothing until it has finished, so any bar, percentage or
* estimate here would be this screen's guess wearing a measurement's clothes. Knowing it has been
* going forty seconds is what a reader actually wants -- it is the difference between waiting and
* going to look at why.
*
* [seconds] is null when this device did not see the compaction start, which is what opening a
* session that is already compacting looks like. That case says only "compacting": no number is the
* honest answer, and a number counted from the moment the screen opened would be wrong in the
* direction that matters, since a compaction somebody is asking about is a long one.
*/
fun compactingLabel(seconds: Long?): String =
when {
seconds == null -> "compacting"
seconds < 60 -> "compacting ${seconds}s"
else -> "compacting ${seconds / 60}m ${seconds % 60}s"
}