Make a command a thing the app knows, and hold it until it can run

Typing "/" now suggests what this app understands -- `/compact` and
`/rename <name>` -- with a line each about what they do, and anything
else beginning with a slash is passed to whatever runs the session,
because a dialect's own vocabulary grows without this list.

None of them are messages, and that is the substance of the change. A
line written into a running turn is read by the *model*, so a command
sent mid-turn either does nothing or arrives as text somebody has to
puzzle over. They now wait for the turn to end. The waiting is done once
for every provider, in the pump that already watches every event for the
boundary, rather than in each driver where a new provider could get it
wrong by leaving it out.

Waiting is a state, so it is on screen: the command sits at the reader's
end of the conversation in blue, with a spinner and "waiting for this
turn to end", and becomes an ordinary blue row when it goes. Blue
because these are about the session rather than about the task -- the
same blue a compaction already used, which is now one colour with one
name rather than two.

Renaming from the settings screen sends exactly this, so it waits and
draws the same way. The name itself is not held: it is this server's own
datum, so the list and the header change at once and only telling the
session waits.

Echo grew the same split, which is where the bug in it showed: its
commands are its messages, so running one announced a `MessageTaken` as
well, and the same line drew twice -- once blue, once purple. A command
owes no announcement; the manager has already recorded that it was sent.

Watched rather than reasoned about: `/compact` during a 25 second turn
held with its bubble up, went out when the turn ended, and the
compaction that followed reported what it recovered.
This commit is contained in:
iris committed 2026-08-29 17:10:21 -04:00
1 parent bebaae7a94
commit 749b2db287
13 files changed
+678 -93

No files matched your search

@@ -561,6 +561,23 @@ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode:
) {}
}
/**
* Asks the session to run one of its own commands.
*
* Sent as typed. The server turns the two it understands into its own operations -- a compaction, a
* rename, which is also what the settings screen sends -- and passes anything else to whatever runs
* the session. Either way it waits for the turn to end if one is in flight, and says so on the
* event stream, which is where the waiting bubble comes from.
*/
fun runCommand(settings: ServerSettings, sessionId: String, text: String) {
requestFromServer(
settings,
"/sessions/$sessionId/command",
method = "POST",
jsonBody = JSONObject().put("text", text).toString(),
) {}
}
/**
* Asks the session to summarise its own history and carry on from the summary.
*
@@ -0,0 +1,148 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
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.unit.dp
/**
* Something a session can be asked to do to itself, rather than something to say to it.
*
* These are the two this app understands, and understanding them is what lets it show them: a
* suggestion while one is being typed, a name in the settings screen that sends one, and a bubble
* that stays up while the session is too busy to run it. Anything else beginning with "/" is passed
* through to whatever runs the session, because a dialect's own vocabulary is its own and grows
* without this list -- it just arrives unannounced and unexplained.
*/
data class SessionCommand(
/** With the slash, as it is typed and as it is sent. */
val name: String,
/** One line, in the suggestion list: what it does, not how. */
val summary: String,
/** What follows the name, named for the reader, or null when nothing does. */
val argument: String?,
) {
/** What to put in the box when this is picked: ready to send, or ready to be finished. */
fun typed(): String = if (argument == null) name else "$name "
}
val SESSION_COMMANDS =
listOf(
SessionCommand(
"/compact",
"Summarise the conversation so far and carry on from the summary",
null,
),
SessionCommand("/rename", "Change what this session is called", "name"),
)
/**
* The commands worth offering for what has been typed so far.
*
* Only for a line that starts with a slash and has not yet become a whole command with an argument
* -- once there is something after "/rename ", the reader is writing the name and a list of
* commands underneath it is in the way.
*/
fun suggestedCommands(input: String): List<SessionCommand> {
if (!input.startsWith("/") || input.contains(' ')) return emptyList()
return SESSION_COMMANDS.filter { it.name.startsWith(input) }
}
/**
* The commands matching what is being typed, above the box they are being typed into.
*
* Above rather than over: a list that covers the transcript hides what the command is about, and
* the reader is usually looking at the thing they mean to act on.
*/
@Composable
fun CommandSuggestions(
commands: List<SessionCommand>,
onPick: (SessionCommand) -> Unit,
modifier: Modifier = Modifier,
) {
if (commands.isEmpty()) return
Card(modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
Column(Modifier.padding(vertical = 4.dp)) {
commands.forEach { command ->
Row(
Modifier.fillMaxWidth()
.clickable { onPick(command) }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
// The command in the colour commands are, so the suggestion and the
// bubble it becomes are visibly the same thing.
if (command.argument == null) command.name
else "${command.name} <${command.argument}>",
style = MaterialTheme.typography.titleSmall,
color = commandColor,
)
Spacer(Modifier.width(12.dp))
Text(
command.summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/**
* A command, where the reader put it: at their end of the conversation.
*
* Blue rather than the colour of something they said, because they did not say it to the model --
* it is an instruction to the session, and the reply to it is the session changing rather than
* anything appearing here.
*
* [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a
* reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes
* and reads as having been missed.
*/
@Composable
fun CommandBubble(text: String, waiting: Boolean = false) {
Box(Modifier.fillMaxWidth()) {
Card(
colors = CardDefaults.cardColors(containerColor = commandColor),
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
) {
Column(Modifier.padding(12.dp)) {
// Stated beside the fill rather than inherited: a semantic colour has to carry
// its own contrast, because the surface under it will not change to rescue it.
Text(text, color = MaterialTheme.colorScheme.inverseOnSurface)
if (waiting) {
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.width(12.dp).height(12.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.inverseOnSurface,
)
Spacer(Modifier.width(6.dp))
Text(
"waiting for this turn to end",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.inverseOnSurface,
)
}
}
}
}
}
}
@@ -76,11 +76,11 @@ fun CompactingRow(seconds: Long?, modifier: Modifier = Modifier) {
style = MaterialTheme.typography.bodySmall,
// The colour is stated beside the fill rather than inherited: a semantic colour has
// to carry its own contrast, since the surface under it will not change to rescue it.
color = compactingColor,
color = commandColor,
)
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
color = compactingColor,
color = commandColor,
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
)
}
@@ -60,6 +60,17 @@ sealed class SessionEvent {
*/
data class PeerMessage(val from: String, val text: String) : SessionEvent()
/**
* A command the session was asked to run on itself and cannot run yet.
*
* Resolved by [CommandSent] with the same id. A command that ran straight away has only that
* one, so nothing here ever draws a bubble that resolves in the same frame.
*/
data class CommandQueued(val id: String, val text: String) : SessionEvent()
/** The same command, handed to the session. */
data class CommandSent(val id: String, val text: String) : SessionEvent()
data class Status(val state: String) : SessionEvent()
/**
@@ -144,6 +155,9 @@ fun parseSeqEvent(json: String): SeqEvent {
)
"peerMessage" ->
SessionEvent.PeerMessage(body.getString("from"), body.getString("text"))
"commandQueued" ->
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
"status" -> SessionEvent.Status(body.getString("state"))
"settings" ->
SessionEvent.Settings(
@@ -324,7 +324,7 @@ fun StatusText(status: String) {
when (status) {
"awaitingInput" -> "your turn" to awaitingColor
"running" -> "running" to runningColor
"compacting" -> "compacting" to compactingColor
"compacting" -> "compacting" to commandColor
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
// Said in words, because it differs in kind from the others rather than in degree:
// the session is not idle and has not exited, nobody has been able to find out
@@ -143,6 +143,14 @@ sealed class TranscriptItem {
data class PeerNote(override val seq: Long, val from: String, val text: String) :
TranscriptItem()
/**
* A command the session ran on itself -- `/compact`, `/rename`.
*
* Kept in the transcript rather than only shown while it waits, because it explains what
* follows: a conversation that suddenly has half the context, or a session with a new name.
*/
data class CommandRow(override val seq: Long, val text: String) : TranscriptItem()
/** Placeholder row for events this build can't render (newer kinds). */
data class Note(override val seq: Long, val text: String) : TranscriptItem()
@@ -250,7 +258,9 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
}
is SessionEvent.PeerMessage ->
items + TranscriptItem.PeerNote(entry.seq, event.from, event.text)
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.CommandQueued -> items
is SessionEvent.Settings -> items
is SessionEvent.Status -> items
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
@@ -320,6 +330,11 @@ fun SessionScreen(
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
// What this session is set to now, seeded from the row that opened it and
// then owned here, because changing either is something this screen does.
// The name shown at the top. Held here rather than read from the row that opened this
// screen, because renaming is something this screen can do -- through the settings below it,
// or by typing the command -- and a header still showing the old name reads as a rename that
// did not take.
var title by remember(summary.id) { mutableStateOf(summary.title) }
var model by remember { mutableStateOf(summary.model) }
var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") }
// The models this provider actually offers, asked of the server rather
@@ -344,6 +359,10 @@ fun SessionScreen(
// reading of events: after everything taken in, not yet taken in
// itself.
var queued by remember { mutableStateOf(listOf<String>()) }
// Commands the session has been asked to run and cannot yet, by the id that will resolve
// them. From the server rather than from this screen, so a rename sent from the settings
// screen -- or from another device -- is drawn waiting here too.
var waitingCommands by remember { mutableStateOf(listOf<Pair<String, String>>()) }
val running = status == "running" || status == "compacting"
var moreHistory by remember { mutableStateOf(true) }
var loadingHistory by remember { mutableStateOf(false) }
@@ -393,6 +412,14 @@ fun SessionScreen(
// earlier one -- and only the first match, so two
// identical messages wait twice.
if (event is SessionEvent.UserMessage) queued = queued - event.text
// Waiting, then gone: a command leaves this list when the session takes it,
// and the row it becomes is added by `foldEvent` in the same pass.
if (event is SessionEvent.CommandQueued) {
waitingCommands = waitingCommands + (event.id to event.text)
}
if (event is SessionEvent.CommandSent) {
waitingCommands = waitingCommands.filterNot { it.first == event.id }
}
// Kept as well as folded. Folding is one-way -- a tool's
// start and end become one row -- so a page arriving in
// front of what is already here cannot be stitched on
@@ -624,6 +651,25 @@ fun SessionScreen(
val text = input.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
// written into a running turn is read by the model instead. The server holds it until the
// 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 = ""
// 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
// datum and changes at once, and only telling the session waits for a boundary.
val renamed =
text.removePrefix("/rename ").trim().takeIf {
text.startsWith("/rename ") && it.isNotEmpty()
}
act {
runCommand(settings, summary.id, text)
renamed?.let { title = it }
}
return
}
input = ""
pendingAttachments = emptyList()
if (running && text.isNotEmpty()) queued = queued + text
@@ -672,7 +718,7 @@ fun SessionScreen(
) {
TextButton(onClick = onBack) { Text("Back") }
Column(Modifier.weight(1f)) {
Text(summary.title, style = MaterialTheme.typography.titleMedium)
Text(title, style = MaterialTheme.typography.titleMedium)
Text(
listOfNotNull(
summary.provider,
@@ -739,9 +785,12 @@ fun SessionScreen(
// Below the working indicator, because that is where they
// are in the session's reading of events: after everything
// it has taken in, and not yet taken in themselves.
if (queued.isNotEmpty()) {
if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
item(key = "queued") {
Column(horizontalAlignment = Alignment.End) {
waitingCommands.forEach { (_, text) ->
CommandBubble(text, waiting = true)
}
queued.forEach { text -> UserBubble(text, pending = true) }
}
}
@@ -867,6 +916,7 @@ fun SessionScreen(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is TranscriptItem.CommandRow -> CommandBubble(item.text)
is TranscriptItem.CompactedNote -> CompactedRow(item)
is TranscriptItem.PeerNote ->
PeerMessageRow(
@@ -913,6 +963,13 @@ fun SessionScreen(
}
}
// Between the transcript and the box: above what is being typed, so the list does not
// cover the thing the command is about, and below everything that explains it.
CommandSuggestions(
commands = suggestedCommands(input),
onPick = { command -> input = command.typed() },
)
// Always enabled -- a send while the session is running becomes a
// steering message injected at the next tool boundary, which is
// the point of the whole app.
@@ -110,15 +110,15 @@ val failedColor: Color
@Composable get() = MaterialTheme.colorScheme.error
/**
* Working on the conversation rather than in it: a compaction.
* About the session rather than about the task: a command, and the compaction one of them starts.
*
* Its own colour because it is its own kind of busy. Everything else a session does is progress
* through the task; this is the session rewriting what it remembers, it can take minutes, and
* nothing it produces appears in the transcript until it is over. A reader who has learned that
* blue means "not stuck, but not answering you either" has learned the only thing that
* distinguishes it from a session that has hung.
* Its own colour because it is its own kind of work. Everything else a session does is progress
* through what was asked of it; this is the session acting on itself -- rewriting what it
* remembers, taking a new name -- and none of it appears in the transcript as an answer to
* anything. A reader who has learned that blue means "not stuck, but not replying to you either"
* has learned the thing that distinguishes it from a session that has hung.
*/
val compactingColor: Color
val commandColor: Color
@Composable get() = Mocha.Blue
/** Waiting on a person: a question, a permission, a turn that is theirs. */