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:
1 parent
2970a6cf9a
commit
32b2a4871a
16 files changed
+414
-99
No files matched your search
@@ -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.
|
||||
@@ -37,12 +37,20 @@
|
||||
<profileable android:shell="true" tools:targetApi="q" />
|
||||
<!-- adjustResize (not the system's default pan): the layout handles
|
||||
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
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:windowSoftInputMode="adjustResize|stateUnchanged"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<intent-filter>
|
||||
<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 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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<String?>(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<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>()) }
|
||||
// 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<String>, 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<String>, onPick: (String
|
||||
closedAt = SystemClock.uptimeMillis()
|
||||
},
|
||||
properties = PopupProperties(focusable = false, clippingEnabled = false),
|
||||
shape = BubbleMenuShape,
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
|
||||
@@ -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<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
|
||||
fun rememberSessionUsage(settings: ServerSettings, setup: String): SessionUsage {
|
||||
var usage by remember(setup) { mutableStateOf<SessionUsage>(SessionUsage.Waiting) }
|
||||
LaunchedEffect(setup) {
|
||||
fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
|
||||
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(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++ } }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<TranscriptUnit>,
|
||||
state: LazyListState,
|
||||
moreHistory: Boolean,
|
||||
selection: SelectionState,
|
||||
modifier: Modifier = Modifier,
|
||||
below: @Composable () -> Unit,
|
||||
unit: @Composable (TranscriptUnit) -> Unit,
|
||||
) {
|
||||
SelectionContainer {
|
||||
SelectionContainer(selection) {
|
||||
LazyColumn(
|
||||
state = state,
|
||||
reverseLayout = true,
|
||||
|
||||
@@ -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<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() }
|
||||
|
||||
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")
|
||||
|
||||
+11
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 <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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
impl Transport {
|
||||
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+59
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user