Read a timeout in units, share one usage answer, and let a tilde mean home

The composer's settings row is outlined bubbles opening round menus, and
the message box is a TextFieldValue so anything put into it without being
typed -- a draft, a share, a slash command -- leaves the cursor at the end.

A tap that puts a text selection away no longer also collapses the card the
text was drawn in: every open and close on the session screen goes through
one guard that spends such a press on the selection.

The usage bar and the usage dialog were two polls of one measurement and
disagreed for up to a minute at a time; they are one feed now, and the
countdown rounds up to the minute in the one place both read.

A working directory typed as ~/repos/ai-app was four literal characters on
the local transport and as an argument on both, so the existence check
refused every home-relative path. It is checked by entering the directory
now, expanded for a local spawn the way the remote shell expands it, and
stored short so the phone draws what somebody would write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-03 19:56:36 -04:00
1 parent 2970a6cf9a
commit 32b2a4871a
16 files changed
+414 -99

No files matched your search

+6 -23
View File
@@ -3,19 +3,12 @@
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).
- [ ] Bash logs should apply colour and the other basic text escape sequences, - [ ] 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. stacking them all in a row.
- [ ] Submit stays greyed out until every question is answered. - [ ] 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.
- [ ] Changing the model on a stopped provider should be possible, stored, and - [ ] Changing the model on a stopped provider should be possible, stored, and
applied the next time it starts. 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.
+10 -2
View File
@@ -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,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)
@@ -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
@@ -225,7 +225,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
@@ -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. * One icon, drawn as text.
* *
@@ -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.
@@ -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
} }
@@ -1064,7 +1095,7 @@ fun SessionScreen(
} }
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 +1103,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 +1118,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 +1163,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 +1373,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 = {
@@ -1720,9 +1753,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 +1784,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 +1801,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 +1921,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 +1940,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(
@@ -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. * 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<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 +2402,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++ } }
} }
/** /**
@@ -31,8 +31,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. */
@@ -84,7 +84,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()
@@ -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
View File
@@ -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)
} }
+8 -1
View File
@@ -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()
} }
+52
View File
@@ -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
View File
@@ -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