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
|
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.
|
|
||||||
@@ -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
@@ -1177,8 +1177,18 @@ async fn set_cwd(
|
|||||||
setup.name
|
setup.name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
// Stored in the short form, so the one path that is kept is the one
|
||||||
|
// the phone will draw -- rather than storing `/home/bob/…` and
|
||||||
|
// abbreviating it again at each place it is shown, which is two
|
||||||
|
// representations of one directory and a second rule to keep in step.
|
||||||
|
// Only where the setup runs here; see `setups::shorten_home`.
|
||||||
|
let stored = if setup.ssh.is_none() {
|
||||||
|
crate::setups::shorten_home(&cwd)
|
||||||
|
} else {
|
||||||
|
cwd.clone()
|
||||||
|
};
|
||||||
manager
|
manager
|
||||||
.set_session_cwd(&id, PathBuf::from(&cwd))
|
.set_session_cwd(&id, PathBuf::from(&stored))
|
||||||
.map_err(bad_request)?;
|
.map_err(bad_request)?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,7 +441,14 @@ pub async fn directory_exists(transport: &Transport, path: &str) -> bool {
|
|||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let launch = Launch::new("test", vec!["-d".to_string(), path.to_string()], None);
|
// Asked by *entering* it rather than by `test -d <path>`, because the
|
||||||
|
// question this is standing in for is "can a session start here" and
|
||||||
|
// because a path is only expanded where it is a working directory --
|
||||||
|
// `~/repos/ai-app` as an argument stays four literal characters on
|
||||||
|
// both transports (`ssh::quote_path`, `ssh::expand_home`), so the old
|
||||||
|
// form answered "no such directory" about every home-relative path
|
||||||
|
// somebody typed.
|
||||||
|
let launch = Launch::new("true", Vec::new(), Some(std::path::Path::new(path)));
|
||||||
transport.capture(&launch).await.is_ok()
|
transport.capture(&launch).await.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,32 @@ pub fn tidy(value: &str) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The inverse of [`tidy`]'s expansion: an absolute path under this
|
||||||
|
/// machine's home, written back as `~/…`.
|
||||||
|
///
|
||||||
|
/// So that a working directory reads on a phone the way it is written by
|
||||||
|
/// hand. `/home/bob/repos/ai-app-2` is most of a line on that screen and
|
||||||
|
/// almost all of it is the part nobody is reading.
|
||||||
|
///
|
||||||
|
/// Applied only to paths on **this** machine. `$HOME` here says nothing
|
||||||
|
/// about the home directory of a machine reached over ssh, so a remote
|
||||||
|
/// path is stored exactly as it was typed -- where a `~` somebody wrote
|
||||||
|
/// stays a `~`, and the remote shell is what expands it
|
||||||
|
/// (`ssh::quote_path`).
|
||||||
|
pub fn shorten_home(path: &str) -> String {
|
||||||
|
let Some(home) = std::env::home_dir() else {
|
||||||
|
return path.to_string();
|
||||||
|
};
|
||||||
|
let home = home.to_string_lossy();
|
||||||
|
// The separator has to be part of the match, or `/home/bobby` would be
|
||||||
|
// read as a path inside `/home/bob`.
|
||||||
|
match path.strip_prefix(home.as_ref()) {
|
||||||
|
Some("") => "~".to_string(),
|
||||||
|
Some(rest) if rest.starts_with('/') => format!("~{rest}"),
|
||||||
|
_ => path.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs a launch to completion and returns its stdout.
|
/// Runs a launch to completion and returns its stdout.
|
||||||
impl Transport {
|
impl Transport {
|
||||||
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
||||||
@@ -182,3 +208,29 @@ impl Transport {
|
|||||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The two halves of a home-relative path, which have to be inverses:
|
||||||
|
/// what is stored is what the phone draws, and what the phone sends
|
||||||
|
/// back is what a process is started in.
|
||||||
|
#[test]
|
||||||
|
fn a_home_path_shortens_and_expands_back() {
|
||||||
|
let Some(home) = std::env::home_dir() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let full = home.join("repos/ai-app-2");
|
||||||
|
let full = full.to_string_lossy();
|
||||||
|
assert_eq!(shorten_home(&full), "~/repos/ai-app-2");
|
||||||
|
assert_eq!(shorten_home(&home.to_string_lossy()), "~");
|
||||||
|
assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref()));
|
||||||
|
|
||||||
|
// Not a prefix match on the characters: a sibling directory whose
|
||||||
|
// name merely starts with the home directory's is not inside it.
|
||||||
|
let sibling = format!("{}-backup/notes", home.to_string_lossy());
|
||||||
|
assert_eq!(shorten_home(&sibling), sibling);
|
||||||
|
assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts");
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-2
@@ -10,7 +10,7 @@
|
|||||||
//! `~/.ssh/config`, agents, and jump hosts all keep working and there is
|
//! `~/.ssh/config`, agents, and jump hosts all keep working and there is
|
||||||
//! only one place to configure connections (PLAN.md, rule 23).
|
//! only one place to configure connections (PLAN.md, rule 23).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::config::SshConfig;
|
use crate::config::SshConfig;
|
||||||
@@ -50,7 +50,15 @@ pub fn command(
|
|||||||
let mut command = Command::new(program);
|
let mut command = Command::new(program);
|
||||||
command.args(args);
|
command.args(args);
|
||||||
if let Some(cwd) = cwd {
|
if let Some(cwd) = cwd {
|
||||||
command.current_dir(cwd);
|
// Expanded here for the same reason `quote_path` expands it on
|
||||||
|
// the far side: a working directory typed as `~/repos/ai-app`
|
||||||
|
// has to mean the same thing whichever machine runs it. There
|
||||||
|
// is no shell in this branch, so nothing else would --
|
||||||
|
// `current_dir` would be handed the literal one-character
|
||||||
|
// directory `~`, and the session would fail to start with an
|
||||||
|
// error naming a path nobody typed. Only the cwd, matching
|
||||||
|
// the remote side, where arguments stay literal.
|
||||||
|
command.current_dir(expand_home(cwd));
|
||||||
}
|
}
|
||||||
return command;
|
return command;
|
||||||
};
|
};
|
||||||
@@ -101,6 +109,30 @@ fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String {
|
|||||||
script
|
script
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A path with a leading `~` replaced by this machine's home directory.
|
||||||
|
///
|
||||||
|
/// The local half of the rule [`quote_path`] states for the remote one, and
|
||||||
|
/// the two are deliberately the same shape: the tilde is expanded, `~user`
|
||||||
|
/// is not (there is no portable expansion for another account's home), and
|
||||||
|
/// nothing else in the path gains a meaning. A machine with no home
|
||||||
|
/// directory at all leaves the path alone, which fails with the operating
|
||||||
|
/// system's own message rather than with a guess.
|
||||||
|
pub(crate) fn expand_home(path: &Path) -> PathBuf {
|
||||||
|
let Some(rest) = path.to_str().and_then(|p| {
|
||||||
|
if p == "~" {
|
||||||
|
Some("")
|
||||||
|
} else {
|
||||||
|
p.strip_prefix("~/")
|
||||||
|
}
|
||||||
|
}) else {
|
||||||
|
return path.to_path_buf();
|
||||||
|
};
|
||||||
|
match std::env::home_dir() {
|
||||||
|
Some(home) => home.join(rest),
|
||||||
|
None => path.to_path_buf(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Quotes a path, expanding a leading `~` and nothing else.
|
/// Quotes a path, expanding a leading `~` and nothing else.
|
||||||
///
|
///
|
||||||
/// [`quote`] is right for every other word crossing to the remote side and
|
/// [`quote`] is right for every other word crossing to the remote side and
|
||||||
@@ -247,6 +279,31 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same character, on the transport with no shell to expand it.
|
||||||
|
///
|
||||||
|
/// The local branch runs the program directly, so a working directory
|
||||||
|
/// of `~/repos/ai-app` would reach `current_dir` as the literal
|
||||||
|
/// one-character directory `~` -- a session that fails to start,
|
||||||
|
/// naming a path nobody typed. The two transports have to agree about
|
||||||
|
/// what a tilde means or a path is only portable by accident.
|
||||||
|
#[test]
|
||||||
|
fn a_local_cwd_expands_its_tilde_the_same_way() {
|
||||||
|
let Some(home) = std::env::home_dir() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
expand_home(Path::new("~/repos/ai-app")),
|
||||||
|
home.join("repos/ai-app")
|
||||||
|
);
|
||||||
|
assert_eq!(expand_home(Path::new("~")), home);
|
||||||
|
// Leading only, and its own segment only -- `quote_path`'s rule.
|
||||||
|
assert_eq!(expand_home(Path::new("/tmp/~/x")), Path::new("/tmp/~/x"));
|
||||||
|
assert_eq!(expand_home(Path::new("~user/x")), Path::new("~user/x"));
|
||||||
|
|
||||||
|
let local = command(None, "claude", &args(["-p"]), Some(Path::new("~/work")));
|
||||||
|
assert_eq!(local.get_current_dir(), Some(home.join("work").as_path()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shell_metacharacters_cross_as_data_not_syntax() {
|
fn shell_metacharacters_cross_as_data_not_syntax() {
|
||||||
// Expanding $HOME must not open a door for anything else: the rest
|
// Expanding $HOME must not open a door for anything else: the rest
|
||||||
|
|||||||
Reference in new issue
Block a user