Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
359649bc73
26 files changed
+1361
-271
No files matched your search
@@ -3,56 +3,26 @@
|
|||||||
Working list from Iris, 2026-09-03. Remove an entry when it lands; annotate
|
Working list from Iris, 2026-09-03. Remove an entry when it lands; annotate
|
||||||
one in place when it turns out to need a decision.
|
one in place when it turns out to need a decision.
|
||||||
|
|
||||||
## App — composer and input
|
|
||||||
|
|
||||||
- [ ] Make the file-add button and the ones beside it "bubble" buttons with a
|
|
||||||
visible outline. The popups they open should be round.
|
|
||||||
- [ ] Returning to the app must not open the keyboard if it was closed when
|
|
||||||
the app was left.
|
|
||||||
- [ ] Tapping a slash-command suggestion should move the cursor to the end of
|
|
||||||
the inserted suggestion.
|
|
||||||
|
|
||||||
## App — transcript
|
## App — transcript
|
||||||
|
|
||||||
- [ ] Tapping an expanded agent message card should close it.
|
- [ ] Tapping an expanded agent message card should close it. — a tap that
|
||||||
- [ ] Tapping to deselect text should not toggle card expansion.
|
clears a selection now spends itself on that and nothing else
|
||||||
|
(`SessionScreen.expanding`); still to check on the emulator whether a tap
|
||||||
|
with *no* selection reaches an opened peer card at all.
|
||||||
- [ ] Text inside code blocks does not highlight when selected (selection
|
- [ ] Text inside code blocks does not highlight when selected (selection
|
||||||
itself works — only the highlight is missing).
|
itself works — only the highlight is missing). Waiting on the app-c7
|
||||||
- [ ] Bash logs should apply colour and the other basic text escape sequences,
|
session, which is replacing the highlighter (`HIGHLIGHTER_PLAN.md`) and
|
||||||
and filter the rest.
|
owns `CodeFence.kt` until it pushes; it confirmed this item is ours.
|
||||||
- [ ] An image should show a loading spinner in an area the size of the image.
|
|
||||||
- [ ] Messages received from other agents are inconsistent — sometimes they
|
- [ ] Messages received from other agents are inconsistent — sometimes they
|
||||||
appear, sometimes they don't.
|
appear, sometimes they don't.
|
||||||
|
|
||||||
## App — navigation
|
|
||||||
|
|
||||||
- [ ] Swiping right should open the session list, unless the gesture belongs to
|
|
||||||
a component (e.g. scrolling left inside a long text block).
|
|
||||||
|
|
||||||
## App — AskUserQuestion card
|
|
||||||
|
|
||||||
- [ ] Selection should highlight instantly instead of waiting; a Submit button
|
|
||||||
at the bottom sends, and becomes a spinner while sending.
|
|
||||||
- [ ] Arrow buttons in the top right navigate between questions instead of
|
|
||||||
stacking them all in a row.
|
|
||||||
- [ ] Submit stays greyed out until every question is answered.
|
|
||||||
|
|
||||||
## Formatting
|
|
||||||
|
|
||||||
- [ ] Show a timeout in the largest possible unit: 480000ms = 8m. Under a
|
|
||||||
minute show only the largest unit (2.5s, 30ms); at or over a minute show
|
|
||||||
all units (5d 12h 4m).
|
|
||||||
- [ ] Use tilde notation when moving the working directory.
|
|
||||||
|
|
||||||
## Session settings
|
## Session settings
|
||||||
|
|
||||||
- [ ] Autocompact belongs in session settings; empty disables it, which is the
|
- [ ] Autocompact belongs in session settings; empty disables it, which is the
|
||||||
default.
|
default. **Needs a decision before building** — nothing called autocompact
|
||||||
- [ ] Changing the model on a stopped provider should be possible, stored, and
|
exists yet on either side. `PLAN.md` has it only as a planned pi-driver
|
||||||
applied the next time it starts.
|
feature (`set_auto_compaction`), and Claude Code runs its own. So this is
|
||||||
|
a new server feature, and the open questions are what the empty-or-not
|
||||||
|
value *is* (a token count? a percentage of the context window?) and which
|
||||||
|
drivers it applies to.
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
- [ ] Add one minute to Claude's "time left".
|
|
||||||
- [ ] The transcript header and the usage menu should share one usage value —
|
|
||||||
they have been seen disagreeing.
|
|
||||||
@@ -37,12 +37,20 @@
|
|||||||
<profileable android:shell="true" tools:targetApi="q" />
|
<profileable android:shell="true" tools:targetApi="q" />
|
||||||
<!-- adjustResize (not the system's default pan): the layout handles
|
<!-- adjustResize (not the system's default pan): the layout handles
|
||||||
the keyboard itself via imePadding(), so the window must resize
|
the keyboard itself via imePadding(), so the window must resize
|
||||||
rather than slide the top bar off screen. -->
|
rather than slide the top bar off screen.
|
||||||
|
|
||||||
|
stateUnchanged: coming back to the app leaves the keyboard as it
|
||||||
|
was left. The default, stateUnspecified, lets the system decide,
|
||||||
|
and what it decides with a focused message field is to open the
|
||||||
|
keyboard, so switching away and back covered half the transcript
|
||||||
|
somebody had switched away to compare against. Unchanged rather
|
||||||
|
than hidden, because a keyboard that was up when the app was left
|
||||||
|
is one somebody was in the middle of typing into. -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize|stateUnchanged"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sixteen colours a terminal program names, and the two it assumes.
|
||||||
|
*
|
||||||
|
* Its own palette rather than the syntax one: a program that prints in red has chosen red, where a
|
||||||
|
* highlighter's colours are this app's reading of somebody else's code. They come out of the same
|
||||||
|
* Catppuccin values (see `ansiPalette` in `Theme.kt`) so nothing on screen is a colour from
|
||||||
|
* somewhere else, but the two are not one table and must not become one -- adding a syntax role to
|
||||||
|
* this list would silently move `ls`'s directory blue.
|
||||||
|
*/
|
||||||
|
data class AnsiPalette(
|
||||||
|
/** Indexes 0-7, then 8-15 bright, in the terminal's own order. */
|
||||||
|
val colours: List<Color>,
|
||||||
|
/** What uncoloured text is, needed only where a style has to state a colour. */
|
||||||
|
val foreground: Color,
|
||||||
|
/** What the text sits on, needed for reverse video. */
|
||||||
|
val background: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a tool printed, with its terminal styling applied and everything else taken out.
|
||||||
|
*
|
||||||
|
* Bash output arrives exactly as the program wrote it, escape sequences included, and drawn
|
||||||
|
* verbatim those are line noise in the middle of the thing being read: `ESC[0;32m` in front of
|
||||||
|
* every green word. Stripping them all would be the other half-answer -- colour is often the whole
|
||||||
|
* of what a diff, a test run or a linter is saying.
|
||||||
|
*
|
||||||
|
* So the sequences that decide how text *looks* become spans, and every other one is dropped.
|
||||||
|
* Dropped rather than shown, because the rest move a cursor around a grid this is not: a transcript
|
||||||
|
* is a scrolling document, and "go to column 40" has no meaning here that is better than nothing.
|
||||||
|
*
|
||||||
|
* A carriage return is honoured the way a terminal honours it: what was written since the last line
|
||||||
|
* break is thrown away and the line starts again. That is what makes a progress bar show its final
|
||||||
|
* state rather than every state it passed through, which was tens of lines run together.
|
||||||
|
*
|
||||||
|
* Not a composable, and the palette is a parameter: this can then be remembered against the text it
|
||||||
|
* parsed rather than re-run on every recomposition of the card holding it.
|
||||||
|
*/
|
||||||
|
fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
|
||||||
|
// The common case by a long way -- nothing to do, and nothing allocated to find that out.
|
||||||
|
if (text.indexOf(ESC) < 0 && text.indexOf('\r') < 0) return AnnotatedString(text)
|
||||||
|
|
||||||
|
val runs = mutableListOf<Run>()
|
||||||
|
var sgr = Sgr.PLAIN
|
||||||
|
var at = 0
|
||||||
|
val plain = StringBuilder()
|
||||||
|
|
||||||
|
fun flush() {
|
||||||
|
if (plain.isNotEmpty()) {
|
||||||
|
runs.add(Run(plain.toString(), sgr.span(palette)))
|
||||||
|
plain.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (at < text.length) {
|
||||||
|
val c = text[at]
|
||||||
|
when {
|
||||||
|
c == ESC -> {
|
||||||
|
flush()
|
||||||
|
at =
|
||||||
|
skipEscape(text, at) { params, final ->
|
||||||
|
if (final == 'm') sgr = sgr.apply(params, palette)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A bare carriage return rewrites the line; one before a newline is the other half of
|
||||||
|
// a Windows line ending and has nothing to rewrite.
|
||||||
|
c == '\r' && text.getOrNull(at + 1) != '\n' -> {
|
||||||
|
flush()
|
||||||
|
dropLine(runs)
|
||||||
|
at++
|
||||||
|
}
|
||||||
|
c == '\r' -> at++
|
||||||
|
// Everything printable, plus the two control characters that are layout rather than
|
||||||
|
// terminal commands. A stray bell or backspace goes for the same reason a cursor
|
||||||
|
// move does.
|
||||||
|
c >= ' ' || c == '\n' || c == '\t' -> {
|
||||||
|
plain.append(c)
|
||||||
|
at++
|
||||||
|
}
|
||||||
|
else -> at++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
|
||||||
|
return buildAnnotatedString {
|
||||||
|
runs.forEach { run ->
|
||||||
|
if (run.style == null) {
|
||||||
|
append(run.text)
|
||||||
|
} else {
|
||||||
|
val pushed = pushStyle(run.style)
|
||||||
|
append(run.text)
|
||||||
|
pop(pushed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One stretch of text that shares a style. */
|
||||||
|
private class Run(val text: String, val style: SpanStyle?)
|
||||||
|
|
||||||
|
/** Throws away everything written since the last line break, as a carriage return does. */
|
||||||
|
private fun dropLine(runs: MutableList<Run>) {
|
||||||
|
while (runs.isNotEmpty()) {
|
||||||
|
val last = runs.removeAt(runs.size - 1)
|
||||||
|
val breakAt = last.text.lastIndexOf('\n')
|
||||||
|
if (breakAt >= 0) {
|
||||||
|
runs.add(Run(last.text.substring(0, breakAt + 1), last.style))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val ESC = '\u001B'
|
||||||
|
|
||||||
|
private const val BELL = '\u0007'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte.
|
||||||
|
*
|
||||||
|
* One reader for every kind, because the point is to *leave* them all behind: a sequence this did
|
||||||
|
* not recognise would otherwise have its body printed as ordinary text, which is worse than the
|
||||||
|
* escape it was meant to remove. Three shapes -- the CSI (`ESC [ … letter`), the string escapes
|
||||||
|
* (OSC, DCS, APC, PM) which run to a terminator, and the two-character ones.
|
||||||
|
*/
|
||||||
|
private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int {
|
||||||
|
val next = text.getOrNull(at + 1) ?: return at + 1
|
||||||
|
return when (next) {
|
||||||
|
'[' -> {
|
||||||
|
var end = at + 2
|
||||||
|
while (end < text.length && text[end] !in CSI_FINAL) end++
|
||||||
|
if (end >= text.length) {
|
||||||
|
// Cut off mid-sequence, which is what a stream that has not finished arriving
|
||||||
|
// looks like: drop the fragment rather than printing it, and the whole sequence
|
||||||
|
// arrives with the next delta.
|
||||||
|
text.length
|
||||||
|
} else {
|
||||||
|
onCsi(text.substring(at + 2, end), text[end])
|
||||||
|
end + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
']',
|
||||||
|
'P',
|
||||||
|
'X',
|
||||||
|
'^',
|
||||||
|
'_' -> {
|
||||||
|
// Runs to a string terminator: `ESC \`, or the bell that xterm allows after an OSC.
|
||||||
|
var end = at + 2
|
||||||
|
while (end < text.length) {
|
||||||
|
if (text[end] == BELL) return end + 1
|
||||||
|
if (text[end] == ESC && text.getOrNull(end + 1) == '\\') return end + 2
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
text.length
|
||||||
|
}
|
||||||
|
else -> at + 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bytes that end a CSI sequence. */
|
||||||
|
private val CSI_FINAL = '@'..'~'
|
||||||
|
|
||||||
|
/** Everything an SGR sequence can turn on, as the terminal tracks it. */
|
||||||
|
private data class Sgr(
|
||||||
|
val fg: Color?,
|
||||||
|
val bg: Color?,
|
||||||
|
val bold: Boolean,
|
||||||
|
val dim: Boolean,
|
||||||
|
val italic: Boolean,
|
||||||
|
val underline: Boolean,
|
||||||
|
val strike: Boolean,
|
||||||
|
val reverse: Boolean,
|
||||||
|
) {
|
||||||
|
/** Null while nothing is set, so unstyled output costs no spans at all. */
|
||||||
|
fun span(palette: AnsiPalette): SpanStyle? {
|
||||||
|
if (this == PLAIN) return null
|
||||||
|
val front = if (reverse) bg ?: palette.background else fg
|
||||||
|
val back = if (reverse) fg ?: palette.foreground else bg
|
||||||
|
// Dim has to have a colour to dim, so where none was named it dims the ordinary one.
|
||||||
|
val stated = front ?: palette.foreground.takeIf { dim }
|
||||||
|
return SpanStyle(
|
||||||
|
color =
|
||||||
|
stated?.let { if (dim) it.copy(alpha = DIM_ALPHA) else it } ?: Color.Unspecified,
|
||||||
|
background = back ?: Color.Unspecified,
|
||||||
|
fontWeight = if (bold) FontWeight.Bold else null,
|
||||||
|
fontStyle = if (italic) FontStyle.Italic else null,
|
||||||
|
textDecoration =
|
||||||
|
when {
|
||||||
|
underline && strike ->
|
||||||
|
TextDecoration.combine(
|
||||||
|
listOf(TextDecoration.Underline, TextDecoration.LineThrough)
|
||||||
|
)
|
||||||
|
underline -> TextDecoration.Underline
|
||||||
|
strike -> TextDecoration.LineThrough
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This state with [params] applied -- one `ESC[…m`, which carries any number of them.
|
||||||
|
*
|
||||||
|
* A code this does not model is ignored rather than reset from: the program meant something by
|
||||||
|
* it, and starting again would also drop the codes beside it that are understood.
|
||||||
|
*/
|
||||||
|
fun apply(params: String, palette: AnsiPalette): Sgr {
|
||||||
|
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a zero too.
|
||||||
|
val codes = params.split(';').map { it.trim().toIntOrNull() ?: 0 }
|
||||||
|
var state = this
|
||||||
|
var at = 0
|
||||||
|
while (at < codes.size) {
|
||||||
|
val code = codes[at]
|
||||||
|
state =
|
||||||
|
when (code) {
|
||||||
|
0 -> PLAIN
|
||||||
|
1 -> state.copy(bold = true)
|
||||||
|
2 -> state.copy(dim = true)
|
||||||
|
3 -> state.copy(italic = true)
|
||||||
|
4 -> state.copy(underline = true)
|
||||||
|
7 -> state.copy(reverse = true)
|
||||||
|
9 -> state.copy(strike = true)
|
||||||
|
21,
|
||||||
|
22 -> state.copy(bold = false, dim = false)
|
||||||
|
23 -> state.copy(italic = false)
|
||||||
|
24 -> state.copy(underline = false)
|
||||||
|
27 -> state.copy(reverse = false)
|
||||||
|
29 -> state.copy(strike = false)
|
||||||
|
in 30..37 -> state.copy(fg = palette.colours[code - 30])
|
||||||
|
in 90..97 -> state.copy(fg = palette.colours[code - 90 + 8])
|
||||||
|
in 40..47 -> state.copy(bg = palette.colours[code - 40])
|
||||||
|
in 100..107 -> state.copy(bg = palette.colours[code - 100 + 8])
|
||||||
|
39 -> state.copy(fg = null)
|
||||||
|
49 -> state.copy(bg = null)
|
||||||
|
38,
|
||||||
|
48 -> {
|
||||||
|
val (colour, last) = extendedColour(codes, at, palette)
|
||||||
|
at = last
|
||||||
|
if (code == 38) state.copy(fg = colour) else state.copy(bg = colour)
|
||||||
|
}
|
||||||
|
else -> state
|
||||||
|
}
|
||||||
|
at++
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val PLAIN =
|
||||||
|
Sgr(
|
||||||
|
fg = null,
|
||||||
|
bg = null,
|
||||||
|
bold = false,
|
||||||
|
dim = false,
|
||||||
|
italic = false,
|
||||||
|
underline = false,
|
||||||
|
strike = false,
|
||||||
|
reverse = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much of its colour dim text keeps: enough to read, little enough to recede. */
|
||||||
|
private const val DIM_ALPHA = 0.65f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour named by a `38`/`48` at [at], and the index of that colour's last parameter.
|
||||||
|
*
|
||||||
|
* Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal one. The first sixteen of
|
||||||
|
* that table are the palette's own, so a program asking for "colour 1" through either spelling gets
|
||||||
|
* the same red.
|
||||||
|
*/
|
||||||
|
private fun extendedColour(codes: List<Int>, at: Int, palette: AnsiPalette): Pair<Color?, Int> =
|
||||||
|
when (codes.getOrNull(at + 1)) {
|
||||||
|
5 -> {
|
||||||
|
val n = codes.getOrNull(at + 2)
|
||||||
|
if (n == null) null to at + 1 else indexedColour(n, palette) to at + 2
|
||||||
|
}
|
||||||
|
2 -> {
|
||||||
|
val r = codes.getOrNull(at + 2)
|
||||||
|
val g = codes.getOrNull(at + 3)
|
||||||
|
val b = codes.getOrNull(at + 4)
|
||||||
|
if (r == null || g == null || b == null) null to at + 1
|
||||||
|
else Color(r.coerceIn(0, 255), g.coerceIn(0, 255), b.coerceIn(0, 255)) to at + 4
|
||||||
|
}
|
||||||
|
else -> null to at + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a grey ramp. */
|
||||||
|
private fun indexedColour(n: Int, palette: AnsiPalette): Color =
|
||||||
|
when {
|
||||||
|
n < 0 -> palette.foreground
|
||||||
|
n < 16 -> palette.colours[n]
|
||||||
|
n < 232 -> {
|
||||||
|
val i = n - 16
|
||||||
|
Color(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
|
||||||
|
}
|
||||||
|
n < 256 -> {
|
||||||
|
val grey = 8 + (n - 232) * 10
|
||||||
|
Color(grey, grey, grey)
|
||||||
|
}
|
||||||
|
else -> palette.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The six levels of each channel in the 256-colour cube, as xterm defines them. */
|
||||||
|
private val CUBE = intArrayOf(0, 95, 135, 175, 215, 255)
|
||||||
@@ -198,16 +198,21 @@ fun AppRoot(
|
|||||||
// row key. Only reachable since a notification can move straight from one session to
|
// row key. Only reachable since a notification can move straight from one session to
|
||||||
// another; every other way here passes through [Screen.Main], which disposes it anyway.
|
// another; every other way here passes through [Screen.Main], which disposes it anyway.
|
||||||
key(here.summary.id) {
|
key(here.summary.id) {
|
||||||
SessionScreen(
|
// The gesture goes on a box around the screen rather than inside it, so it is the
|
||||||
settings = current,
|
// outermost thing in the tree and everything within has already had its chance at
|
||||||
summary = here.summary,
|
// the drag. See [swipeBack]. No imePadding here, for the reason above.
|
||||||
onBack = goToMain,
|
Box(Modifier.swipeBack(goToMain)) {
|
||||||
share = share,
|
SessionScreen(
|
||||||
onShareTaken = { share = null },
|
settings = current,
|
||||||
)
|
summary = here.summary,
|
||||||
|
onBack = goToMain,
|
||||||
|
share = share,
|
||||||
|
onShareTaken = { share = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is Screen.Spawn ->
|
is Screen.Spawn ->
|
||||||
Box(Modifier.imePadding()) {
|
Box(Modifier.imePadding().swipeBack(goToMain)) {
|
||||||
SpawnScreen(
|
SpawnScreen(
|
||||||
settings = current,
|
settings = current,
|
||||||
onSpawned = { spawned ->
|
onSpawned = { spawned ->
|
||||||
@@ -218,7 +223,7 @@ fun AppRoot(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
is Screen.Settings ->
|
is Screen.Settings ->
|
||||||
Box(Modifier.imePadding()) {
|
Box(Modifier.imePadding().swipeBack(goToMain)) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
existing = current,
|
existing = current,
|
||||||
onSaved = { saved ->
|
onSaved = { saved ->
|
||||||
|
|||||||
@@ -10,42 +10,174 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedCard
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** One question's answer on its way back, so a card can hand over several at once. */
|
||||||
|
data class QuestionAnswer(val questionId: String, val answers: List<String>)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every question one tool call is waiting on.
|
* What the reader has settled on for one question, before any of it is sent.
|
||||||
|
*
|
||||||
|
* Held here rather than inferred from the transcript, which is what made picking an option feel
|
||||||
|
* broken: the mark used to appear only when the answer had crossed the tunnel, been recorded and
|
||||||
|
* come back as an event, so on a phone the card sat unchanged for most of a second after a tap and
|
||||||
|
* the natural response was to tap again.
|
||||||
|
*
|
||||||
|
* Picked options and typed words are one field each because they are alternatives rather than
|
||||||
|
* parts: answering in the reader's own words is the case no option covers, so typing puts the picks
|
||||||
|
* away and picking puts the words away, and there is never a draft that means two things.
|
||||||
|
*/
|
||||||
|
data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
|
||||||
|
val settled: Boolean
|
||||||
|
get() = picked.isNotEmpty() || other.isNotBlank()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What goes back, in the order the options were offered rather than the order they were tapped:
|
||||||
|
* the reader is answering a list, and it should read back as that list.
|
||||||
|
*/
|
||||||
|
fun answers(options: List<QuestionOption>): List<String> =
|
||||||
|
if (other.isNotBlank()) listOf(other.trim())
|
||||||
|
else options.map { it.label }.filter { it in picked }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every question one tool call is waiting on, one at a time.
|
||||||
*
|
*
|
||||||
* All of it comes from the question events themselves -- what each option means, what picking it
|
* All of it comes from the question events themselves -- what each option means, what picking it
|
||||||
* would produce, whether several may be picked at once. None of it is read out of the call's own
|
* would produce, whether several may be picked at once. None of it is read out of the call's own
|
||||||
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
|
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
|
||||||
* app, where no other provider can reach it and where it drifts the first time the schema moves.
|
* app, where no other provider can reach it and where it drifts the first time the schema moves.
|
||||||
|
*
|
||||||
|
* One question on screen with arrows to the others, rather than all of them stacked. A card asking
|
||||||
|
* three questions with four options and a description each is several screens tall, so the reader
|
||||||
|
* scrolls past the question they are answering to reach the button that sends it, and never sees
|
||||||
|
* the whole of any one of them. Paged, each question is a screen and the count says how many are
|
||||||
|
* left -- which is also what makes "not all of them are answered" something the reader can act on
|
||||||
|
* rather than something to go hunting for.
|
||||||
|
*
|
||||||
|
* Nothing is sent until Submit. Answering is one act even when it is several questions: the tool
|
||||||
|
* asked them together and is waiting on all of them, and sending each as it was tapped meant the
|
||||||
|
* reader could not change their mind about the first after reading the third.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AskUserQuestionBody(
|
fun AskUserQuestionBody(
|
||||||
asks: List<TranscriptItem.QuestionCard>,
|
asks: List<TranscriptItem.QuestionCard>,
|
||||||
onAnswer: (questionId: String, answers: List<String>) -> Unit,
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// Seeded from what was already answered, so a card the reader comes back to shows their
|
||||||
|
// answers rather than an empty draft over them.
|
||||||
|
var drafts by
|
||||||
|
remember(asks.map { it.id }) {
|
||||||
|
mutableStateOf(
|
||||||
|
asks.associate { ask ->
|
||||||
|
ask.id to
|
||||||
|
Draft(
|
||||||
|
picked =
|
||||||
|
ask.answers
|
||||||
|
.filter { a -> ask.options.any { it.label == a } }
|
||||||
|
.toSet(),
|
||||||
|
other =
|
||||||
|
ask.answers
|
||||||
|
.firstOrNull { a -> ask.options.none { it.label == a } }
|
||||||
|
.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var at by remember(asks.map { it.id }) { mutableIntStateOf(0) }
|
||||||
|
var sending by remember(asks.map { it.id }) { mutableStateOf(false) }
|
||||||
|
if (asks.isEmpty()) return
|
||||||
|
val showing = asks[at.coerceIn(0, asks.size - 1)]
|
||||||
|
val outstanding = asks.filter { it.answers.isEmpty() }
|
||||||
|
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
asks.forEach { ask ->
|
if (asks.size > 1) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Question ${at + 1} of ${asks.size}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
// Disabled at the ends rather than absent, so the pair keeps its place and the
|
||||||
|
// reader can see that there is nothing further that way.
|
||||||
|
MarkButton("Previous question", { at-- }, enabled = at > 0) {
|
||||||
|
Chevron(Pointing.Left, colour = LocalContentColor.current)
|
||||||
|
}
|
||||||
|
MarkButton("Next question", { at++ }, enabled = at < asks.size - 1) {
|
||||||
|
Chevron(Pointing.Right, colour = LocalContentColor.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
AskedQuestion(
|
||||||
|
showing,
|
||||||
|
draft = drafts[showing.id] ?: Draft(),
|
||||||
|
onDraft = { drafts = drafts + (showing.id to it) },
|
||||||
|
)
|
||||||
|
if (outstanding.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
AskedQuestion(ask) { answers -> onAnswer(ask.id, answers) }
|
// Greyed until every question has an answer, because the tool is waiting on all of
|
||||||
|
// them: a submit that sent two of three would leave the third one asked and the card
|
||||||
|
// looking dealt with.
|
||||||
|
val ready = outstanding.all { drafts[it.id]?.settled == true }
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
sending = true
|
||||||
|
onAnswer(
|
||||||
|
outstanding.map { ask ->
|
||||||
|
QuestionAnswer(ask.id, (drafts[ask.id] ?: Draft()).answers(ask.options))
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
// Back to a button whatever happened. A refusal is reported by the screen
|
||||||
|
// around this, and the draft is still here to send again -- a spinner
|
||||||
|
// that never stops would be the only sign of a failure this card cannot
|
||||||
|
// describe.
|
||||||
|
sending = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = ready && !sending,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (sending) {
|
||||||
|
// In the button rather than beside it, so the row does not change height at
|
||||||
|
// the moment it is pressed.
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier.height(18.dp).width(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = LocalContentColor.current,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
if (outstanding.size > 1) "Submit ${outstanding.size} answers" else "Submit"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,9 +188,16 @@ fun AskUserQuestionBody(
|
|||||||
* The same body wherever a question appears -- on the call that asked it, or as a card of its own
|
* The same body wherever a question appears -- on the call that asked it, or as a card of its own
|
||||||
* when nothing did. A question is the same thing either way, and two renderings of it would be two
|
* when nothing did. A question is the same thing either way, and two renderings of it would be two
|
||||||
* places for an answer to go missing.
|
* places for an answer to go missing.
|
||||||
|
*
|
||||||
|
* [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here
|
||||||
|
* sends anything. An answered question ignores both and draws what was answered.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) -> Unit) {
|
fun AskedQuestion(
|
||||||
|
ask: TranscriptItem.QuestionCard,
|
||||||
|
draft: Draft,
|
||||||
|
onDraft: (Draft) -> Unit,
|
||||||
|
) {
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
ask.header?.let { header ->
|
ask.header?.let { header ->
|
||||||
// Its own line rather than beside the question, because it is a label *for* the
|
// Its own line rather than beside the question, because it is a label *for* the
|
||||||
@@ -77,16 +216,20 @@ fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) ->
|
|||||||
// very little without the three it was chosen over. Marked in the same purple that says
|
// very little without the three it was chosen over. Marked in the same purple that says
|
||||||
// "picked" while the question is still open, so it is one appearance learned once.
|
// "picked" while the question is still open, so it is one appearance learned once.
|
||||||
val answered = ask.answers.isNotEmpty()
|
val answered = ask.answers.isNotEmpty()
|
||||||
if (ask.multiSelect && !answered) {
|
// What is marked: what was answered once there is an answer, and what the finger has
|
||||||
MultipleChoice(ask.options, onAnswer)
|
// chosen until then.
|
||||||
} else if (ask.options.all { it.description == null && it.preview == null }) {
|
val marked = if (answered) ask.answers.toSet() else draft.picked
|
||||||
|
// Null once the question is answered: the options stay and stop being pressable.
|
||||||
|
val onPick: ((String) -> Unit)? =
|
||||||
|
if (answered) null else { label -> onDraft(pick(draft, label, ask.multiSelect)) }
|
||||||
|
if (ask.options.all { it.description == null && it.preview == null }) {
|
||||||
// Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words
|
// Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words
|
||||||
// do not need a card each.
|
// do not need a card each.
|
||||||
AnswerOptions(ask.options, ask.answers, onAnswer.takeUnless { answered })
|
AnswerOptions(ask.options, marked.toList(), onPick)
|
||||||
} else {
|
} else {
|
||||||
ask.options.forEach { option ->
|
ask.options.forEach { option ->
|
||||||
OptionCard(option, selected = option.label in ask.answers) {
|
OptionCard(option, selected = option.label in marked) {
|
||||||
if (!answered) onAnswer(listOf(option.label))
|
onPick?.invoke(option.label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,35 +245,24 @@ fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) ->
|
|||||||
modifier = Modifier.padding(top = 8.dp),
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!answered) OtherAnswer(onAnswer)
|
if (!answered) {
|
||||||
|
OtherAnswer(draft.other) { onDraft(Draft(other = it)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options that can be chosen together, with one button to send them.
|
* [label] added to, or taken out of, what [draft] has picked.
|
||||||
*
|
*
|
||||||
* The answer goes back as the list it is. What a provider makes of several answers is decided where
|
* A single-answer question replaces rather than accumulates, and either way picking puts any typed
|
||||||
* that provider is spoken to -- Claude Code's answers map holds a string, so they are joined there
|
* words away -- see [Draft].
|
||||||
* -- and nothing on this side has to know that.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
|
||||||
private fun MultipleChoice(options: List<QuestionOption>, onAnswer: (List<String>) -> Unit) {
|
when {
|
||||||
var chosen by remember { mutableStateOf(setOf<String>()) }
|
!multiSelect -> Draft(picked = setOf(label))
|
||||||
options.forEach { option ->
|
label in draft.picked -> Draft(picked = draft.picked - label)
|
||||||
OptionCard(option, selected = option.label in chosen) {
|
else -> Draft(picked = draft.picked + label)
|
||||||
chosen = if (option.label in chosen) chosen - option.label else chosen + option.label
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
OutlinedButton(
|
|
||||||
// In the order they were offered rather than the order they were tapped: the reader is
|
|
||||||
// answering a list, and it should read back as that list.
|
|
||||||
onClick = { onAnswer(options.map { it.label }.filter { it in chosen }) },
|
|
||||||
enabled = chosen.isNotEmpty(),
|
|
||||||
) {
|
|
||||||
Text(if (chosen.size <= 1) "Send answer" else "Send ${chosen.size} answers")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One option: what it is called, what it means, and what it would produce.
|
* One option: what it is called, what it means, and what it would produce.
|
||||||
@@ -208,20 +340,17 @@ private fun Preview(preview: String) {
|
|||||||
* cannot tell that it was ever open.
|
* cannot tell that it was ever open.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun OtherAnswer(onAnswer: (List<String>) -> Unit) {
|
private fun OtherAnswer(text: String, onText: (String) -> Unit) {
|
||||||
var text by remember { mutableStateOf("") }
|
// No Send of its own: this is one more way to answer the question, and the card's Submit is
|
||||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
// what sends it. A second send button beside the field made the shorter half of the card look
|
||||||
OutlinedTextField(
|
// like the one that finishes it.
|
||||||
value = text,
|
OutlinedTextField(
|
||||||
onValueChange = { text = it },
|
value = text,
|
||||||
label = { Text("Other") },
|
onValueChange = onText,
|
||||||
singleLine = true,
|
label = { Text("Other") },
|
||||||
modifier = Modifier.weight(1f),
|
singleLine = true,
|
||||||
)
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
TextButton(onClick = { onAnswer(listOf(text.trim())) }, enabled = text.isNotBlank()) {
|
)
|
||||||
Text("Send")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,10 +364,10 @@ private fun OtherAnswer(onAnswer: (List<String>) -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun AnswerOptions(
|
fun AnswerOptions(
|
||||||
options: List<QuestionOption>,
|
options: List<QuestionOption>,
|
||||||
/** What was chosen, marked rather than restated; empty while the question is open. */
|
/** What is chosen: the answer once there is one, and what the finger has marked until then. */
|
||||||
answers: List<String> = emptyList(),
|
answers: List<String> = emptyList(),
|
||||||
/** Null once the question is answered -- the buttons stay, and stop being buttons. */
|
/** Null once the question is answered -- the buttons stay, and stop being buttons. */
|
||||||
onAnswer: ((List<String>) -> Unit)?,
|
onPick: ((String) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
FlowRow(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -248,11 +377,11 @@ fun AnswerOptions(
|
|||||||
options.forEach { option ->
|
options.forEach { option ->
|
||||||
val taken = option.label in answers
|
val taken = option.label in answers
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { onAnswer?.invoke(listOf(option.label)) },
|
onClick = { onPick?.invoke(option.label) },
|
||||||
// Disabled rather than removed, so an answered question still shows what it
|
// Disabled rather than removed, so an answered question still shows what it
|
||||||
// offered. Material dims a disabled button's own border and label, which would
|
// offered. Material dims a disabled button's own border and label, which would
|
||||||
// take the mark with it -- both are stated here instead.
|
// take the mark with it -- both are stated here instead.
|
||||||
enabled = onAnswer != null,
|
enabled = onPick != null,
|
||||||
border =
|
border =
|
||||||
BorderStroke(
|
BorderStroke(
|
||||||
if (taken) 2.dp else 1.dp,
|
if (taken) 2.dp else 1.dp,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
// The composer's row of settings and pickers, and the menus they open. One file because the
|
||||||
|
// outline and the corner are one appearance: a control shaped like this opens a surface shaped
|
||||||
|
// like this, and a reader learns the pair once.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bordered pill: a control that can be seen without being pressed.
|
||||||
|
*
|
||||||
|
* The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at
|
||||||
|
* all until they are touched. Three bare words sitting under the message field read as a caption
|
||||||
|
* about the field rather than as three things to press, and the only way to find out otherwise was
|
||||||
|
* to press one. The outline says "control" without the weight of a filled button, which is reserved
|
||||||
|
* here for the two that act on the session (send, and start/stop).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BubbleButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = BubbleShape,
|
||||||
|
// A text button's padding rather than a filled button's 24dp: these sit three across
|
||||||
|
// under the message field, and the wider padding is what decides whether the row fits.
|
||||||
|
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fully round ends, so the control reads as a bubble rather than as a box. */
|
||||||
|
val BubbleShape: Shape = RoundedCornerShape(percent = 50)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The corner on a menu one of these opens.
|
||||||
|
*
|
||||||
|
* A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding
|
||||||
|
* ends that tall would bow its sides. This is the roundest corner that still leaves a straight edge
|
||||||
|
* beside a one-line option, which is the shortest menu here.
|
||||||
|
*/
|
||||||
|
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
|
||||||
@@ -11,14 +11,25 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** Which way a [Chevron] points. */
|
||||||
|
enum class Pointing {
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A chevron, pointing up or down.
|
* A chevron, pointing whichever of the four ways is asked for.
|
||||||
*
|
*
|
||||||
* Drawn rather than set in a font: a chevron from an icon font is one of the glyphs a system font
|
* Drawn rather than set in a font: a chevron from an icon font is one of the glyphs a system font
|
||||||
* may simply not have, and the reader who gets an empty box instead is never the one who wrote it.
|
* may simply not have, and the reader who gets an empty box instead is never the one who wrote it.
|
||||||
*
|
*
|
||||||
* One composable for both directions rather than two that differ by a minus sign -- the pair would
|
* One composable for all four directions rather than one per axis that differ by which coordinate
|
||||||
* drift, and the drift would be a bug in exactly one direction.
|
* gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one
|
||||||
|
* direction. The shape is written once in its own coordinates, where x runs across the opening and
|
||||||
|
* y runs from the open side to the tip, and [Pointing] is only a table of how those two map onto
|
||||||
|
* the box.
|
||||||
*
|
*
|
||||||
* It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole
|
* It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole
|
||||||
* of what assistive technology has to go on, and it is also the answer to "what was that arrow for"
|
* of what assistive technology has to go on, and it is also the answer to "what was that arrow for"
|
||||||
@@ -26,28 +37,36 @@ import androidx.compose.ui.unit.dp
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun Chevron(
|
fun Chevron(
|
||||||
pointingUp: Boolean,
|
pointing: Pointing,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
colour: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
colour: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
) {
|
) {
|
||||||
Canvas(modifier.width(20.dp).height(10.dp)) {
|
val sideways = pointing == Pointing.Left || pointing == Pointing.Right
|
||||||
|
Canvas(
|
||||||
|
modifier
|
||||||
|
.width(if (sideways) CHEVRON_DEPTH else CHEVRON_SPAN)
|
||||||
|
.height(if (sideways) CHEVRON_SPAN else CHEVRON_DEPTH)
|
||||||
|
) {
|
||||||
val inset = 2.dp.toPx()
|
val inset = 2.dp.toPx()
|
||||||
val point = if (pointingUp) inset else size.height - inset
|
val wide = size.width - inset
|
||||||
val ends = if (pointingUp) size.height - inset else inset
|
val tall = size.height - inset
|
||||||
|
fun at(across: Float, along: Float) =
|
||||||
|
when (pointing) {
|
||||||
|
Pointing.Up -> Offset(lerp(inset, wide, across), lerp(tall, inset, along))
|
||||||
|
Pointing.Down -> Offset(lerp(inset, wide, across), lerp(inset, tall, along))
|
||||||
|
Pointing.Left -> Offset(lerp(wide, inset, along), lerp(inset, tall, across))
|
||||||
|
Pointing.Right -> Offset(lerp(inset, wide, along), lerp(inset, tall, across))
|
||||||
|
}
|
||||||
val stroke = 2.dp.toPx()
|
val stroke = 2.dp.toPx()
|
||||||
drawLine(
|
drawLine(colour, at(0f, 0f), at(0.5f, 1f), strokeWidth = stroke, cap = StrokeCap.Round)
|
||||||
colour,
|
drawLine(colour, at(0.5f, 1f), at(1f, 0f), strokeWidth = stroke, cap = StrokeCap.Round)
|
||||||
Offset(inset, ends),
|
|
||||||
Offset(size.width / 2, point),
|
|
||||||
strokeWidth = stroke,
|
|
||||||
cap = StrokeCap.Round,
|
|
||||||
)
|
|
||||||
drawLine(
|
|
||||||
colour,
|
|
||||||
Offset(size.width / 2, point),
|
|
||||||
Offset(size.width - inset, ends),
|
|
||||||
strokeWidth = stroke,
|
|
||||||
cap = StrokeCap.Round,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun lerp(from: Float, to: Float, fraction: Float) = from + (to - from) * fraction
|
||||||
|
|
||||||
|
/** How far the chevron opens, across the direction it points. */
|
||||||
|
private val CHEVRON_SPAN = 20.dp
|
||||||
|
|
||||||
|
/** How far it reaches in the direction it points. */
|
||||||
|
private val CHEVRON_DEPTH = 10.dp
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A span of milliseconds, written the way somebody reads it.
|
||||||
|
*
|
||||||
|
* A tool's timeout arrives as `480000`, which nobody reads as eight minutes. The rule has two
|
||||||
|
* halves, because a short span and a long one are read for different things. Under a minute the
|
||||||
|
* question is "roughly how long", so only the largest unit is shown and a fraction of it carries
|
||||||
|
* the rest -- `2.5s`, `30ms`. At a minute or more the question is "how long exactly", so every unit
|
||||||
|
* that has something in it is written out -- `5d 12h 4m`. Units that are empty are left out rather
|
||||||
|
* than written as zero, since the labels say which is which and `5d 0h 4m` is only longer.
|
||||||
|
*
|
||||||
|
* Sub-second precision is dropped past a minute: nothing that takes days is measured in
|
||||||
|
* milliseconds, and carrying them would make the common case the widest one.
|
||||||
|
*/
|
||||||
|
fun formatMillis(ms: Long): String {
|
||||||
|
if (ms < 0) return "-" + formatMillis(-ms)
|
||||||
|
if (ms < 1000) return "${ms}ms"
|
||||||
|
if (ms < 60_000) {
|
||||||
|
val tenths = (ms + 50) / 100
|
||||||
|
val whole = tenths / 10
|
||||||
|
val rest = tenths % 10
|
||||||
|
return if (rest == 0L) "${whole}s" else "$whole.${rest}s"
|
||||||
|
}
|
||||||
|
val seconds = ms / 1000
|
||||||
|
val parts =
|
||||||
|
listOf(
|
||||||
|
"d" to seconds / 86_400,
|
||||||
|
"h" to seconds / 3600 % 24,
|
||||||
|
"m" to seconds / 60 % 60,
|
||||||
|
"s" to seconds % 60,
|
||||||
|
)
|
||||||
|
return parts.filter { it.second > 0 }.joinToString(" ") { "${it.second}${it.first}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [text] as a span when it is a whole number of milliseconds, and unchanged when it is not. */
|
||||||
|
fun formatMillisText(text: String): String =
|
||||||
|
text.trim().toLongOrNull()?.let { formatMillis(it) } ?: text
|
||||||
@@ -224,7 +224,7 @@ private class LiveParse(
|
|||||||
val parse = parseMarkdown(tailText)
|
val parse = parseMarkdown(tailText)
|
||||||
val all = pieces(parse)
|
val all = pieces(parse)
|
||||||
val open = (parse as? State.Success)?.let { openPiece(it, all) }
|
val open = (parse as? State.Success)?.let { openPiece(it, all) }
|
||||||
if (open == null || parse !is State.Success) {
|
if (open == null) {
|
||||||
return LiveParse(
|
return LiveParse(
|
||||||
next,
|
next,
|
||||||
frozen,
|
frozen,
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
@@ -180,13 +183,52 @@ fun GlyphButton(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
colour: Color = MaterialTheme.colorScheme.primary,
|
colour: Color = MaterialTheme.colorScheme.primary,
|
||||||
|
) {
|
||||||
|
MarkButton(label, onClick, modifier, enabled) {
|
||||||
|
Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same square, around a mark that is not a glyph.
|
||||||
|
*
|
||||||
|
* A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the
|
||||||
|
* size, spacing and touch target every other icon button on this app's headers already is -- so
|
||||||
|
* this is [GlyphButton] with the mark left to the caller rather than a second set of measurements
|
||||||
|
* beside it. The caller still owes it a [label]: nothing here draws a word.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MarkButton(
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
mark: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
|
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
|
||||||
) {
|
) {
|
||||||
Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline)
|
mark()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The square a glyph button occupies, with a spinner in it instead of a mark.
|
||||||
|
*
|
||||||
|
* For a button whose work is under way. It takes the button's whole box rather than the mark's, so
|
||||||
|
* swapping one for the other leaves everything in the row exactly where it was -- a control that
|
||||||
|
* changed the width of its header while it worked would move its neighbours at the moment somebody
|
||||||
|
* was pressing them.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GlyphSpinner(label: String, modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(Modifier.size(GLYPH_EXTENT), strokeWidth = 2.dp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.widthIn
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -97,11 +98,18 @@ private fun PendingThumbnail(
|
|||||||
// The two are told apart for the same reason the transcript's images are: one of them
|
// 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.
|
// is worth waiting for and the other never resolves.
|
||||||
null ->
|
null ->
|
||||||
Text(
|
if (failed) {
|
||||||
if (failed) "!" else "…",
|
Text(
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
"!",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// A spinner, as the transcript's images have: one appearance for "a picture
|
||||||
|
// is on its way", learned once. An ellipsis had to be read as a spinner that
|
||||||
|
// was not moving.
|
||||||
|
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
|
}
|
||||||
else ->
|
else ->
|
||||||
Image(
|
Image(
|
||||||
bitmap = image,
|
bitmap = image,
|
||||||
|
|||||||
@@ -7,13 +7,23 @@ import java.time.OffsetDateTime
|
|||||||
// arithmetic is the same in both and only the sentence around it differs, so everything here
|
// arithmetic is the same in both and only the sentence around it differs, so everything here
|
||||||
// returns the span or the state on its own and leaves the wording to the caller.
|
// returns the span or the state on its own and leaves the wording to the caller.
|
||||||
|
|
||||||
/** "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words. */
|
/**
|
||||||
fun formatSpan(until: Duration): String =
|
* "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words.
|
||||||
when {
|
*
|
||||||
until.toHours() >= 24 -> "${until.toDays()}d ${until.toHours() % 24}h"
|
* Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s
|
||||||
until.toHours() > 0 -> "${until.toHours()}h ${until.toMinutes() % 60}m"
|
* left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the
|
||||||
else -> "${until.toMinutes()}m"
|
* figure on a minute it has already spent -- so the reader watching the number decide whether to
|
||||||
|
* start something was consistently told less headroom than they had. One rule, so the session bar
|
||||||
|
* and the usage dialog cannot round a shared measurement two different ways.
|
||||||
|
*/
|
||||||
|
fun formatSpan(until: Duration): String {
|
||||||
|
val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1)
|
||||||
|
return when {
|
||||||
|
up.toHours() >= 24 -> "${up.toDays()}d ${up.toHours() % 24}h"
|
||||||
|
up.toHours() > 0 -> "${up.toHours()}h ${up.toMinutes() % 60}m"
|
||||||
|
else -> "${up.toMinutes()}m"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What is known about when a usage window ends.
|
* What is known about when a usage window ends.
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -20,6 +22,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.FilterQuality
|
import androidx.compose.ui.graphics.FilterQuality
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
@@ -94,14 +97,19 @@ fun SessionImage(
|
|||||||
val heightPx = with(LocalDensity.current) { height.roundToPx() }
|
val heightPx = with(LocalDensity.current) { height.roundToPx() }
|
||||||
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
||||||
when (val image = bitmap) {
|
when (val image = bitmap) {
|
||||||
|
// Two states, not one: an image still arriving and an image that will never arrive
|
||||||
|
// look nothing alike to a reader who can do something about the second. So one gets a
|
||||||
|
// spinner in the space the picture is about to fill, and the other gets words.
|
||||||
null ->
|
null ->
|
||||||
Text(
|
if (failed) {
|
||||||
// Two states, not one: an image still arriving and an image that will never
|
Text(
|
||||||
// arrive look nothing alike to a reader who can do something about the second.
|
"[image $ref unavailable]",
|
||||||
if (failed) "[image $ref unavailable]" else "[loading image…]",
|
style = MaterialTheme.typography.bodySmall,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
)
|
||||||
)
|
} else {
|
||||||
|
LoadingImage(height)
|
||||||
|
}
|
||||||
else ->
|
else ->
|
||||||
Image(
|
Image(
|
||||||
bitmap = image,
|
bitmap = image,
|
||||||
@@ -153,17 +161,51 @@ fun SessionImageViewer(
|
|||||||
// coming. Stated in white because this box paints its own black behind them and a
|
// coming. Stated in white because this box paints its own black behind them and a
|
||||||
// theme colour would be picked against a surface that is not there.
|
// theme colour would be picked against a surface that is not there.
|
||||||
null ->
|
null ->
|
||||||
Text(
|
if (failed) {
|
||||||
if (failed) "Image $ref is unavailable" else "Loading image…",
|
Text(
|
||||||
color = Color.White,
|
"Image $ref is unavailable",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
color = Color.White,
|
||||||
)
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// The whole dialog is the area this picture is about to fill, so the
|
||||||
|
// spinner sits in the middle of it. White for the same reason the words
|
||||||
|
// beside it are: this box paints its own black, and a theme colour would
|
||||||
|
// be chosen against a surface that is not there.
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
}
|
||||||
else -> ZoomableImage(image)
|
else -> ZoomableImage(image)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The room a picture is about to take, with a spinner in the middle of it.
|
||||||
|
*
|
||||||
|
* A square of the row's own height rather than the full width of the transcript: the height is what
|
||||||
|
* [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width
|
||||||
|
* placeholder would promise a picture wider than most of them turn out to be. Square is the closest
|
||||||
|
* thing to "the size of it" that can be drawn before knowing.
|
||||||
|
*
|
||||||
|
* Tinted, so the reader can see that something is being kept for a picture. That is also what
|
||||||
|
* distinguishes it from the failure beside it, which is words on the ordinary surface.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LoadingImage(height: Dp) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(height)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(Modifier.size(LOADING_SPINNER), strokeWidth = 2.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small enough to sit inside the thumbnail's square without filling it. */
|
||||||
|
private val LOADING_SPINNER = 24.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Four lines of the body style the transcript is set in.
|
* Four lines of the body style the transcript is set in.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.text.selection.rememberSelectionState
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -71,6 +72,8 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.PopupProperties
|
import androidx.compose.ui.window.PopupProperties
|
||||||
@@ -267,13 +270,17 @@ fun SessionScreen(
|
|||||||
// leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed
|
// leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed
|
||||||
// message. See `Drafts.kt` for why this one piece of state is the device's rather than the
|
// message. See `Drafts.kt` for why this one piece of state is the device's rather than the
|
||||||
// server's.
|
// server's.
|
||||||
var input by remember(summary.id) { mutableStateOf(loadDraft(context, summary.id)) }
|
var input by remember(summary.id) { mutableStateOf(atEnd(loadDraft(context, summary.id))) }
|
||||||
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
|
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
|
||||||
// makes the session re-read the whole conversation, which is worth asking about first.
|
// makes the session re-read the whole conversation, which is worth asking about first.
|
||||||
var pendingModel by remember { mutableStateOf<String?>(null) }
|
var pendingModel by remember { mutableStateOf<String?>(null) }
|
||||||
// What was last taken from the command suggestions, so the list closes behind it; see
|
// What was last taken from the command suggestions, so the list closes behind it; see
|
||||||
// [CommandSuggestions] at its call site.
|
// [CommandSuggestions] at its call site.
|
||||||
var picked by remember { mutableStateOf<String?>(null) }
|
var picked by remember { mutableStateOf<String?>(null) }
|
||||||
|
// The transcript's selection, held here rather than inside [TranscriptList] because the rows
|
||||||
|
// have to ask whether anything is selected before they treat a tap as their own -- see
|
||||||
|
// [expanding].
|
||||||
|
val selection = rememberSelectionState()
|
||||||
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
||||||
// Which runs of adjacent tool calls are open. Keyed by the first call's
|
// Which runs of adjacent tool calls are open. Keyed by the first call's
|
||||||
// id, so a group survives more calls arriving after it.
|
// id, so a group survives more calls arriving after it.
|
||||||
@@ -485,6 +492,30 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A press on the transcript that would open or close something, and the one thing every such
|
||||||
|
* press has to check first.
|
||||||
|
*
|
||||||
|
* The transcript is one [SelectionContainer], so a reader who has selected some text puts that
|
||||||
|
* selection away by tapping -- and the tap that does it lands on whatever card the text is
|
||||||
|
* drawn in. Left alone, that card takes it as a press of its own: the reader clears a selection
|
||||||
|
* and the tool call under their finger collapses, which is a second thing happening for a
|
||||||
|
* gesture that meant one. So a press with a selection outstanding spends itself clearing it and
|
||||||
|
* does nothing else, and the press after that -- with nothing selected -- opens or closes as
|
||||||
|
* usual.
|
||||||
|
*
|
||||||
|
* Every open and close on this screen goes through here rather than each writing the check,
|
||||||
|
* since which card the finger lands on is not something the reader chose and the rule cannot
|
||||||
|
* hold for only some of them.
|
||||||
|
*/
|
||||||
|
fun expanding(toggle: () -> Unit) {
|
||||||
|
if (selection.selectedTexts.isNotEmpty()) {
|
||||||
|
selection.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toggle()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changes a row's height while the end the reader touched stays where it is.
|
* Changes a row's height while the end the reader touched stays where it is.
|
||||||
*
|
*
|
||||||
@@ -505,7 +536,7 @@ fun SessionScreen(
|
|||||||
* comes from the row's own detector ([LastTouch]), written by the gesture that is about to run
|
* comes from the row's own detector ([LastTouch]), written by the gesture that is about to run
|
||||||
* [toggle].
|
* [toggle].
|
||||||
*/
|
*/
|
||||||
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) {
|
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) = expanding {
|
||||||
if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key
|
if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key
|
||||||
toggle()
|
toggle()
|
||||||
}
|
}
|
||||||
@@ -1027,7 +1058,7 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Opens or closes one memory note, wherever it is drawn; see [MemoryNote]. */
|
/** Opens or closes one memory note, wherever it is drawn; see [MemoryNote]. */
|
||||||
fun toggleMemory(text: String) {
|
fun toggleMemory(text: String) = expanding {
|
||||||
openMemories = if (text in openMemories) openMemories - text else openMemories + text
|
openMemories = if (text in openMemories) openMemories - text else openMemories + text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,7 +1074,7 @@ fun SessionScreen(
|
|||||||
* the reader tapped keeps its place because the list keeps it, not because a measurement
|
* the reader tapped keeps its place because the list keeps it, not because a measurement
|
||||||
* corrected it afterwards.
|
* corrected it afterwards.
|
||||||
*/
|
*/
|
||||||
fun togglePeer(seq: Long) {
|
fun togglePeer(seq: Long) = expanding {
|
||||||
expandedNotes = if (seq in expandedNotes) expandedNotes - seq else expandedNotes + seq
|
expandedNotes = if (seq in expandedNotes) expandedNotes - seq else expandedNotes + seq
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1063,8 +1094,21 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends every answer a question card handed over, and says when the last of them has settled.
|
||||||
|
*
|
||||||
|
* All of them in one go because a card asks its questions together and the tool is waiting on
|
||||||
|
* all of them; the completion is what turns the card's spinner back into a button, whether the
|
||||||
|
* server took them or refused.
|
||||||
|
*/
|
||||||
|
fun answerAll(answers: List<QuestionAnswer>, onSettled: () -> Unit) {
|
||||||
|
act(onDone = onSettled) {
|
||||||
|
answers.forEach { answerQuestion(settings, summary.id, it.questionId, it.answers) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun send() {
|
fun send() {
|
||||||
val text = input.trim()
|
val text = input.text.trim()
|
||||||
val attachments = pendingAttachments
|
val attachments = pendingAttachments
|
||||||
if (text.isEmpty() && attachments.isEmpty()) return
|
if (text.isEmpty() && attachments.isEmpty()) return
|
||||||
// A command is not a message: it is an instruction to the session about itself, and one
|
// A command is not a message: it is an instruction to the session about itself, and one
|
||||||
@@ -1072,7 +1116,7 @@ fun SessionScreen(
|
|||||||
// turn ends and says so, which is where its waiting bubble comes from -- so nothing is
|
// turn ends and says so, which is where its waiting bubble comes from -- so nothing is
|
||||||
// held here, and there is no local guess to correct when the answer arrives.
|
// held here, and there is no local guess to correct when the answer arrives.
|
||||||
if (text.startsWith("/") && attachments.isEmpty()) {
|
if (text.startsWith("/") && attachments.isEmpty()) {
|
||||||
input = ""
|
input = atEnd("")
|
||||||
saveDraft(context, summary.id, "")
|
saveDraft(context, summary.id, "")
|
||||||
// The one command with a visible effect outside the transcript, applied when the
|
// The one command with a visible effect outside the transcript, applied when the
|
||||||
// server has accepted it rather than when it was typed: the name is this app's own
|
// server has accepted it rather than when it was typed: the name is this app's own
|
||||||
@@ -1087,7 +1131,7 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
input = ""
|
input = atEnd("")
|
||||||
saveDraft(context, summary.id, "")
|
saveDraft(context, summary.id, "")
|
||||||
pendingAttachments = emptyList()
|
pendingAttachments = emptyList()
|
||||||
// Nothing is added here. The server says what is waiting -- it emits `messageQueued`
|
// Nothing is added here. The server says what is waiting -- it emits `messageQueued`
|
||||||
@@ -1132,14 +1176,15 @@ fun SessionScreen(
|
|||||||
onShareTaken()
|
onShareTaken()
|
||||||
incoming.uris.forEach(::attach)
|
incoming.uris.forEach(::attach)
|
||||||
incoming.text?.let { shared ->
|
incoming.text?.let { shared ->
|
||||||
input = if (input.isBlank()) shared else input + "\n" + shared
|
input = atEnd(if (input.text.isBlank()) shared else input.text + "\n" + shared)
|
||||||
saveDraft(context, summary.id, input)
|
saveDraft(context, summary.id, input.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One poll for this machine's limits, read by the two things that show them: the bar under
|
// One poll for the machines' limits, read by everything on this screen that reports them:
|
||||||
// the header, and the colour of the button that opens the dialog.
|
// the bar under the header, the colour of the button that opens the dialog, and the dialog.
|
||||||
val usage = rememberSessionUsage(settings, summary.setup)
|
val usageFeed = rememberUsageFeed(settings)
|
||||||
|
val usage = usageFeed.forSetup(summary.setup)
|
||||||
RecordFrames()
|
RecordFrames()
|
||||||
var usageOpen by remember { mutableStateOf(false) }
|
var usageOpen by remember { mutableStateOf(false) }
|
||||||
var settingsOpen by remember { mutableStateOf(false) }
|
var settingsOpen by remember { mutableStateOf(false) }
|
||||||
@@ -1341,6 +1386,7 @@ fun SessionScreen(
|
|||||||
units = units,
|
units = units,
|
||||||
state = listState,
|
state = listState,
|
||||||
moreHistory = moreHistory,
|
moreHistory = moreHistory,
|
||||||
|
selection = selection,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
|
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
|
||||||
below = {
|
below = {
|
||||||
@@ -1476,16 +1522,7 @@ fun SessionScreen(
|
|||||||
else expandedTools + id
|
else expandedTools + id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAnswer = { questionId, answers ->
|
onAnswer = ::answerAll,
|
||||||
act {
|
|
||||||
answerQuestion(
|
|
||||||
settings,
|
|
||||||
summary.id,
|
|
||||||
questionId,
|
|
||||||
answers,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
image = { ref ->
|
image = { ref ->
|
||||||
SessionImage(
|
SessionImage(
|
||||||
settings,
|
settings,
|
||||||
@@ -1535,16 +1572,7 @@ fun SessionScreen(
|
|||||||
else expandedTools + item.id
|
else expandedTools + item.id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAnswer = { questionId, answers ->
|
onAnswer = ::answerAll,
|
||||||
act {
|
|
||||||
answerQuestion(
|
|
||||||
settings,
|
|
||||||
summary.id,
|
|
||||||
questionId,
|
|
||||||
answers,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
image = { ref ->
|
image = { ref ->
|
||||||
SessionImage(
|
SessionImage(
|
||||||
settings,
|
settings,
|
||||||
@@ -1555,16 +1583,7 @@ fun SessionScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
is TranscriptItem.QuestionCard ->
|
is TranscriptItem.QuestionCard ->
|
||||||
QuestionRow(item) { answers ->
|
QuestionRow(item, ::answerAll)
|
||||||
act {
|
|
||||||
answerQuestion(
|
|
||||||
settings,
|
|
||||||
summary.id,
|
|
||||||
item.id,
|
|
||||||
answers,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is TranscriptItem.ErrorMsg ->
|
is TranscriptItem.ErrorMsg ->
|
||||||
Text(
|
Text(
|
||||||
item.message,
|
item.message,
|
||||||
@@ -1658,7 +1677,7 @@ fun SessionScreen(
|
|||||||
.semantics { contentDescription = "Jump to latest" },
|
.semantics { contentDescription = "Jump to latest" },
|
||||||
) {
|
) {
|
||||||
Chevron(
|
Chevron(
|
||||||
pointingUp = false,
|
Pointing.Down,
|
||||||
colour = MaterialTheme.colorScheme.onSurface,
|
colour = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
)
|
)
|
||||||
@@ -1720,9 +1739,13 @@ fun SessionScreen(
|
|||||||
// has nothing left to offer, in front of the box they are about to send from.
|
// has nothing left to offer, in front of the box they are about to send from.
|
||||||
// Held by what was picked rather than by a flag, so typing anything else brings
|
// Held by what was picked rather than by a flag, so typing anything else brings
|
||||||
// the list back without needing a second thing to reset.
|
// the list back without needing a second thing to reset.
|
||||||
commands = if (input == picked) emptyList() else suggestedCommands(input),
|
commands = if (input.text == picked) emptyList() else suggestedCommands(input.text),
|
||||||
onPick = { command ->
|
onPick = { command ->
|
||||||
input = command.typed()
|
// At the end of what was inserted, which is where the reader carries on
|
||||||
|
// typing: a command with an argument is put in the box half-written, and a
|
||||||
|
// cursor left at the front makes the next keystroke the first character of
|
||||||
|
// "/rename" rather than of the name.
|
||||||
|
input = atEnd(command.typed())
|
||||||
picked = command.typed()
|
picked = command.typed()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1747,7 +1770,7 @@ fun SessionScreen(
|
|||||||
value = input,
|
value = input,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
input = it
|
input = it
|
||||||
saveDraft(context, summary.id, it)
|
saveDraft(context, summary.id, it.text)
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
// No longer "(+image)": the images are on screen above this, and a placeholder
|
// No longer "(+image)": the images are on screen above this, and a placeholder
|
||||||
@@ -1764,13 +1787,14 @@ fun SessionScreen(
|
|||||||
var attaching by remember { mutableStateOf(false) }
|
var attaching by remember { mutableStateOf(false) }
|
||||||
Box {
|
Box {
|
||||||
// Just "+". The count it used to carry was standing in for showing them.
|
// Just "+". The count it used to carry was standing in for showing them.
|
||||||
TextButton(onClick = { attaching = true }) { Text("+") }
|
BubbleButton(onClick = { attaching = true }) { Text("+") }
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = attaching,
|
expanded = attaching,
|
||||||
onDismissRequest = { attaching = false },
|
onDismissRequest = { attaching = false },
|
||||||
// See PickerButton: without this the menu opens a status bar's
|
// See PickerButton: without this the menu opens a status bar's
|
||||||
// height away from the button in an edge-to-edge activity.
|
// height away from the button in an edge-to-edge activity.
|
||||||
properties = PopupProperties(clippingEnabled = false),
|
properties = PopupProperties(clippingEnabled = false),
|
||||||
|
shape = BubbleMenuShape,
|
||||||
) {
|
) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Photo") },
|
text = { Text("Photo") },
|
||||||
@@ -1883,7 +1907,7 @@ fun SessionScreen(
|
|||||||
// not hidden, for the reason the button beside it is always here.
|
// not hidden, for the reason the button beside it is always here.
|
||||||
Button(
|
Button(
|
||||||
onClick = { send() },
|
onClick = { send() },
|
||||||
enabled = input.isNotBlank() || pendingAttachments.isNotEmpty(),
|
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
|
||||||
colors = actionButtonColors(if (running) queueColor else sendColor),
|
colors = actionButtonColors(if (running) queueColor else sendColor),
|
||||||
) {
|
) {
|
||||||
Glyph(
|
Glyph(
|
||||||
@@ -1902,7 +1926,7 @@ fun SessionScreen(
|
|||||||
// open is the screen's business rather than any row's. See [SessionImageViewer].
|
// open is the screen's business rather than any row's. See [SessionImageViewer].
|
||||||
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
|
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
|
||||||
if (usageOpen) {
|
if (usageOpen) {
|
||||||
UsageDialog(settings = settings, onDismiss = { usageOpen = false })
|
UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false })
|
||||||
}
|
}
|
||||||
if (settingsOpen) {
|
if (settingsOpen) {
|
||||||
SessionSettingsDialog(
|
SessionSettingsDialog(
|
||||||
@@ -2097,10 +2121,18 @@ private data class QueuedMessage(
|
|||||||
* always going to re-read the conversation -- the switch adds nothing to that bill. And a session
|
* always going to re-read the conversation -- the switch adds nothing to that bill. And a session
|
||||||
* reporting zero context is holding nothing, which is what `/clear` leaves behind.
|
* reporting zero context is holding nothing, which is what `/clear` leaves behind.
|
||||||
*
|
*
|
||||||
* Where the figure is *unknown* rather than zero the fallback is what it always was: whether
|
* Where the figure is *unknown* rather than zero the fallback is whether anything has been said
|
||||||
* anything has been said at all. Unknown is not nothing, and treating it as nothing would drop the
|
* **since the last clear**. Unknown is not nothing, and treating it as nothing would drop the
|
||||||
* warning on exactly the sessions -- an import, a fresh reattach -- where nobody has measured yet
|
* warning on exactly the sessions -- an import, a fresh reattach -- where nobody has measured yet
|
||||||
* and the conversation may be enormous.
|
* and the conversation may be enormous. But a clear is the one case that makes the whole loaded
|
||||||
|
* transcript stop counting: it leaves the conversation on screen and takes it out of the session's
|
||||||
|
* context, and the server reports the context as unmeasured afterwards rather than as zero, since
|
||||||
|
* nobody has counted what is left. So the reading that used the whole list warned about dropping a
|
||||||
|
* cache that the clear had already dropped -- on the screen where a reader has just deliberately
|
||||||
|
* emptied the thing being warned about.
|
||||||
|
*
|
||||||
|
* With no clear anywhere in what is loaded this is the old reading exactly, which is the
|
||||||
|
* conservative answer for a clear that happened further back than the loaded window.
|
||||||
*/
|
*/
|
||||||
private fun worthWarningAbout(
|
private fun worthWarningAbout(
|
||||||
status: String,
|
status: String,
|
||||||
@@ -2110,7 +2142,7 @@ private fun worthWarningAbout(
|
|||||||
when {
|
when {
|
||||||
status == "exited" -> false
|
status == "exited" -> false
|
||||||
contextTokens != null -> contextTokens > 0
|
contextTokens != null -> contextTokens > 0
|
||||||
else -> items.isNotEmpty()
|
else -> items.asReversed().takeWhile { it !is TranscriptItem.ClearedNote }.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2272,17 +2304,29 @@ private fun SessionStatusRow(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun QuestionRow(
|
private fun QuestionRow(
|
||||||
question: TranscriptItem.QuestionCard,
|
question: TranscriptItem.QuestionCard,
|
||||||
onAnswer: (List<String>) -> Unit,
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
// The same body the questions on a tool call get: one question is the same
|
// The same body the questions on a tool call get, down to the submit button: one
|
||||||
// thing whether or not something else asked it.
|
// question is the same thing whether or not something asked it, and two renderings of
|
||||||
AskedQuestion(question, onAnswer)
|
// it would be two places for an answer to go missing.
|
||||||
|
AskUserQuestionBody(listOf(question), onAnswer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [text] in the message box, with the cursor after it.
|
||||||
|
*
|
||||||
|
* Everything that puts words in the box without the reader typing them goes through here: a
|
||||||
|
* restored draft, a share arriving from another app, a slash command taken from the suggestions.
|
||||||
|
* All three leave the reader mid-sentence, and all three used to leave the cursor at whatever
|
||||||
|
* offset it happened to hold -- which for a box that has never been focused is the very start, so
|
||||||
|
* picking `/rename` and typing put the name in front of the command.
|
||||||
|
*/
|
||||||
|
private fun atEnd(text: String) = TextFieldValue(text, TextRange(text.length))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long after a menu closes a press on its own button still counts as the press that closed it.
|
* How long after a menu closes a press on its own button still counts as the press that closed it.
|
||||||
*
|
*
|
||||||
@@ -2314,7 +2358,7 @@ private fun PickerButton(current: String, options: List<String>, onPick: (String
|
|||||||
// as a new one.
|
// as a new one.
|
||||||
var closedAt by remember { mutableLongStateOf(0L) }
|
var closedAt by remember { mutableLongStateOf(0L) }
|
||||||
Box {
|
Box {
|
||||||
TextButton(
|
BubbleButton(
|
||||||
onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true }
|
onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true }
|
||||||
) {
|
) {
|
||||||
// One line, truncated rather than wrapped: this sits in a row
|
// One line, truncated rather than wrapped: this sits in a row
|
||||||
@@ -2353,6 +2397,7 @@ private fun PickerButton(current: String, options: List<String>, onPick: (String
|
|||||||
closedAt = SystemClock.uptimeMillis()
|
closedAt = SystemClock.uptimeMillis()
|
||||||
},
|
},
|
||||||
properties = PopupProperties(focusable = false, clippingEnabled = false),
|
properties = PopupProperties(focusable = false, clippingEnabled = false),
|
||||||
|
shape = BubbleMenuShape,
|
||||||
) {
|
) {
|
||||||
options.forEach { option ->
|
options.forEach { option ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -55,28 +56,63 @@ sealed class SessionUsage {
|
|||||||
private const val REFRESH_MS = 60_000L
|
private const val REFRESH_MS = 60_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One machine's rate limits, polled.
|
* One poll of every machine's limits, and the handle to ask again.
|
||||||
*
|
*
|
||||||
* Hoisted out of [SessionUsageBar] because two things on a session's screen show this same answer
|
* A screen shows this answer in more than one place -- the bar under the session header, the colour
|
||||||
* -- the bar, and the colour of the button that opens the usage dialog. Fetching it twice would
|
* of the button beside it, and the dialog that button opens -- and each of those used to fetch for
|
||||||
* cost two round trips to say one thing, and the two copies would disagree for up to a minute at a
|
* itself. Two fetches say one thing twice and then disagree about it: the bar's copy can be a whole
|
||||||
* time, which is the interface contradicting itself about a number somebody is deciding on.
|
* refresh interval old when the dialog opens with a fresh one, so the header read 42% while the
|
||||||
|
* screen over it read 47%, about a number somebody is deciding on. One feed per screen, and
|
||||||
|
* [refresh] moves both.
|
||||||
|
*/
|
||||||
|
class UsageFeed(
|
||||||
|
val snapshots: LoadState<List<UsageSnapshot>>,
|
||||||
|
/**
|
||||||
|
* A fetch is outstanding. Only ever true over an answer already shown; see [rememberUsageFeed].
|
||||||
|
*/
|
||||||
|
val refreshing: Boolean,
|
||||||
|
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
||||||
|
val refresh: () -> Unit,
|
||||||
|
) {
|
||||||
|
/** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */
|
||||||
|
fun forSetup(setup: String): SessionUsage =
|
||||||
|
when (val state = snapshots) {
|
||||||
|
is LoadState.Loading -> SessionUsage.Waiting
|
||||||
|
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
||||||
|
is LoadState.Loaded -> usageFor(state.value, setup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one poll of the machines' rate limits, polled and refreshable.
|
||||||
|
*
|
||||||
|
* Hoisted out of [SessionUsageBar] because everything on a session's screen that reports on usage
|
||||||
|
* has to be reporting the same measurement; see [UsageFeed].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberSessionUsage(settings: ServerSettings, setup: String): SessionUsage {
|
fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
|
||||||
var usage by remember(setup) { mutableStateOf<SessionUsage>(SessionUsage.Waiting) }
|
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
|
||||||
LaunchedEffect(setup) {
|
var refreshing by remember { mutableStateOf(true) }
|
||||||
|
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh
|
||||||
|
// also resets the countdown to the next one rather than leaving one due immediately after.
|
||||||
|
var asked by remember { mutableIntStateOf(0) }
|
||||||
|
LaunchedEffect(asked) {
|
||||||
while (true) {
|
while (true) {
|
||||||
usage =
|
refreshing = true
|
||||||
|
// Replaces the answer only once the next one is in hand: dropping back to Loading
|
||||||
|
// would blank a bar somebody is reading for the length of a round trip, and what was
|
||||||
|
// on screen is still the last thing the machine actually said.
|
||||||
|
snapshots =
|
||||||
try {
|
try {
|
||||||
usageFor(withContext(Dispatchers.IO) { fetchUsage(settings) }, setup)
|
LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) })
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
SessionUsage.Unavailable(e.message ?: "couldn't reach the backend")
|
LoadState.failed(e)
|
||||||
}
|
}
|
||||||
|
refreshing = false
|
||||||
delay(REFRESH_MS)
|
delay(REFRESH_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return usage
|
return remember(snapshots, refreshing) { UsageFeed(snapshots, refreshing) { asked++ } }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.draggable
|
||||||
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dragging the screen to the right to step back to the one behind it.
|
||||||
|
*
|
||||||
|
* The platform's own back gesture is a swipe from the very edge, and only from there; on a phone
|
||||||
|
* held in one hand the way back from a session is either that narrow strip or the arrow at the top
|
||||||
|
* left, which is the far corner from the thumb. This is the same movement from anywhere on the
|
||||||
|
* screen.
|
||||||
|
*
|
||||||
|
* **It loses every argument.** The gesture is a plain horizontal [draggable] on the outside of the
|
||||||
|
* screen, so anything inside that wants horizontal drags has already taken them by the time this
|
||||||
|
* would see them: pointer events reach the innermost node first, and a drag a child has consumed
|
||||||
|
* never crosses this modifier's touch slop. That is what keeps a wide code fence, a table scrolled
|
||||||
|
* sideways or a text selection working -- they are the components the reader meant, and this is
|
||||||
|
* only what is left over. Vertical drags are not its orientation, so the transcript scrolls
|
||||||
|
* untouched.
|
||||||
|
*
|
||||||
|
* The screen follows the finger rather than jumping at the end, because a gesture with no feedback
|
||||||
|
* cannot be aborted: the reader has to be able to see it starting and change their mind. Released
|
||||||
|
* short of [SWIPE_BACK_TRAVEL] it slides back and nothing happens. Right rather than left, and only
|
||||||
|
* right, since there is nothing forward of these screens to go to.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.swipeBack(onBack: () -> Unit): Modifier {
|
||||||
|
val offset = remember { Animatable(0f) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val travel = with(LocalDensity.current) { SWIPE_BACK_TRAVEL.toPx() }
|
||||||
|
return draggable(
|
||||||
|
state =
|
||||||
|
rememberDraggableState { delta ->
|
||||||
|
// Rightward only: a leftward drag stays at zero rather than lifting the
|
||||||
|
// screen off its left edge, which would look like a gesture that does
|
||||||
|
// something and does not.
|
||||||
|
scope.launch { offset.snapTo((offset.value + delta).coerceAtLeast(0f)) }
|
||||||
|
},
|
||||||
|
orientation = Orientation.Horizontal,
|
||||||
|
onDragStopped = {
|
||||||
|
if (offset.value >= travel) {
|
||||||
|
onBack()
|
||||||
|
// Straight back rather than animated: the screen this was moving is being
|
||||||
|
// replaced, and animating it home first would show the old one sliding back
|
||||||
|
// into place after the new one had arrived.
|
||||||
|
offset.snapTo(0f)
|
||||||
|
} else {
|
||||||
|
offset.animateTo(0f)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// Read inside the block, so following the finger is a draw-phase change and costs no
|
||||||
|
// recomposition of the screen being dragged.
|
||||||
|
.graphicsLayer { translationX = offset.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far the screen has to be pulled for letting go to mean "back" rather than "never mind". */
|
||||||
|
private val SWIPE_BACK_TRAVEL: Dp = 96.dp
|
||||||
@@ -25,7 +25,9 @@ private object Mocha {
|
|||||||
val Sky = Color(0xFF89DCEB)
|
val Sky = Color(0xFF89DCEB)
|
||||||
val Blue = Color(0xFF89B4FA)
|
val Blue = Color(0xFF89B4FA)
|
||||||
val Lavender = Color(0xFFB4BEFE)
|
val Lavender = Color(0xFFB4BEFE)
|
||||||
|
val Pink = Color(0xFFF5C2E7)
|
||||||
val Text = Color(0xFFCDD6F4)
|
val Text = Color(0xFFCDD6F4)
|
||||||
|
val Subtext1 = Color(0xFFBAC2DE)
|
||||||
val Subtext0 = Color(0xFFA6ADC8)
|
val Subtext0 = Color(0xFFA6ADC8)
|
||||||
val Overlay0 = Color(0xFF6C7086)
|
val Overlay0 = Color(0xFF6C7086)
|
||||||
val Surface2 = Color(0xFF585B70)
|
val Surface2 = Color(0xFF585B70)
|
||||||
@@ -216,6 +218,45 @@ fun catppuccinSyntax(): SyntaxPalette =
|
|||||||
mark = Mocha.Sky,
|
mark = Mocha.Sky,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sixteen terminal colours, for what a Bash tool call printed; see [AnsiPalette].
|
||||||
|
*
|
||||||
|
* Catppuccin publishes its own ANSI mapping and this is it, rather than the eight accents picked by
|
||||||
|
* eye: a program printing in "colour 4" means blue, and which blue is a decision the palette has
|
||||||
|
* already made for every other blue on the screen.
|
||||||
|
*
|
||||||
|
* Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which
|
||||||
|
* is upstream's choice and not an omission here. A program that uses bright red to mean something
|
||||||
|
* other than red is relying on a distinction its own terminal may not draw either.
|
||||||
|
*
|
||||||
|
* The background is [rawSurface] because that is what a tool's output is drawn on, and reverse
|
||||||
|
* video needs to know what it is reversing against.
|
||||||
|
*/
|
||||||
|
fun ansiPalette(): AnsiPalette =
|
||||||
|
AnsiPalette(
|
||||||
|
colours =
|
||||||
|
listOf(
|
||||||
|
Mocha.Surface1,
|
||||||
|
Mocha.Red,
|
||||||
|
Mocha.Green,
|
||||||
|
Mocha.Yellow,
|
||||||
|
Mocha.Blue,
|
||||||
|
Mocha.Pink,
|
||||||
|
Mocha.Teal,
|
||||||
|
Mocha.Subtext1,
|
||||||
|
Mocha.Surface2,
|
||||||
|
Mocha.Red,
|
||||||
|
Mocha.Green,
|
||||||
|
Mocha.Yellow,
|
||||||
|
Mocha.Blue,
|
||||||
|
Mocha.Pink,
|
||||||
|
Mocha.Teal,
|
||||||
|
Mocha.Subtext0,
|
||||||
|
),
|
||||||
|
foreground = Mocha.Text,
|
||||||
|
background = Mocha.Crust,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A link. Blue is what a link is on every Catppuccin surface, and the one colour to leave alone.
|
* A link. Blue is what a link is on every Catppuccin surface, and the one colour to leave alone.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ data class ToolInput(
|
|||||||
/** The tool's own one-line summary, when it wrote one. */
|
/** The tool's own one-line summary, when it wrote one. */
|
||||||
val description: String?,
|
val description: String?,
|
||||||
/**
|
/**
|
||||||
* How long the call may take, as the tool expressed it. Shown apart because it is a limit on
|
* How long the call may take, in the largest units it fits ([formatMillis]). Shown apart
|
||||||
* the call rather than part of what the call does.
|
* because it is a limit on the call rather than part of what the call does.
|
||||||
*/
|
*/
|
||||||
val timeout: String?,
|
val timeout: String?,
|
||||||
/** Everything else, as `name: value` lines. Never dropped. */
|
/** Everything else, as `name: value` lines. Never dropped. */
|
||||||
@@ -83,7 +83,7 @@ fun parseToolInput(tool: String, input: String): ToolInput {
|
|||||||
val description = DESCRIPTIONS.firstNotNullOfOrNull {
|
val description = DESCRIPTIONS.firstNotNullOfOrNull {
|
||||||
json.optString(it).takeIf { v -> v.isNotBlank() }
|
json.optString(it).takeIf { v -> v.isNotBlank() }
|
||||||
}
|
}
|
||||||
val timeout = json.optString("timeout").takeIf { it.isNotBlank() }
|
val timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
|
||||||
val rest =
|
val rest =
|
||||||
json
|
json
|
||||||
.keys()
|
.keys()
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -174,7 +177,7 @@ fun ToolGroup(
|
|||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
isToolExpanded: (String) -> Boolean,
|
isToolExpanded: (String) -> Boolean,
|
||||||
onToolToggle: (String) -> Unit,
|
onToolToggle: (String) -> Unit,
|
||||||
onAnswer: (questionId: String, answers: List<String>) -> Unit,
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||||
image: @Composable (String) -> Unit,
|
image: @Composable (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val heading = "Called ${group.calls.size} tools"
|
val heading = "Called ${group.calls.size} tools"
|
||||||
@@ -254,7 +257,7 @@ private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Chevron(pointingUp = true, colour = colour)
|
Chevron(Pointing.Up, colour = colour)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +309,7 @@ fun ToolCard(
|
|||||||
tool: TranscriptItem.ToolRun,
|
tool: TranscriptItem.ToolRun,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onAnswer: (questionId: String, answers: List<String>) -> Unit,
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||||
image: @Composable (String) -> Unit = {},
|
image: @Composable (String) -> Unit = {},
|
||||||
/** Square where this card faces another in a group; see [connectedShape]. */
|
/** Square where this card faces another in a group; see [connectedShape]. */
|
||||||
shape: Shape = CardDefaults.shape,
|
shape: Shape = CardDefaults.shape,
|
||||||
@@ -380,9 +383,17 @@ fun ToolCard(
|
|||||||
// face it was written for: this is column-aligned far more often than it is
|
// face it was written for: this is column-aligned far more often than it is
|
||||||
// prose -- a directory listing, a diff, a table of numbers -- and a
|
// prose -- a directory listing, a diff, a table of numbers -- and a
|
||||||
// proportional font silently destroys the alignment that carried the meaning.
|
// proportional font silently destroys the alignment that carried the meaning.
|
||||||
|
//
|
||||||
|
// Its terminal styling applied and the rest of the escapes taken out, since
|
||||||
|
// what a shell prints is written for a terminal: colour is often the whole of
|
||||||
|
// what a diff or a test run is saying, and the sequences that carry it are
|
||||||
|
// unreadable drawn verbatim. Remembered against the text, so a card that is
|
||||||
|
// open through a scroll parses once. See [ansiStyled].
|
||||||
|
val palette = remember { ansiPalette() }
|
||||||
|
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
|
||||||
RawBlock(Modifier.padding(top = 2.dp)) {
|
RawBlock(Modifier.padding(top = 2.dp)) {
|
||||||
Text(
|
Text(
|
||||||
tool.output,
|
styled,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
)
|
)
|
||||||
@@ -398,9 +409,7 @@ fun ToolCard(
|
|||||||
if (tool.tool == ASK_USER_QUESTION) {
|
if (tool.tool == ASK_USER_QUESTION) {
|
||||||
AskUserQuestionBody(tool.asks, onAnswer)
|
AskUserQuestionBody(tool.asks, onAnswer)
|
||||||
} else {
|
} else {
|
||||||
tool.asks.forEach { ask ->
|
tool.asks.forEach { ask -> PermissionAsk(ask, onAnswer) }
|
||||||
PermissionAsk(ask) { answers -> onAnswer(ask.id, answers) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,7 +423,17 @@ fun ToolCard(
|
|||||||
* the ask can stand alone, and here it does not have to -- the card above is showing exactly that.
|
* the ask can stand alone, and here it does not have to -- the card above is showing exactly that.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) -> Unit) {
|
private fun PermissionAsk(
|
||||||
|
ask: TranscriptItem.QuestionCard,
|
||||||
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||||
|
) {
|
||||||
|
// What was pressed, before the answer has been round-tripped. Two bare words with no submit
|
||||||
|
// step -- unlike a question card, where the answer is several choices and worth reviewing --
|
||||||
|
// so the press has to be its own acknowledgement or the row sits unchanged for a round trip
|
||||||
|
// and reads as having missed the tap. Cleared when the request settles: by then either the
|
||||||
|
// answer is in `ask.answers` and the mark stands on a measurement, or it failed and the
|
||||||
|
// buttons come back rather than leaving a decision marked that nothing recorded.
|
||||||
|
var pressed by remember(ask.id) { mutableStateOf<String?>(null) }
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
ask.prompt.substringBefore('\n'),
|
ask.prompt.substringBefore('\n'),
|
||||||
@@ -425,7 +444,18 @@ private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List<Stri
|
|||||||
// [AskedQuestion], which is the same rule on the question card. A permission is where it
|
// [AskedQuestion], which is the same rule on the question card. A permission is where it
|
||||||
// matters most: "Answered: Deny" alone does not say that Allow was the alternative, and
|
// matters most: "Answered: Deny" alone does not say that Allow was the alternative, and
|
||||||
// whether a tool was allowed or refused is the thing a reader comes back to this row for.
|
// whether a tool was allowed or refused is the thing a reader comes back to this row for.
|
||||||
AnswerOptions(ask.options, ask.answers, onAnswer.takeIf { ask.answers.isEmpty() })
|
val settled = ask.answers.isNotEmpty()
|
||||||
|
AnswerOptions(
|
||||||
|
ask.options,
|
||||||
|
if (settled) ask.answers else listOfNotNull(pressed),
|
||||||
|
onPick =
|
||||||
|
if (settled || pressed != null) null
|
||||||
|
else
|
||||||
|
{ label ->
|
||||||
|
pressed = label
|
||||||
|
onAnswer(listOf(QuestionAnswer(ask.id, listOf(label)))) { pressed = null }
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionState
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -44,17 +45,23 @@ import androidx.compose.ui.unit.dp
|
|||||||
* drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep
|
* drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep
|
||||||
* their tap handlers: selection is a long press, and the container passes an ordinary click through
|
* their tap handlers: selection is a long press, and the container passes an ordinary click through
|
||||||
* to the card under it.
|
* to the card under it.
|
||||||
|
*
|
||||||
|
* [selection] is the container's own state, held by the caller rather than made here, because the
|
||||||
|
* rows have to be able to ask whether anything is selected before they act on a tap -- a tap whose
|
||||||
|
* job is to put a selection away is not also a tap on the card under it. See the caller's
|
||||||
|
* `expanding`.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TranscriptList(
|
fun TranscriptList(
|
||||||
units: List<TranscriptUnit>,
|
units: List<TranscriptUnit>,
|
||||||
state: LazyListState,
|
state: LazyListState,
|
||||||
moreHistory: Boolean,
|
moreHistory: Boolean,
|
||||||
|
selection: SelectionState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
below: @Composable () -> Unit,
|
below: @Composable () -> Unit,
|
||||||
unit: @Composable (TranscriptUnit) -> Unit,
|
unit: @Composable (TranscriptUnit) -> Unit,
|
||||||
) {
|
) {
|
||||||
SelectionContainer {
|
SelectionContainer(selection) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = state,
|
state = state,
|
||||||
reverseLayout = true,
|
reverseLayout = true,
|
||||||
|
|||||||
@@ -15,20 +15,11 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Window bars for the account's rate limits, with reset times.
|
* Window bars for the account's rate limits, with reset times.
|
||||||
@@ -41,23 +32,7 @@ import kotlinx.coroutines.withContext
|
|||||||
* handles that itself.
|
* handles that itself.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) {
|
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var state by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
|
|
||||||
|
|
||||||
fun refresh() {
|
|
||||||
state = LoadState.Loading
|
|
||||||
scope.launch {
|
|
||||||
state =
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchUsage(settings)) }
|
|
||||||
} catch (e: ApiException) {
|
|
||||||
LoadState.failed(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(Unit) { refresh() }
|
|
||||||
|
|
||||||
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the
|
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the
|
||||||
// gaps between its title, its content and its buttons at sizes meant for a sentence of prose
|
// gaps between its title, its content and its buttons at sizes meant for a sentence of prose
|
||||||
// and a decision; this is a dense read-out, and those gaps left a band of empty dialog above
|
// and a decision; this is a dense read-out, and those gaps left a band of empty dialog above
|
||||||
@@ -84,7 +59,14 @@ fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) {
|
|||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
GlyphButton(REFRESH_GLYPH, "Refresh usage", { refresh() })
|
// A spinner in the button's place while the answer is on its way, since the
|
||||||
|
// numbers under it stay put during a refresh -- without it, pressing refresh
|
||||||
|
// over an unchanged read-out looks like a button that does nothing.
|
||||||
|
if (feed.refreshing) {
|
||||||
|
GlyphSpinner("Refreshing usage")
|
||||||
|
} else {
|
||||||
|
GlyphButton(REFRESH_GLYPH, "Refresh usage", feed.refresh)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
// Scrolls rather than being trimmed: a machine can report any number of windows
|
// Scrolls rather than being trimmed: a machine can report any number of windows
|
||||||
@@ -92,7 +74,7 @@ fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) {
|
|||||||
// running out of room is silent. `fill = false` so a short read-out keeps a short
|
// running out of room is silent. `fill = false` so a short read-out keeps a short
|
||||||
// dialog instead of stretching to the window.
|
// dialog instead of stretching to the window.
|
||||||
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
|
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
|
||||||
UsageBody(state)
|
UsageBody(feed.snapshots)
|
||||||
}
|
}
|
||||||
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
||||||
Text("Close")
|
Text("Close")
|
||||||
|
|||||||
+11
-1
@@ -1177,8 +1177,18 @@ async fn set_cwd(
|
|||||||
setup.name
|
setup.name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
// Stored in the short form, so the one path that is kept is the one
|
||||||
|
// the phone will draw -- rather than storing `/home/bob/…` and
|
||||||
|
// abbreviating it again at each place it is shown, which is two
|
||||||
|
// representations of one directory and a second rule to keep in step.
|
||||||
|
// Only where the setup runs here; see `setups::shorten_home`.
|
||||||
|
let stored = if setup.ssh.is_none() {
|
||||||
|
crate::setups::shorten_home(&cwd)
|
||||||
|
} else {
|
||||||
|
cwd.clone()
|
||||||
|
};
|
||||||
manager
|
manager
|
||||||
.set_session_cwd(&id, PathBuf::from(&cwd))
|
.set_session_cwd(&id, PathBuf::from(&stored))
|
||||||
.map_err(bad_request)?;
|
.map_err(bad_request)?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,7 +441,14 @@ pub async fn directory_exists(transport: &Transport, path: &str) -> bool {
|
|||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let launch = Launch::new("test", vec!["-d".to_string(), path.to_string()], None);
|
// Asked by *entering* it rather than by `test -d <path>`, because the
|
||||||
|
// question this is standing in for is "can a session start here" and
|
||||||
|
// because a path is only expanded where it is a working directory --
|
||||||
|
// `~/repos/ai-app` as an argument stays four literal characters on
|
||||||
|
// both transports (`ssh::quote_path`, `ssh::expand_home`), so the old
|
||||||
|
// form answered "no such directory" about every home-relative path
|
||||||
|
// somebody typed.
|
||||||
|
let launch = Launch::new("true", Vec::new(), Some(std::path::Path::new(path)));
|
||||||
transport.capture(&launch).await.is_ok()
|
transport.capture(&launch).await.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+119
-4
@@ -1217,9 +1217,16 @@ impl SessionManager {
|
|||||||
// change, and as an error if it cannot. The config above is a
|
// change, and as an error if it cannot. The config above is a
|
||||||
// different question -- what to launch this session with next
|
// different question -- what to launch this session with next
|
||||||
// time -- and it is answered by the request.
|
// time -- and it is answered by the request.
|
||||||
session.ask("change how much it asks", |driver| {
|
announce_or_ask(
|
||||||
driver.set_permission_mode(mode)
|
session,
|
||||||
});
|
&self.data_dir.join(id),
|
||||||
|
Event::Settings {
|
||||||
|
model: None,
|
||||||
|
permission_mode: Some(mode.to_string()),
|
||||||
|
},
|
||||||
|
"change how much it asks",
|
||||||
|
|driver| driver.set_permission_mode(mode),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1323,7 +1330,16 @@ impl SessionManager {
|
|||||||
if let Some(session) = inner.live.get(id) {
|
if let Some(session) = inner.live.get(id) {
|
||||||
// See `set_session_permission_mode`: the driver reports what
|
// See `set_session_permission_mode`: the driver reports what
|
||||||
// it is set to, this only asks.
|
// it is set to, this only asks.
|
||||||
session.ask("change model", |driver| driver.set_model(model));
|
announce_or_ask(
|
||||||
|
session,
|
||||||
|
&self.data_dir.join(id),
|
||||||
|
Event::Settings {
|
||||||
|
model: Some(model.to_string()),
|
||||||
|
permission_mode: None,
|
||||||
|
},
|
||||||
|
"change model",
|
||||||
|
|driver| driver.set_model(model),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1685,6 +1701,37 @@ impl SessionManager {
|
|||||||
/// to the one process, and every line it wrote was then translated once per
|
/// to the one process, and every line it wrote was then translated once per
|
||||||
/// reader -- three presses put three interleaved copies of one reply on
|
/// reader -- three presses put three interleaved copies of one reply on
|
||||||
/// screen.
|
/// screen.
|
||||||
|
/// A setting change: asked of the driver, or announced as the session's own
|
||||||
|
/// where there is no process for a driver to speak for.
|
||||||
|
///
|
||||||
|
/// The pair that [`LiveSession::ask`] cannot serve. Everything else it
|
||||||
|
/// covers genuinely needs a process -- a message sent to a session that is
|
||||||
|
/// not running has nowhere to go -- but a setting is held in the config as
|
||||||
|
/// well, and a session with nothing running *is* what the config says: the
|
||||||
|
/// value is applied the moment it next starts. So `ask`'s "this session has
|
||||||
|
/// no process running, so it can't change model" was true of the driver and
|
||||||
|
/// false of the session, and it left the phone showing the old model over a
|
||||||
|
/// config that had already taken the new one, with no way to change it
|
||||||
|
/// short of starting the session first.
|
||||||
|
///
|
||||||
|
/// `Exited` and nothing else, for the reason [`start_if_exited`] gives:
|
||||||
|
/// `Unknown` means nobody could find out, and a session whose process may
|
||||||
|
/// well be reading its fifo is one to ask rather than to answer for.
|
||||||
|
fn announce_or_ask(
|
||||||
|
session: &LiveSession,
|
||||||
|
session_dir: &Path,
|
||||||
|
settled: Event,
|
||||||
|
what: &str,
|
||||||
|
request: impl FnOnce(&dyn Driver),
|
||||||
|
) {
|
||||||
|
let status = corrected(*session.shared.status.lock().unwrap(), session_dir);
|
||||||
|
if status == SessionStatus::Exited {
|
||||||
|
let _ = session.sink.send(settled);
|
||||||
|
} else {
|
||||||
|
session.ask(what, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn corrected(status: SessionStatus, session_dir: &Path) -> SessionStatus {
|
fn corrected(status: SessionStatus, session_dir: &Path) -> SessionStatus {
|
||||||
if status == SessionStatus::Exited && adoptable(session_dir) {
|
if status == SessionStatus::Exited && adoptable(session_dir) {
|
||||||
SessionStatus::Unknown
|
SessionStatus::Unknown
|
||||||
@@ -3613,6 +3660,74 @@ mod tests {
|
|||||||
std::fs::write(path, rewritten).expect("write transcript");
|
std::fs::write(path, rewritten).expect("write transcript");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A setting changed on a session with nothing running is recorded as
|
||||||
|
/// the session's own, rather than refused because there is no driver.
|
||||||
|
///
|
||||||
|
/// The config already took it -- that is what a session starts with next
|
||||||
|
/// time -- so the refusal was about the driver while reading as though
|
||||||
|
/// it were about the session, and the phone went on showing the old
|
||||||
|
/// model over a stored new one. Asked of a session told it has exited,
|
||||||
|
/// since the rule is about the status rather than about which driver it
|
||||||
|
/// is; the same is true of the permission mode, which is why they go
|
||||||
|
/// through one function.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_stopped_session_takes_a_setting_for_the_next_time_it_starts() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let config_path = dir.path().join("config.ron");
|
||||||
|
let data_dir = dir.path().join("sessions");
|
||||||
|
seed_echo_only(&config_path);
|
||||||
|
let manager = SessionManager::new(
|
||||||
|
config_path.clone(),
|
||||||
|
data_dir.clone(),
|
||||||
|
data_dir.join("models"),
|
||||||
|
)
|
||||||
|
.expect("manager");
|
||||||
|
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||||
|
let session = manager.session(&info.id).expect("live session");
|
||||||
|
let mut rx = session.subscribe();
|
||||||
|
|
||||||
|
let _ = session.sink.send(Event::Status {
|
||||||
|
state: SessionStatus::Exited,
|
||||||
|
});
|
||||||
|
collect_until(&mut rx, |event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
Event::Status {
|
||||||
|
state: SessionStatus::Exited
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.set_session_model(&info.id, "haiku")
|
||||||
|
.expect("store the model");
|
||||||
|
collect_until(
|
||||||
|
&mut rx,
|
||||||
|
|event| matches!(event, Event::Settings { model: Some(model), .. } if model == "haiku"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
manager.sessions()[0].model.as_deref(),
|
||||||
|
Some("haiku"),
|
||||||
|
"stored, so the next start uses it"
|
||||||
|
);
|
||||||
|
|
||||||
|
manager
|
||||||
|
.set_session_permission_mode(&info.id, "plan")
|
||||||
|
.expect("store the mode");
|
||||||
|
collect_until(&mut rx, |event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
Event::Settings {
|
||||||
|
permission_mode: Some(mode),
|
||||||
|
..
|
||||||
|
} if mode == "plan"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Stopping and starting a session is about its *process*, and the two
|
/// Stopping and starting a session is about its *process*, and the two
|
||||||
/// refusals are the whole of what keeps starting one from becoming a
|
/// refusals are the whole of what keeps starting one from becoming a
|
||||||
/// second one on the same conversation.
|
/// second one on the same conversation.
|
||||||
|
|||||||
@@ -160,6 +160,32 @@ pub fn tidy(value: &str) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The inverse of [`tidy`]'s expansion: an absolute path under this
|
||||||
|
/// machine's home, written back as `~/…`.
|
||||||
|
///
|
||||||
|
/// So that a working directory reads on a phone the way it is written by
|
||||||
|
/// hand. `/home/bob/repos/ai-app-2` is most of a line on that screen and
|
||||||
|
/// almost all of it is the part nobody is reading.
|
||||||
|
///
|
||||||
|
/// Applied only to paths on **this** machine. `$HOME` here says nothing
|
||||||
|
/// about the home directory of a machine reached over ssh, so a remote
|
||||||
|
/// path is stored exactly as it was typed -- where a `~` somebody wrote
|
||||||
|
/// stays a `~`, and the remote shell is what expands it
|
||||||
|
/// (`ssh::quote_path`).
|
||||||
|
pub fn shorten_home(path: &str) -> String {
|
||||||
|
let Some(home) = std::env::home_dir() else {
|
||||||
|
return path.to_string();
|
||||||
|
};
|
||||||
|
let home = home.to_string_lossy();
|
||||||
|
// The separator has to be part of the match, or `/home/bobby` would be
|
||||||
|
// read as a path inside `/home/bob`.
|
||||||
|
match path.strip_prefix(home.as_ref()) {
|
||||||
|
Some("") => "~".to_string(),
|
||||||
|
Some(rest) if rest.starts_with('/') => format!("~{rest}"),
|
||||||
|
_ => path.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs a launch to completion and returns its stdout.
|
/// Runs a launch to completion and returns its stdout.
|
||||||
impl Transport {
|
impl Transport {
|
||||||
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
||||||
@@ -182,3 +208,29 @@ impl Transport {
|
|||||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The two halves of a home-relative path, which have to be inverses:
|
||||||
|
/// what is stored is what the phone draws, and what the phone sends
|
||||||
|
/// back is what a process is started in.
|
||||||
|
#[test]
|
||||||
|
fn a_home_path_shortens_and_expands_back() {
|
||||||
|
let Some(home) = std::env::home_dir() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let full = home.join("repos/ai-app-2");
|
||||||
|
let full = full.to_string_lossy();
|
||||||
|
assert_eq!(shorten_home(&full), "~/repos/ai-app-2");
|
||||||
|
assert_eq!(shorten_home(&home.to_string_lossy()), "~");
|
||||||
|
assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref()));
|
||||||
|
|
||||||
|
// Not a prefix match on the characters: a sibling directory whose
|
||||||
|
// name merely starts with the home directory's is not inside it.
|
||||||
|
let sibling = format!("{}-backup/notes", home.to_string_lossy());
|
||||||
|
assert_eq!(shorten_home(&sibling), sibling);
|
||||||
|
assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts");
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-2
@@ -10,7 +10,7 @@
|
|||||||
//! `~/.ssh/config`, agents, and jump hosts all keep working and there is
|
//! `~/.ssh/config`, agents, and jump hosts all keep working and there is
|
||||||
//! only one place to configure connections (PLAN.md, rule 23).
|
//! only one place to configure connections (PLAN.md, rule 23).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::config::SshConfig;
|
use crate::config::SshConfig;
|
||||||
@@ -50,7 +50,15 @@ pub fn command(
|
|||||||
let mut command = Command::new(program);
|
let mut command = Command::new(program);
|
||||||
command.args(args);
|
command.args(args);
|
||||||
if let Some(cwd) = cwd {
|
if let Some(cwd) = cwd {
|
||||||
command.current_dir(cwd);
|
// Expanded here for the same reason `quote_path` expands it on
|
||||||
|
// the far side: a working directory typed as `~/repos/ai-app`
|
||||||
|
// has to mean the same thing whichever machine runs it. There
|
||||||
|
// is no shell in this branch, so nothing else would --
|
||||||
|
// `current_dir` would be handed the literal one-character
|
||||||
|
// directory `~`, and the session would fail to start with an
|
||||||
|
// error naming a path nobody typed. Only the cwd, matching
|
||||||
|
// the remote side, where arguments stay literal.
|
||||||
|
command.current_dir(expand_home(cwd));
|
||||||
}
|
}
|
||||||
return command;
|
return command;
|
||||||
};
|
};
|
||||||
@@ -101,6 +109,30 @@ fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
|
|||||||
script
|
script
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A path with a leading `~` replaced by this machine's home directory.
|
||||||
|
///
|
||||||
|
/// The local half of the rule [`quote_path`] states for the remote one, and
|
||||||
|
/// the two are deliberately the same shape: the tilde is expanded, `~user`
|
||||||
|
/// is not (there is no portable expansion for another account's home), and
|
||||||
|
/// nothing else in the path gains a meaning. A machine with no home
|
||||||
|
/// directory at all leaves the path alone, which fails with the operating
|
||||||
|
/// system's own message rather than with a guess.
|
||||||
|
pub(crate) fn expand_home(path: &Path) -> PathBuf {
|
||||||
|
let Some(rest) = path.to_str().and_then(|p| {
|
||||||
|
if p == "~" {
|
||||||
|
Some("")
|
||||||
|
} else {
|
||||||
|
p.strip_prefix("~/")
|
||||||
|
}
|
||||||
|
}) else {
|
||||||
|
return path.to_path_buf();
|
||||||
|
};
|
||||||
|
match std::env::home_dir() {
|
||||||
|
Some(home) => home.join(rest),
|
||||||
|
None => path.to_path_buf(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Quotes a path, expanding a leading `~` and nothing else.
|
/// Quotes a path, expanding a leading `~` and nothing else.
|
||||||
///
|
///
|
||||||
/// [`quote`] is right for every other word crossing to the remote side and
|
/// [`quote`] is right for every other word crossing to the remote side and
|
||||||
@@ -247,6 +279,31 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same character, on the transport with no shell to expand it.
|
||||||
|
///
|
||||||
|
/// The local branch runs the program directly, so a working directory
|
||||||
|
/// of `~/repos/ai-app` would reach `current_dir` as the literal
|
||||||
|
/// one-character directory `~` -- a session that fails to start,
|
||||||
|
/// naming a path nobody typed. The two transports have to agree about
|
||||||
|
/// what a tilde means or a path is only portable by accident.
|
||||||
|
#[test]
|
||||||
|
fn a_local_cwd_expands_its_tilde_the_same_way() {
|
||||||
|
let Some(home) = std::env::home_dir() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
expand_home(Path::new("~/repos/ai-app")),
|
||||||
|
home.join("repos/ai-app")
|
||||||
|
);
|
||||||
|
assert_eq!(expand_home(Path::new("~")), home);
|
||||||
|
// Leading only, and its own segment only -- `quote_path`'s rule.
|
||||||
|
assert_eq!(expand_home(Path::new("/tmp/~/x")), Path::new("/tmp/~/x"));
|
||||||
|
assert_eq!(expand_home(Path::new("~user/x")), Path::new("~user/x"));
|
||||||
|
|
||||||
|
let local = command(None, "claude", &args(["-p"]), Some(Path::new("~/work")));
|
||||||
|
assert_eq!(local.get_current_dir(), Some(home.join("work").as_path()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shell_metacharacters_cross_as_data_not_syntax() {
|
fn shell_metacharacters_cross_as_data_not_syntax() {
|
||||||
// Expanding $HOME must not open a door for anything else: the rest
|
// Expanding $HOME must not open a door for anything else: the rest
|
||||||
|
|||||||
Reference in new issue
Block a user