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,96 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.model.State
|
||||
import com.mikepenz.markdown.model.parseMarkdown
|
||||
|
||||
/**
|
||||
* A message's top-level markdown blocks, cut where the parser says the blocks are.
|
||||
*
|
||||
* The point is the draw phase. A reply's display list holds every glyph of it, and it is
|
||||
* re-recorded whenever drawing is invalidated -- so one long message is as expensive to draw as a
|
||||
* hundred short ones, and skipping the rows around it cannot help while it is the one on screen.
|
||||
* Measured on a Pixel 9 Pro XL: 97% of rows correctly skipped, and the tallest row still being
|
||||
* drawn was 36,982px, about twenty-five screens in a single message. Cut into blocks, only the
|
||||
* screen or two actually being read is ever recorded.
|
||||
*
|
||||
* Cut at the parser's own boundaries rather than at blank lines, which is the whole reason this is
|
||||
* safe: a heading, a fenced code block, a table and a list are each one node whatever is inside
|
||||
* them, so a loose list does not become five one-item lists and a fence is never split down the
|
||||
* middle. Guessing at block boundaries with a line scanner gets all three of those wrong.
|
||||
*
|
||||
* It also bounds parsing, which was the other symptom: one message took **1.4 seconds** to parse as
|
||||
* a single unit, and a block is a paragraph.
|
||||
*/
|
||||
fun markdownBlocks(text: String): List<String> {
|
||||
// A reference definition sits at the foot of a message and is used by links above it. Parsed on
|
||||
// its own each block would lose the definition, and the link would draw as literal brackets --
|
||||
// so a message carrying one is kept whole. Rare enough to be worth giving up the split for.
|
||||
if (REFERENCE_DEFINITION.containsMatchIn(text)) return listOf(text)
|
||||
val parsed = parseMarkdown(text) as? State.Success ?: return listOf(text)
|
||||
val blocks =
|
||||
parsed.node.children
|
||||
.map { text.substring(it.startOffset, it.endOffset) }
|
||||
.filter { it.isNotBlank() }
|
||||
return if (blocks.size <= 1) listOf(text) else blocks
|
||||
}
|
||||
|
||||
/** `[label]: https://…` at the start of a line -- see [markdownBlocks]. */
|
||||
private val REFERENCE_DEFINITION = Regex("""^ {0,3}\[[^\]]+]:\s""", RegexOption.MULTILINE)
|
||||
|
||||
/**
|
||||
* A reply drawn a block at a time.
|
||||
*
|
||||
* Each block keeps its composition and its layout whichever way it is scrolled -- that is what
|
||||
* stops a message being rebuilt when somebody comes back to it. The heights come from the blocks
|
||||
* themselves as they are measured, so the running total is the same arrangement the list uses one
|
||||
* level up.
|
||||
*
|
||||
* [live] is the message currently arriving, and it is the only one that gets a layer per block. A
|
||||
* layer buys one thing here: when drawing is invalidated, only the block that changed is
|
||||
* re-recorded instead of the whole reply. That is worth a great deal while a reply is streaming,
|
||||
* because every delta invalidates the message and a finished one can be twenty-five screens tall.
|
||||
* It is worth nothing once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows
|
||||
* were re-recorded 65 times in fifty seconds of reading -- and it is not free: each layer is a
|
||||
* layout node and a display list held for the life of the row, and live node count is what the
|
||||
* per-frame cost of the transcript scales with.
|
||||
*/
|
||||
@Composable
|
||||
fun BlockedMarkdown(
|
||||
text: String,
|
||||
replies: ParsedReplies,
|
||||
modifier: Modifier = Modifier,
|
||||
live: Boolean = false,
|
||||
) {
|
||||
val blocks = remember(text) { replies.blocksOf(text) }
|
||||
if (blocks.size == 1) {
|
||||
MarkdownText(blocks.first(), replies, modifier)
|
||||
return
|
||||
}
|
||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(BLOCK_SPACING)) {
|
||||
blocks.forEach { block ->
|
||||
MarkdownText(
|
||||
block,
|
||||
replies,
|
||||
Modifier.fillMaxWidth()
|
||||
.then(if (live) Modifier.graphicsLayer() else Modifier)
|
||||
.drawWithContent {
|
||||
val started = System.nanoTime()
|
||||
drawContent()
|
||||
DebugStats.record("record: one block", System.nanoTime() - started)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The gap between one block of a reply and the next, here and in [transcriptUnits]. */
|
||||
val BLOCK_SPACING = 6.dp
|
||||
Reference in new issue
Block a user