diff --git a/TODO.md b/TODO.md index b93385f..74e8cc1 100644 --- a/TODO.md +++ b/TODO.md @@ -3,19 +3,12 @@ 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. -## 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 -- [ ] Tapping an expanded agent message card should close it. -- [ ] Tapping to deselect text should not toggle card expansion. +- [ ] Tapping an expanded agent message card should close it. — a tap that + 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 itself works — only the highlight is missing). - [ ] Bash logs should apply colour and the other basic text escape sequences, @@ -37,22 +30,12 @@ one in place when it turns out to need a decision. 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 - [ ] Autocompact belongs in session settings; empty disables it, which is the default. - [ ] Changing the model on a stopped provider should be possible, stored, and applied the next time it starts. +- [ ] Switching models must not warn when there is no context for the warning to + matter — e.g. straight after a clear. -## 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. diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml index 2958fb5..3a4512d 100644 --- a/app/androidApp/src/main/AndroidManifest.xml +++ b/app/androidApp/src/main/AndroidManifest.xml @@ -37,12 +37,20 @@ + 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. --> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt new file mode 100644 index 0000000..2da613f --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt @@ -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) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt new file mode 100644 index 0000000..bc18ad8 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt @@ -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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index a95074f..37981ba 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -225,7 +225,7 @@ private class LiveParse( val parse = parseMarkdown(tailText) val all = pieces(parse) val open = (parse as? State.Success)?.let { openPiece(it, all) } - if (open == null || parse !is State.Success) { + if (open == null) { return LiveParse( next, frozen, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt index 92068c1..66093c7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt @@ -1,10 +1,13 @@ package com.example.aiapp +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription @@ -190,6 +193,24 @@ fun GlyphButton( } } +/** + * 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) + } +} + /** * One icon, drawn as text. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt index b6252e6..1db6b3a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt @@ -7,13 +7,23 @@ import java.time.OffsetDateTime // 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. -/** "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words. */ -fun formatSpan(until: Duration): String = - when { - until.toHours() >= 24 -> "${until.toDays()}d ${until.toHours() % 24}h" - until.toHours() > 0 -> "${until.toHours()}h ${until.toMinutes() % 60}m" - else -> "${until.toMinutes()}m" +/** + * "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words. + * + * Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s + * left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the + * 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. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index ea2438c..767f8a9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.selection.rememberSelectionState import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button 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.semantics.contentDescription 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.unit.dp 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 // message. See `Drafts.kt` for why this one piece of state is the device's rather than the // 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 // makes the session re-read the whole conversation, which is worth asking about first. var pendingModel by remember { mutableStateOf(null) } // What was last taken from the command suggestions, so the list closes behind it; see // [CommandSuggestions] at its call site. var picked by remember { mutableStateOf(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()) } // Which runs of adjacent tool calls are open. Keyed by the first call's // 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. * @@ -505,7 +536,7 @@ fun SessionScreen( * comes from the row's own detector ([LastTouch]), written by the gesture that is about to run * [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 toggle() } @@ -1027,7 +1058,7 @@ fun SessionScreen( } /** 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 } @@ -1043,7 +1074,7 @@ fun SessionScreen( * the reader tapped keeps its place because the list keeps it, not because a measurement * corrected it afterwards. */ - fun togglePeer(seq: Long) { + fun togglePeer(seq: Long) = expanding { expandedNotes = if (seq in expandedNotes) expandedNotes - seq else expandedNotes + seq } @@ -1064,7 +1095,7 @@ fun SessionScreen( } fun send() { - val text = input.trim() + val text = input.text.trim() val attachments = pendingAttachments if (text.isEmpty() && attachments.isEmpty()) return // A command is not a message: it is an instruction to the session about itself, and one @@ -1072,7 +1103,7 @@ fun SessionScreen( // 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. if (text.startsWith("/") && attachments.isEmpty()) { - input = "" + input = atEnd("") saveDraft(context, summary.id, "") // 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 @@ -1087,7 +1118,7 @@ fun SessionScreen( } return } - input = "" + input = atEnd("") saveDraft(context, summary.id, "") pendingAttachments = emptyList() // Nothing is added here. The server says what is waiting -- it emits `messageQueued` @@ -1132,14 +1163,15 @@ fun SessionScreen( onShareTaken() incoming.uris.forEach(::attach) incoming.text?.let { shared -> - input = if (input.isBlank()) shared else input + "\n" + shared - saveDraft(context, summary.id, input) + input = atEnd(if (input.text.isBlank()) shared else input.text + "\n" + shared) + saveDraft(context, summary.id, input.text) } } - // One poll for this machine's limits, read by the two things that show them: the bar under - // the header, and the colour of the button that opens the dialog. - val usage = rememberSessionUsage(settings, summary.setup) + // One poll for the machines' limits, read by everything on this screen that reports them: + // the bar under the header, the colour of the button that opens the dialog, and the dialog. + val usageFeed = rememberUsageFeed(settings) + val usage = usageFeed.forSetup(summary.setup) RecordFrames() var usageOpen by remember { mutableStateOf(false) } var settingsOpen by remember { mutableStateOf(false) } @@ -1341,6 +1373,7 @@ fun SessionScreen( units = units, state = listState, moreHistory = moreHistory, + selection = selection, modifier = Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() }, below = { @@ -1720,9 +1753,13 @@ fun SessionScreen( // 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 // 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 -> - 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() }, ) @@ -1747,7 +1784,7 @@ fun SessionScreen( value = input, onValueChange = { input = it - saveDraft(context, summary.id, it) + saveDraft(context, summary.id, it.text) }, modifier = Modifier.fillMaxWidth(), // No longer "(+image)": the images are on screen above this, and a placeholder @@ -1764,13 +1801,14 @@ fun SessionScreen( var attaching by remember { mutableStateOf(false) } Box { // Just "+". The count it used to carry was standing in for showing them. - TextButton(onClick = { attaching = true }) { Text("+") } + BubbleButton(onClick = { attaching = true }) { Text("+") } DropdownMenu( expanded = attaching, onDismissRequest = { attaching = false }, // See PickerButton: without this the menu opens a status bar's // height away from the button in an edge-to-edge activity. properties = PopupProperties(clippingEnabled = false), + shape = BubbleMenuShape, ) { DropdownMenuItem( text = { Text("Photo") }, @@ -1883,7 +1921,7 @@ fun SessionScreen( // not hidden, for the reason the button beside it is always here. Button( onClick = { send() }, - enabled = input.isNotBlank() || pendingAttachments.isNotEmpty(), + enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(), colors = actionButtonColors(if (running) queueColor else sendColor), ) { Glyph( @@ -1902,7 +1940,7 @@ fun SessionScreen( // open is the screen's business rather than any row's. See [SessionImageViewer]. fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } } if (usageOpen) { - UsageDialog(settings = settings, onDismiss = { usageOpen = false }) + UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false }) } if (settingsOpen) { SessionSettingsDialog( @@ -2283,6 +2321,17 @@ private fun QuestionRow( } } +/** + * [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. * @@ -2314,7 +2363,7 @@ private fun PickerButton(current: String, options: List, onPick: (String // as a new one. var closedAt by remember { mutableLongStateOf(0L) } Box { - TextButton( + BubbleButton( onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true } ) { // One line, truncated rather than wrapped: this sits in a row @@ -2353,6 +2402,7 @@ private fun PickerButton(current: String, options: List, onPick: (String closedAt = SystemClock.uptimeMillis() }, properties = PopupProperties(focusable = false, clippingEnabled = false), + shape = BubbleMenuShape, ) { options.forEach { option -> DropdownMenuItem( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt index ada615f..c2b3695 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -55,28 +56,63 @@ sealed class SessionUsage { 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 - * -- the bar, and the colour of the button that opens the usage dialog. Fetching it twice would - * cost two round trips to say one thing, and the two copies would disagree for up to a minute at a - * time, which is the interface contradicting itself about a number somebody is deciding on. + * A screen shows this answer in more than one place -- the bar under the session header, the colour + * of the button beside it, and the dialog that button opens -- and each of those used to fetch for + * itself. Two fetches say one thing twice and then disagree about it: the bar's copy can be a whole + * 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>, + /** + * 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 -fun rememberSessionUsage(settings: ServerSettings, setup: String): SessionUsage { - var usage by remember(setup) { mutableStateOf(SessionUsage.Waiting) } - LaunchedEffect(setup) { +fun rememberUsageFeed(settings: ServerSettings): UsageFeed { + var snapshots by remember { mutableStateOf>>(LoadState.Loading) } + 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) { - 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 { - usageFor(withContext(Dispatchers.IO) { fetchUsage(settings) }, setup) + LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) }) } catch (e: ApiException) { - SessionUsage.Unavailable(e.message ?: "couldn't reach the backend") + LoadState.failed(e) } + refreshing = false delay(REFRESH_MS) } } - return usage + return remember(snapshots, refreshing) { UsageFeed(snapshots, refreshing) { asked++ } } } /** diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt index 4ed473f..440b785 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt @@ -31,8 +31,8 @@ data class ToolInput( /** The tool's own one-line summary, when it wrote one. */ val description: String?, /** - * How long the call may take, as the tool expressed it. Shown apart because it is a limit on - * the call rather than part of what the call does. + * How long the call may take, in the largest units it fits ([formatMillis]). Shown apart + * because it is a limit on the call rather than part of what the call does. */ val timeout: String?, /** Everything else, as `name: value` lines. Never dropped. */ @@ -84,7 +84,7 @@ fun parseToolInput(tool: String, input: String): ToolInput { val description = DESCRIPTIONS.firstNotNullOfOrNull { 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 = json .keys() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt index 8618784..35ec86a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.text.selection.SelectionState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable 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 * their tap handlers: selection is a long press, and the container passes an ordinary click through * 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 fun TranscriptList( units: List, state: LazyListState, moreHistory: Boolean, + selection: SelectionState, modifier: Modifier = Modifier, below: @Composable () -> Unit, unit: @Composable (TranscriptUnit) -> Unit, ) { - SelectionContainer { + SelectionContainer(selection) { LazyColumn( state = state, reverseLayout = true, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt index 39ecf07..b2bf44d 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt @@ -15,20 +15,11 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog 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. @@ -41,23 +32,7 @@ import kotlinx.coroutines.withContext * handles that itself. */ @Composable -fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) { - val scope = rememberCoroutineScope() - var state by remember { mutableStateOf>>(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() } - +fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) { // 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 // 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, 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)) // 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 // dialog instead of stretching to the window. Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { - UsageBody(state) + UsageBody(feed.snapshots) } TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("Close") diff --git a/server/src/routes.rs b/server/src/routes.rs index 114a5b5..9ba6659 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -1177,8 +1177,18 @@ async fn set_cwd( 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 - .set_session_cwd(&id, PathBuf::from(&cwd)) + .set_session_cwd(&id, PathBuf::from(&stored)) .map_err(bad_request)?; Ok(StatusCode::NO_CONTENT) } diff --git a/server/src/session/import.rs b/server/src/session/import.rs index 122176a..103c92e 100644 --- a/server/src/session/import.rs +++ b/server/src/session/import.rs @@ -441,7 +441,14 @@ pub async fn directory_exists(transport: &Transport, path: &str) -> bool { if path.is_empty() { return false; } - let launch = Launch::new("test", vec!["-d".to_string(), path.to_string()], None); + // Asked by *entering* it rather than by `test -d `, 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() } diff --git a/server/src/setups.rs b/server/src/setups.rs index b3d3a82..52d2088 100644 --- a/server/src/setups.rs +++ b/server/src/setups.rs @@ -160,6 +160,32 @@ pub fn tidy(value: &str) -> Option { }) } +/// 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. impl Transport { pub async fn capture(&self, launch: &Launch) -> Result { @@ -182,3 +208,29 @@ impl Transport { 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"); + } +} diff --git a/server/src/ssh.rs b/server/src/ssh.rs index 890852d..e47a511 100644 --- a/server/src/ssh.rs +++ b/server/src/ssh.rs @@ -10,7 +10,7 @@ //! `~/.ssh/config`, agents, and jump hosts all keep working and there is //! only one place to configure connections (PLAN.md, rule 23). -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use crate::config::SshConfig; @@ -50,7 +50,15 @@ pub fn command( let mut command = Command::new(program); command.args(args); 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; }; @@ -101,6 +109,30 @@ fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String { 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. /// /// [`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] fn shell_metacharacters_cross_as_data_not_syntax() { // Expanding $HOME must not open a door for anything else: the rest