Colour what a shell printed, swipe back, and let a stopped session take a setting

Bash output arrived with its escape sequences in it, so a coloured diff or
test run was line noise around the thing being read. The sequences that
decide how text looks are spans now and every other one is dropped, with a
carriage return honoured the way a terminal honours it so a progress bar
shows its final state rather than every state it passed through.

A rightward drag anywhere on a session, spawn or settings screen steps back,
following the finger so it can be abandoned. It loses every argument: a
child that consumes horizontal drags -- a wide fence, a table, a selection
-- has already taken the gesture before this sees it.

Changing the model or the permission mode of a session with nothing running
was refused, in words about the driver, while the config had already taken
the value that its next start will use. Both now announce the stored setting
instead, through one function, since which of the pair it is does not change
the rule.

The model-switch warning no longer fires after a clear: the server reports
the context as unmeasured rather than zero afterwards, and the fallback
reading counted the whole conversation still on screen.

An image loading shows a spinner in the space it is about to fill, in the
transcript and in the composer's attachments alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-03 20:17:12 -04:00
1 parent 8dcd2cb708
commit a383c19dd5
10 files changed
+651 -48

No files matched your search

+119 -4
View File
@@ -1217,9 +1217,16 @@ impl SessionManager {
// change, and as an error if it cannot. The config above is a
// different question -- what to launch this session with next
// time -- and it is answered by the request.
session.ask("change how much it asks", |driver| {
driver.set_permission_mode(mode)
});
announce_or_ask(
session,
&self.data_dir.join(id),
Event::Settings {
model: None,
permission_mode: Some(mode.to_string()),
},
"change how much it asks",
|driver| driver.set_permission_mode(mode),
);
}
Ok(())
}
@@ -1323,7 +1330,16 @@ impl SessionManager {
if let Some(session) = inner.live.get(id) {
// See `set_session_permission_mode`: the driver reports what
// it is set to, this only asks.
session.ask("change model", |driver| driver.set_model(model));
announce_or_ask(
session,
&self.data_dir.join(id),
Event::Settings {
model: Some(model.to_string()),
permission_mode: None,
},
"change model",
|driver| driver.set_model(model),
);
}
Ok(())
}
@@ -1685,6 +1701,37 @@ impl SessionManager {
/// to the one process, and every line it wrote was then translated once per
/// reader -- three presses put three interleaved copies of one reply on
/// screen.
/// A setting change: asked of the driver, or announced as the session's own
/// where there is no process for a driver to speak for.
///
/// The pair that [`LiveSession::ask`] cannot serve. Everything else it
/// covers genuinely needs a process -- a message sent to a session that is
/// not running has nowhere to go -- but a setting is held in the config as
/// well, and a session with nothing running *is* what the config says: the
/// value is applied the moment it next starts. So `ask`'s "this session has
/// no process running, so it can't change model" was true of the driver and
/// false of the session, and it left the phone showing the old model over a
/// config that had already taken the new one, with no way to change it
/// short of starting the session first.
///
/// `Exited` and nothing else, for the reason [`start_if_exited`] gives:
/// `Unknown` means nobody could find out, and a session whose process may
/// well be reading its fifo is one to ask rather than to answer for.
fn announce_or_ask(
session: &LiveSession,
session_dir: &Path,
settled: Event,
what: &str,
request: impl FnOnce(&dyn Driver),
) {
let status = corrected(*session.shared.status.lock().unwrap(), session_dir);
if status == SessionStatus::Exited {
let _ = session.sink.send(settled);
} else {
session.ask(what, request);
}
}
fn corrected(status: SessionStatus, session_dir: &Path) -> SessionStatus {
if status == SessionStatus::Exited && adoptable(session_dir) {
SessionStatus::Unknown
@@ -3613,6 +3660,74 @@ mod tests {
std::fs::write(path, rewritten).expect("write transcript");
}
/// A setting changed on a session with nothing running is recorded as
/// the session's own, rather than refused because there is no driver.
///
/// The config already took it -- that is what a session starts with next
/// time -- so the refusal was about the driver while reading as though
/// it were about the session, and the phone went on showing the old
/// model over a stored new one. Asked of a session told it has exited,
/// since the rule is about the status rather than about which driver it
/// is; the same is true of the permission mode, which is why they go
/// through one function.
#[tokio::test]
async fn a_stopped_session_takes_a_setting_for_the_next_time_it_starts() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.ron");
let data_dir = dir.path().join("sessions");
seed_echo_only(&config_path);
let manager = SessionManager::new(
config_path.clone(),
data_dir.clone(),
data_dir.join("models"),
)
.expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
let session = manager.session(&info.id).expect("live session");
let mut rx = session.subscribe();
let _ = session.sink.send(Event::Status {
state: SessionStatus::Exited,
});
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Exited
}
)
})
.await;
manager
.set_session_model(&info.id, "haiku")
.expect("store the model");
collect_until(
&mut rx,
|event| matches!(event, Event::Settings { model: Some(model), .. } if model == "haiku"),
)
.await;
assert_eq!(
manager.sessions()[0].model.as_deref(),
Some("haiku"),
"stored, so the next start uses it"
);
manager
.set_session_permission_mode(&info.id, "plan")
.expect("store the mode");
collect_until(&mut rx, |event| {
matches!(
event,
Event::Settings {
permission_mode: Some(mode),
..
} if mode == "plan"
)
})
.await;
}
/// Stopping and starting a session is about its *process*, and the two
/// refusals are the whole of what keeps starting one from becoming a
/// second one on the same conversation.