Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
1ed6e29bc6
14 files changed
+637
-180
No files matched your search
@@ -558,6 +558,22 @@ fun interruptSession(settings: ServerSettings, sessionId: String) {
|
||||
requestFromServer(settings, "/sessions/$sessionId/interrupt", method = "POST") {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the process behind a session, leaving the session and its transcript.
|
||||
*
|
||||
* Not a delete and not an interrupt: the conversation stays exactly where it is and [startSession]
|
||||
* picks it back up. The server reports what it could not do -- there was nothing running, or the
|
||||
* machine would not say whether there was -- rather than answering the same way either way.
|
||||
*/
|
||||
fun stopSession(settings: ServerSettings, sessionId: String) {
|
||||
requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {}
|
||||
}
|
||||
|
||||
/** Starts the process again on the conversation it left. See [stopSession]. */
|
||||
fun startSession(settings: ServerSettings, sessionId: String) {
|
||||
requestFromServer(settings, "/sessions/$sessionId/start", method = "POST") {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a Claude Code session from the machine.
|
||||
*
|
||||
|
||||
@@ -31,6 +31,12 @@ import androidx.compose.ui.unit.sp
|
||||
* 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
|
||||
@@ -52,9 +58,27 @@ 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: interrupt the turn that is running. */
|
||||
/**
|
||||
* `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.
|
||||
*
|
||||
@@ -142,5 +166,13 @@ fun Glyph(
|
||||
Text(glyph, fontFamily = NerdIcons, fontSize = size, color = colour, modifier = modifier)
|
||||
}
|
||||
|
||||
/** The size an icon draws at beside a line of text. */
|
||||
private val GLYPH_SIZE = 20.sp
|
||||
/**
|
||||
* 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
|
||||
@@ -1478,30 +1478,44 @@ fun SessionScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (running) {
|
||||
// The same filled shape as the button beside it, not an outlined one: these
|
||||
// are two things you can do about the turn that is running, and weighting one
|
||||
// of them as secondary said they were a primary action and its qualifier.
|
||||
// What separates them is the colour and the mark, which is what they mean.
|
||||
Button(
|
||||
onClick = { act { interruptSession(settings, summary.id) } },
|
||||
colors = actionButtonColors(stopColor),
|
||||
) {
|
||||
// A filled square, which is what stop has looked like since tape decks.
|
||||
Glyph(
|
||||
STOP_GLYPH,
|
||||
colour = LocalContentColor.current,
|
||||
modifier = Modifier.semantics { contentDescription = "Stop" },
|
||||
)
|
||||
// The same filled shape as the button beside it, not an outlined one: these are
|
||||
// two things you can do about the session, and weighting one of them as secondary
|
||||
// said they were a primary action and its qualifier. What separates them is the
|
||||
// colour and the mark, which is what they mean.
|
||||
//
|
||||
// Always here, rather than arriving with the turn as it used to. A control that
|
||||
// comes and goes makes its own presence the signal, and its absence could not say
|
||||
// whether there was nothing to do; a button that is always in the same place also
|
||||
// cannot push Send off the end of the row by turning up.
|
||||
val process =
|
||||
when {
|
||||
running -> ProcessAction.Pause
|
||||
status == "exited" -> ProcessAction.Start
|
||||
else -> ProcessAction.Stop
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { act { process.perform(settings, summary.id) } },
|
||||
colors = actionButtonColors(process.colour()),
|
||||
) {
|
||||
Glyph(
|
||||
process.glyph,
|
||||
colour = LocalContentColor.current,
|
||||
modifier = Modifier.semantics { contentDescription = process.label },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// The paper plane, with a clock on it while a turn is in flight: sending then
|
||||
// queues the message for the next tool boundary rather than starting a turn of
|
||||
// its own, and the two have to be told apart at a glance. The label says the same
|
||||
// thing to a screen reader, which has nothing else to read.
|
||||
//
|
||||
// Disabled while there is nothing to send, rather than pressable and silent:
|
||||
// `send` has always returned early on an empty composer, so the button promised
|
||||
// something it would not do, and the only feedback was the ripple. Disabled and
|
||||
// not hidden, for the reason the button beside it is always here.
|
||||
Button(
|
||||
onClick = { send() },
|
||||
enabled = input.isNotBlank() || pendingAttachments.isNotEmpty(),
|
||||
colors = actionButtonColors(if (running) queueColor else sendColor),
|
||||
) {
|
||||
Glyph(
|
||||
@@ -1522,6 +1536,39 @@ fun SessionScreen(
|
||||
/** What pressing Send does right now, said the same way to the eye and to a screen reader. */
|
||||
private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send"
|
||||
|
||||
/**
|
||||
* What the composer's process button would do if it were pressed now.
|
||||
*
|
||||
* One value rather than four parallel conditions over the status, because the mark, the colour, the
|
||||
* name a screen reader is given and the request that goes out are four halves of one decision. A
|
||||
* button drawn as a pause that terminates the CLI is the worst bug available here, and separate
|
||||
* branches over the same condition are how that happens -- these three each have to cover every
|
||||
* case, and the compiler says so.
|
||||
*/
|
||||
private enum class ProcessAction(val glyph: String, val label: String) {
|
||||
/** A turn is running: take it back, and leave the process holding the conversation. */
|
||||
Pause(PAUSE_GLYPH, "Pause"),
|
||||
/** Nothing is running, but the process behind the session is: end it. */
|
||||
Stop(STOP_GLYPH, "Stop"),
|
||||
/** The process is gone: start it again, on the conversation it left. */
|
||||
Start(PLAY_GLYPH, "Start"),
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProcessAction.colour() =
|
||||
when (this) {
|
||||
ProcessAction.Pause -> pauseColor
|
||||
ProcessAction.Stop -> stopColor
|
||||
ProcessAction.Start -> startColor
|
||||
}
|
||||
|
||||
private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) =
|
||||
when (this) {
|
||||
ProcessAction.Pause -> interruptSession(settings, sessionId)
|
||||
ProcessAction.Stop -> stopSession(settings, sessionId)
|
||||
ProcessAction.Start -> startSession(settings, sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* An inline transcript image, fetched (authenticated, pinned) from the session's files route. The
|
||||
* bitmap is remembered per ref, so scrolling doesn't refetch.
|
||||
|
||||
@@ -213,13 +213,14 @@ val overLimitColor: Color
|
||||
@Composable get() = MaterialTheme.colorScheme.error
|
||||
|
||||
/**
|
||||
* The composer's three buttons, coloured by what pressing one does rather than by where it sits.
|
||||
* The composer's buttons, coloured by what pressing one does rather than by where it sits.
|
||||
*
|
||||
* Green sends now, blue sends later, red takes the running turn away. The pair of greens and the
|
||||
* pair of reds elsewhere in this file are deliberate near-collisions worth naming: [runningColor]
|
||||
* is green because a session is working, and [failedColor] is red because one fell over -- those
|
||||
* are *states*, and these are *actions*. A reader never has to tell them apart, because nothing
|
||||
* here is a state and nothing there is pressable.
|
||||
* Green makes something happen now, blue makes it happen later, orange takes back what is in
|
||||
* flight, red ends the process. The near-collisions with the states above are deliberate and worth
|
||||
* naming rather than collapsing: [runningColor] is green because a session is working,
|
||||
* [failedColor] is red because one fell over, [awaitingColor] is the same orange because a session
|
||||
* is waiting on somebody -- those are *states*, and these are *actions*. A reader never has to tell
|
||||
* them apart, because nothing here is a state and nothing there is pressable.
|
||||
*/
|
||||
val sendColor: Color
|
||||
@Composable get() = Mocha.Green
|
||||
@@ -228,10 +229,30 @@ val sendColor: Color
|
||||
val queueColor: Color
|
||||
@Composable get() = Mocha.Blue
|
||||
|
||||
/** Interrupting the running turn -- the one button here that takes something away. */
|
||||
/**
|
||||
* Interrupting the running turn: the work stops and the session stays.
|
||||
*
|
||||
* Orange rather than red because of how much it takes: only what is in flight. The process is still
|
||||
* there holding the conversation, and the next message starts a turn as though nothing had
|
||||
* happened. Red is spent on [stopColor], which is the same button in the same place when what it
|
||||
* would end is the session's process.
|
||||
*/
|
||||
val pauseColor: Color
|
||||
@Composable get() = Mocha.Peach
|
||||
|
||||
/** Ending the session's process -- the one button here that takes something away. */
|
||||
val stopColor: Color
|
||||
@Composable get() = Mocha.Red
|
||||
|
||||
/**
|
||||
* Starting the process again, on the conversation it left.
|
||||
*
|
||||
* The same green as [sendColor] on purpose: both mean "this happens now", and they are never the
|
||||
* same button -- the process button only offers to start when there is nothing running to stop.
|
||||
*/
|
||||
val startColor: Color
|
||||
@Composable get() = Mocha.Green
|
||||
|
||||
/**
|
||||
* A filled button in one of the action colours above.
|
||||
*
|
||||
|
||||
Binary file not shown.
+16
-3
@@ -32,6 +32,8 @@ GLYPHS=(
|
||||
U+F0450 # md-refresh
|
||||
U+F048A # md-send
|
||||
U+F04DB # md-stop
|
||||
U+F03E4 # md-pause
|
||||
U+F040A # md-play
|
||||
U+F1163 # md-send_clock
|
||||
U+F0156 # md-close
|
||||
U+F004D # md-arrow_left
|
||||
@@ -52,9 +54,20 @@ python3 -m venv "$work/venv"
|
||||
|
||||
unicodes="$(IFS=,; echo "${GLYPHS[*]}")"
|
||||
mkdir -p "$(dirname "$out")"
|
||||
# The proportional face rather than the Mono one: these are drawn inline
|
||||
# beside text, where a fixed advance would pad each icon out to a cell.
|
||||
"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFont-Regular.ttf" \
|
||||
# The Mono face rather than the proportional one, which this used until
|
||||
# 2026-08-30. Every glyph in it is one em wide and one em tall, so two
|
||||
# icons drawn at the same size are the same size -- which is what makes two
|
||||
# icon buttons beside each other match without either of them being told a
|
||||
# width. In the proportional face the advances run from 0.46 em (play) to
|
||||
# 0.92 em (line chart), so the composer's Send button came out visibly wider
|
||||
# than the Stop button next to it, and any fix at the call site would have
|
||||
# been one measurement hardcoded per pair.
|
||||
#
|
||||
# The trade is the one the old comment named: an icon inline beside text is
|
||||
# padded out to a cell. That is worth it, and it is also why GLYPH_SIZE in
|
||||
# NerdIcons.kt came down when this changed -- a glyph that fills its em
|
||||
# draws bigger at the same point size than one that does not.
|
||||
"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFontMono-Regular.ttf" \
|
||||
--unicodes="$unicodes" \
|
||||
--layout-features= \
|
||||
--drop-tables+=DSIG \
|
||||
|
||||
+26
-91
@@ -1,7 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Builds and runs this app on an emulator, creating/booting the AVD first if
|
||||
# it isn't already up. Same flow as dev-updater's run-android.sh; see that
|
||||
# script for the reasoning behind the avd handling.
|
||||
# Builds this app and runs it on this checkout's emulator.
|
||||
#
|
||||
# The emulator half of this -- which AVD this checkout means, creating it,
|
||||
# booting it headless, and refusing to start one the machine has no room for
|
||||
# -- lives in ~/repos/emulator-tools and is shared with every other Android
|
||||
# checkout here. This script kept its own copy of that sequence until
|
||||
# 2026-08-30, as did dev-updater's and ai-app's, and three copies of "boot an
|
||||
# emulator" is three places for the memory check that was missing from all of
|
||||
# them.
|
||||
#
|
||||
# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh,
|
||||
# which can also be sourced directly for one-off commands.
|
||||
@@ -9,105 +15,34 @@ set -eu
|
||||
|
||||
APP_ID="com.example.aiapp"
|
||||
|
||||
DEVICE_PROFILE="${DEVICE_PROFILE:-pixel_10}"
|
||||
SYSTEM_IMAGE="${SYSTEM_IMAGE:-system-images;android-36;google_apis;x86_64}"
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# One AVD per checkout, named after it -- so two clones of this repo, or a
|
||||
# clone and a worktree, each get their own rather than fighting over one.
|
||||
# This used to default to a machine-wide "tdep", which made the emulator the
|
||||
# one thing here that could not be worked on in parallel: taking it meant
|
||||
# asking whoever had it, waiting, and handing it back, and installing onto a
|
||||
# running one steals the foreground from whatever they were looking at.
|
||||
# Derived rather than written down, so neither clone names the other's.
|
||||
# Override with AVD_NAME=... to share one deliberately.
|
||||
AVD_NAME="${AVD_NAME:-$(basename "$(dirname "$SCRIPT_DIR")")}"
|
||||
|
||||
# shellcheck source=./android-env.sh
|
||||
. ./android-env.sh
|
||||
|
||||
# Prints the adb serial of a running instance of AVD "$1", or nothing.
|
||||
avd_serial() {
|
||||
for s in $(adb devices | awk '$2 == "device" {print $1}'); do
|
||||
if [ "$(adb -s "$s" emu avd name 2>/dev/null | head -n1 | tr -d '\r')" = "$1" ]; then
|
||||
echo "$s"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
echo "==> Ensuring emulator system image is installed"
|
||||
android sdk install emulator "$SYSTEM_IMAGE" || echo " (non-fatal: see above)"
|
||||
|
||||
if [ ! -f "$ANDROID_AVD_HOME/$AVD_NAME.ini" ]; then
|
||||
echo "==> Creating AVD '$AVD_NAME' ($DEVICE_PROFILE, $SYSTEM_IMAGE)"
|
||||
echo no | avdmanager create avd \
|
||||
-n "$AVD_NAME" \
|
||||
-k "$SYSTEM_IMAGE" \
|
||||
--device "$DEVICE_PROFILE" \
|
||||
--sdcard 512M
|
||||
else
|
||||
echo "==> Reusing existing AVD '$AVD_NAME'"
|
||||
if ! command -v emu >/dev/null 2>&1; then
|
||||
echo "run-android.sh: no 'emu' command." >&2
|
||||
echo " It comes from ~/repos/emulator-tools; run that repo's ./install.sh." >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# Host keyboard into the emulator -- this app has text fields.
|
||||
CONFIG_INI="$ANDROID_AVD_HOME/$AVD_NAME.avd/config.ini"
|
||||
if [ -f "$CONFIG_INI" ]; then
|
||||
grep -v '^hw\.keyboard=' "$CONFIG_INI" >"$CONFIG_INI.tmp"
|
||||
echo "hw.keyboard=yes" >>"$CONFIG_INI.tmp"
|
||||
mv "$CONFIG_INI.tmp" "$CONFIG_INI"
|
||||
fi
|
||||
|
||||
SERIAL=$(avd_serial "$AVD_NAME")
|
||||
if [ -n "$SERIAL" ]; then
|
||||
echo "==> Emulator '$AVD_NAME' already running ($SERIAL)"
|
||||
else
|
||||
# Clean up a stray/crashed process for this AVD, if any. The bracketed
|
||||
# first character keeps the pattern from matching the shell running
|
||||
# this script -- unbracketed, this kills that shell mid-run.
|
||||
pkill -f "[e]mulator.*-avd $AVD_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
EMU_LOG="/tmp/$AVD_NAME-emulator.log"
|
||||
: >"$EMU_LOG"
|
||||
if [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then
|
||||
echo "==> Starting emulator '$AVD_NAME' with GPU acceleration (-gpu host)"
|
||||
emulator -avd "$AVD_NAME" -gpu host -no-audio >"$EMU_LOG" 2>&1 &
|
||||
else
|
||||
echo "==> No display -- starting emulator '$AVD_NAME' headless (-gpu swiftshader_indirect)"
|
||||
emulator -avd "$AVD_NAME" -gpu swiftshader_indirect -no-audio -no-window \
|
||||
>"$EMU_LOG" 2>&1 &
|
||||
fi
|
||||
EMU_PID=$!
|
||||
|
||||
i=0
|
||||
booted=""
|
||||
while [ "$i" -lt 150 ]; do
|
||||
if ! kill -0 "$EMU_PID" 2>/dev/null; then
|
||||
echo "Emulator process exited unexpectedly. Log output:" >&2
|
||||
cat "$EMU_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
SERIAL=$(avd_serial "$AVD_NAME")
|
||||
if [ -n "$SERIAL" ]; then
|
||||
booted=$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
|
||||
[ "$booted" = "1" ] && break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
if [ "$booted" != "1" ]; then
|
||||
echo "Emulator did not finish booting in time. Log output:" >&2
|
||||
cat "$EMU_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# Prints the serial, having created and booted the AVD if it had to. Named
|
||||
# after the checkout, so this cannot land on another session's emulator --
|
||||
# and refuses rather than starting one when the machine is short of memory,
|
||||
# because what an OOM kills is somebody else's work rather than the emulator
|
||||
# that asked for the memory.
|
||||
echo "==> Emulator"
|
||||
SERIAL=$(emu up)
|
||||
export ANDROID_SERIAL="$SERIAL"
|
||||
|
||||
echo "==> Building debug APK"
|
||||
./gradlew :androidApp:assembleDebug
|
||||
|
||||
APK="androidApp/build/outputs/apk/debug/androidApp-debug.apk"
|
||||
echo "==> Installing and launching $APK"
|
||||
adb -s "$SERIAL" install -r "$APK"
|
||||
adb -s "$SERIAL" shell am start -n "$APP_ID/.MainActivity"
|
||||
# ANDROID_SERIAL above is what aims these; the adb wrapper would work it out
|
||||
# from the checkout anyway, but a script that says which device it means does
|
||||
# not depend on being run from the right directory.
|
||||
adb install -r "$APK"
|
||||
adb shell am start -n "$APP_ID/.MainActivity"
|
||||
Reference in new issue
Block a user