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,219 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The icons the app draws, as glyphs in a Nerd Fonts subset rather than as vector assets.
|
||||
*
|
||||
* Drawing them as *text* is what makes them cheap: an icon beside a line of text wants that line's
|
||||
* size, colour and baseline, and a `Text` gets all three for free where an `Icon` needs each one
|
||||
* set and kept in step by hand.
|
||||
*
|
||||
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
|
||||
* grounds that a system font may not have the glyph and whoever gets the empty box instead is never
|
||||
* the person who wrote it. That objection is about *relying* on a system font, and it is exactly
|
||||
* right: the answer is not to avoid glyphs but to ship them. The font here is
|
||||
* `app/build-icon-font.sh`'s output -- eleven glyphs, 2.1 KB, subset out of the 3 MB symbols font
|
||||
* and committed -- so the codepoints below are resolved by an asset in the APK and cannot come back
|
||||
* as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script
|
||||
* did not subset is a glyph that silently isn't there.
|
||||
*
|
||||
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
|
||||
* That is what makes two icons the same size without either of them being given a size: the
|
||||
* proportional face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side
|
||||
* by side came out visibly different widths, and matching them at the call site would have meant
|
||||
* one hardcoded measurement per pair. [GLYPH_SIZE] carries the cost.
|
||||
*
|
||||
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
|
||||
* Material Design codepoints. Those two must not drift: an icon that means "settings" in one app
|
||||
* and something else in the other is the failure this is worth preventing. The script is copied
|
||||
* rather than shared because most of what looks like duplication is the `GLYPHS` list, which has to
|
||||
* differ -- the point of subsetting is to ship only the codepoints one app draws. All Material
|
||||
* Design bar one, so they read as one family; the exception is noted where it is declared.
|
||||
*/
|
||||
val NerdIcons = FontFamily(Font(R.font.nerd_icons))
|
||||
|
||||
/** Nerd Fonts puts these in plane 15, so each is a surrogate pair. */
|
||||
private fun glyph(codePoint: Int) = String(Character.toChars(codePoint))
|
||||
|
||||
/** `md-cog` -- settings for the thing it sits beside. */
|
||||
val SETTINGS_GLYPH = glyph(0xF0493)
|
||||
|
||||
/** `md-refresh` -- ask the server again for whatever is on screen. */
|
||||
val REFRESH_GLYPH = glyph(0xF0450)
|
||||
|
||||
/** `md-send` -- the filled paper plane: submit what is in the composer. */
|
||||
val SEND_GLYPH = glyph(0xF048A)
|
||||
|
||||
/**
|
||||
* `md-stop` -- a filled square: end the process behind this session.
|
||||
*
|
||||
* The square is what stop has meant since tape decks, and it is spent here on the thing that
|
||||
* actually stops rather than on pausing. [PAUSE_GLYPH] is the turn; this is the session.
|
||||
*/
|
||||
val STOP_GLYPH = glyph(0xF04DB)
|
||||
|
||||
/**
|
||||
* `md-pause` -- two bars: take the running turn away and leave the session there.
|
||||
*
|
||||
* The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what
|
||||
* pressing it now would do to the process, and the three marks are the three answers. An interrupt
|
||||
* ends a turn and nothing else -- the CLI is still there and still holds the conversation -- which
|
||||
* is a pause, not a stop, and drawing it as a square said otherwise.
|
||||
*/
|
||||
val PAUSE_GLYPH = glyph(0xF03E4)
|
||||
|
||||
/** `md-play` -- start the process again, on the conversation it left. See [PAUSE_GLYPH]. */
|
||||
val PLAY_GLYPH = glyph(0xF040A)
|
||||
|
||||
/**
|
||||
* `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn.
|
||||
*
|
||||
* The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than
|
||||
* starting one, and the two buttons have to be told apart at a glance -- one glyph doing both jobs
|
||||
* while looking identical would promise something immediate and do something that waits.
|
||||
*/
|
||||
val QUEUE_GLYPH = glyph(0xF1163)
|
||||
|
||||
/** `md-close` -- take this off again: an attachment picked and not wanted. */
|
||||
val CLOSE_GLYPH = glyph(0xF0156)
|
||||
|
||||
/** `md-arrow_left` -- back one level, to whatever this was opened from. */
|
||||
val BACK_GLYPH = glyph(0xF004D)
|
||||
|
||||
/** `md-bell` -- the notifications this session is allowed to raise. */
|
||||
val BELL_GLYPH = glyph(0xF009A)
|
||||
|
||||
/**
|
||||
* `fa-line_chart` -- how much of the account's rate limits is gone.
|
||||
*
|
||||
* Font Awesome's rather than Material's, which is the one break in the family above: it was asked
|
||||
* for by name, and Material's chart glyphs are a bare line where this one has its axes, which is
|
||||
* what makes it read as a measurement rather than as a trend.
|
||||
*/
|
||||
val USAGE_GLYPH = glyph(0xF201)
|
||||
|
||||
/**
|
||||
* `md-speedometer` -- what this session is costing to draw.
|
||||
*
|
||||
* A speedometer rather than a bug, because what it copies is a measurement rather than a fault
|
||||
* report: it is as useful on a screen that feels fine, where the answer is that nothing is slow.
|
||||
*/
|
||||
val SPEED_GLYPH = glyph(0xF04C5)
|
||||
|
||||
/**
|
||||
* The size an icon draws at beside a line of text.
|
||||
*
|
||||
* 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at
|
||||
* most 0.83 em of its point size and most filled a good deal less, so the number was standing in
|
||||
* for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and
|
||||
* keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked
|
||||
* for. This is what the largest of them already drew at.
|
||||
*/
|
||||
private val GLYPH_SIZE = 17.sp
|
||||
|
||||
/**
|
||||
* The same measurement in dp: a glyph's em box is its point size, and a layout is laid out in dp.
|
||||
*/
|
||||
private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
|
||||
|
||||
/**
|
||||
* The square a glyph button occupies: the mark, plus the same ring of padding on all four sides.
|
||||
*
|
||||
* The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to
|
||||
* the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of
|
||||
* its own, and a mark cannot end up further from the button beside it than from the edge of the
|
||||
* screen. That is what it was: the box was the size of the mark (28dp) and the separation was
|
||||
* bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the
|
||||
* edge, so a pair that acts on one screen read as two unrelated marks with one falling off it.
|
||||
*
|
||||
* 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has
|
||||
* to find. It is what the pressed-state ripple draws, too: at 28dp that circle was inscribed in the
|
||||
* mark's own corners, and beside a title it arrived at the first letter. And it is taller than any
|
||||
* header's text, which is what lets the button fill a header row rather than sit in the middle of
|
||||
* one -- the rows add no vertical padding of their own for the same reason they add no gap.
|
||||
*/
|
||||
private val GLYPH_BUTTON_SIZE = 48.dp
|
||||
|
||||
/**
|
||||
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
|
||||
* a back arrow.
|
||||
*
|
||||
* Two glyph buttons need nothing between them: each brings its own ring and the two add up, which
|
||||
* is why a row of them sets no spacing. Text brings none, so the second ring has to be asked for.
|
||||
* Without it the pressed-state circle, which fills the whole square, arrives at the first letter of
|
||||
* the title -- and the gap a reader sees between the mark and that title is then half the one
|
||||
* between the two marks at the other end of the same row.
|
||||
*/
|
||||
val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
|
||||
|
||||
/**
|
||||
* A glyph you can press: the icon equivalent of a `TextButton`.
|
||||
*
|
||||
* Its own composable so that every icon button in the app is one size and one colour without each
|
||||
* caller saying so, and so the [label] none of them displays is still there for a screen reader --
|
||||
* which is all assistive technology has to go on, and also the answer to "what was that button for"
|
||||
* six months from now.
|
||||
*
|
||||
* [enabled] is passed through rather than left to callers hiding the button: a control that comes
|
||||
* and goes makes its own absence the signal, and absence cannot say whether there was nothing to do
|
||||
* or nobody checked.
|
||||
*/
|
||||
@Composable
|
||||
fun GlyphButton(
|
||||
glyph: String,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
colour: Color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
|
||||
) {
|
||||
Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One icon, drawn as text.
|
||||
*
|
||||
* Callers that are already inside something pressable use this; [GlyphButton] is the one that adds
|
||||
* the press. Either way the caller owes it a description, since neither draws a word.
|
||||
*/
|
||||
@Composable
|
||||
fun Glyph(
|
||||
glyph: String,
|
||||
modifier: Modifier = Modifier,
|
||||
colour: Color = MaterialTheme.colorScheme.primary,
|
||||
size: TextUnit = GLYPH_SIZE,
|
||||
) {
|
||||
// Line height of the point size, which for this font is the square the glyph draws in: its
|
||||
// ascent and descent add up to exactly one em, and every glyph in the Mono face fills that em.
|
||||
// Left to the inherited body style the line box was 24sp tall around a 17sp-wide mark, so a
|
||||
// glyph took a seventh more vertical space than horizontal wherever one is drawn without a box
|
||||
// around it -- and where there is a box, that leading is what its padding is measured through.
|
||||
Text(
|
||||
glyph,
|
||||
fontFamily = NerdIcons,
|
||||
fontSize = size,
|
||||
lineHeight = size,
|
||||
color = colour,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user