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

+36
View File
@@ -19,6 +19,7 @@
//! POST /sessions/{id}/interrupt
//! POST /sessions/{id}/title {title}
//! POST /sessions/{id}/model {model}
//! POST /sessions/{id}/command {text} -- /compact, /rename x, or the dialect's own
//! POST /sessions/{id}/compact
//! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message
//! GET /sessions/{id}/files/{name} images the session produced or was sent
@@ -58,6 +59,7 @@ use tokio::sync::{broadcast, mpsc};
use tokio_stream::StreamExt;
use tokio_stream::wrappers::ReceiverStream;
use crate::session::driver::SessionCommand;
use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up};
use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
@@ -85,6 +87,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}/model", post(set_model))
.route("/sessions/{id}/permission-mode", post(set_permission_mode))
.route("/sessions/{id}/compact", post(compact))
.route("/sessions/{id}/command", post(command))
.route("/sessions/{id}/attachments", post(upload_attachment))
.route("/sessions/{id}/files/{name}", get(serve_file))
// Phone photos overflow axum's 2 MB default body cap.
@@ -737,6 +740,39 @@ async fn set_permission_mode(
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CommandRequest {
text: String,
}
/// Runs one of the session's own commands, now or at the next boundary.
///
/// The two this server understands are turned into the operations it has
/// -- a compaction, a rename, which is also how the settings screen asks
/// -- and everything else is passed to the session verbatim, because a
/// dialect's vocabulary is its own and grows without this file.
async fn command(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<CommandRequest>,
) -> Result<StatusCode, ApiError> {
let text = body.text.trim();
let (name, rest) = match text.split_once(char::is_whitespace) {
Some((name, rest)) => (name, rest.trim()),
None => (text, ""),
};
match (name, rest) {
("/compact", _) => lookup(&manager, &id)?.run_command(SessionCommand::Compact),
// Through the manager, not the session: a name is persisted and
// listed as well as forwarded, and that is one operation.
("/rename", "") => return Err(bad_request(anyhow::anyhow!("a session needs a name"))),
("/rename", title) => manager.rename_session(&id, title).map_err(bad_request)?,
_ => lookup(&manager, &id)?.run_command(SessionCommand::Raw(text.to_string())),
}
Ok(StatusCode::NO_CONTENT)
}
async fn compact(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,