Attach any file, take shares from other apps, and survive a backwards highlight

Attachments were images only. Now any file can be attached: from the file
chooser behind the "+" menu, or from Android's share sheet, which the app
is now in. An image still goes to the model as a picture; anything else is
stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`)
and the Claude driver ends the message with `Attached file: /abs/path`,
since the CLI reads files by path and a model cannot be shown a trace. The
user-message field is renamed `images` -> `attachments` on both sides,
with a serde alias reading the rows written before. A share arrives before
anyone has said which session it is for, so it is held in AppRoot with a
banner on the list until a session takes it; an open session takes it at
once. Unreadable shares are reported beside the composer, not thrown.

The tool card crashed the app when opened on a command holding a quoted
glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers
`x '*/a/*'` with a span whose end is before its start, and AnnotatedString
refuses the range. Such spans are dropped; the library is the place for
the fix. The echo driver gains `/bash <command>` so a card with a given
command can be produced on the emulator.

ui-sandbox.sh's token salvage read the tokens block's close only at a line
start, ran past the compact `),],` the server writes, and copied `setups`
into the new config twice, which the server then refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-03 08:15:10 -04:00
1 parent 4bc69e8f9c
commit 6180663f14
25 files changed
+672 -165

No files matched your search

+44 -16
View File
@@ -7,6 +7,8 @@
//! A leading word asks for something more specific:
//!
//! - `/tool [input]` -- a full tool run, start through end.
//! - `/bash [command]` -- a Bash call carrying that command, for what the
//! phone's shell highlighting does to a particular line.
//! - `/tools [n] [gap]` -- n calls back to back, for what a run of them
//! looks like when a screen groups them. `gap` is seconds between one
//! call and the next, default none: it is what makes a run *grow* while
@@ -37,7 +39,7 @@
//! shape a real model's reply arrives in, and the one where the row a
//! reader is anchored to is the row that keeps changing height.
//! - `/mixed N` -- N beats of an interleaved transcript: paragraphs of
//! different lengths, single tool calls, runs of adjacent ones, images
//! different lengths, single tool calls, runs of adjacent ones, attachments
//! and a peer message. Rows of every shape and height the app draws, in
//! one session, which is what a scrolling problem needs in order to be
//! reproduced twice the same way.
@@ -55,7 +57,9 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use super::driver::{Driver, Event, EventSink, ImageRef, QuestionOption, SessionStatus, Unqueued};
use super::driver::{
AttachmentRef, Driver, Event, EventSink, QuestionOption, SessionStatus, Unqueued,
};
/// Delay between streamed deltas -- long enough that streaming is visibly
/// streaming in the UI, short enough that tests waiting on a full turn
@@ -94,7 +98,7 @@ pub struct EchoDriver {
/// Held messages with the id of the `MessageQueued` each one announced,
/// so the announcement can say which waiting bubble it resolves.
queued: Arc<Mutex<Vec<Held>>>,
/// Where `/mixed` writes the images it references, which is the same
/// Where `/mixed` writes the attachments it references, which is the same
/// directory the files route serves them from.
session_dir: PathBuf,
/// Ids of the questions awaiting an answer, in the order they were
@@ -249,7 +253,7 @@ impl EchoDriver {
/// transcript, and a command is not -- the manager has already
/// recorded that one was sent, and saying so twice drew the same
/// line in both colours.
fn handle(&self, text: String, images: Vec<ImageRef>, announce: bool) {
fn handle(&self, text: String, attachments: Vec<AttachmentRef>, announce: bool) {
let sink = self.sink.clone();
// Mid-turn messages are held rather than answered, the way a real
@@ -265,9 +269,13 @@ impl EchoDriver {
self.queued
.lock()
.unwrap()
.push((id.clone(), text.clone(), images.clone()));
.push((id.clone(), text.clone(), attachments.clone()));
if announce {
self.emit(Event::MessageQueued { id, text, images });
self.emit(Event::MessageQueued {
id,
text,
attachments,
});
}
return;
}
@@ -288,7 +296,7 @@ impl EchoDriver {
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
images: images.clone(),
attachments: attachments.clone(),
});
}
self.emit(Event::Status {
@@ -319,7 +327,7 @@ impl EchoDriver {
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
images: images.clone(),
attachments: attachments.clone(),
});
}
self.emit(Event::PeerMessage {
@@ -345,7 +353,7 @@ impl EchoDriver {
self.emit(Event::MessageTaken {
id: None,
text,
images,
attachments,
});
}
self.compact();
@@ -357,7 +365,7 @@ impl EchoDriver {
self.emit(Event::MessageTaken {
id: None,
text,
images,
attachments,
});
}
self.ask_user_question();
@@ -427,6 +435,9 @@ impl EchoDriver {
text.strip_prefix("/tool")
.map(|rest| rest.trim().to_string())
};
let run_bash = text
.strip_prefix("/bash")
.map(|rest| rest.trim().to_string());
// Seconds to stay running before answering, default 30. Clamped
// rather than trusted: this is a test affordance, and a session
// pinned running for an hour by a typo is a worse outcome than a
@@ -469,7 +480,7 @@ impl EchoDriver {
send(Event::MessageTaken {
id: None,
text: text.clone(),
images: images.clone(),
attachments: attachments.clone(),
});
}
send(Event::Status {
@@ -585,6 +596,23 @@ impl EchoDriver {
return;
}
if let Some(command) = run_bash {
let id = format!("b-{}", super::random_hex());
send(Event::ToolStart {
id: id.clone(),
tool: "Bash".to_string(),
input: serde_json::json!({
"command": command,
"description": "Run what /bash was given",
}),
});
tokio::time::sleep(DELTA_DELAY).await;
send(Event::ToolEnd {
id,
output: format!("ran: {command}"),
});
}
if let Some(input) = run_tool {
let id = format!("t-{}", super::random_hex());
send(Event::ToolStart {
@@ -762,7 +790,7 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
/// it. All three, because all three are what the `MessageTaken` at the other
/// end owes -- named rather than written out at each of the four places that
/// mention it.
type Held = (String, String, Vec<ImageRef>);
type Held = (String, String, Vec<AttachmentRef>);
/// A markdown table [columns] wide, with cells too long for one line.
///
@@ -832,7 +860,7 @@ fn markdown_table(columns: usize) -> String {
/// of them owes the same answer.
fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
let held = std::mem::take(&mut *queued.lock().unwrap());
for (id, text, images) in held {
for (id, text, attachments) in held {
// Announced before it is answered, in that order: a phone showing
// the message as pending needs the signal that it has been read,
// and the answer is meaningless above a message still drawn as
@@ -840,7 +868,7 @@ fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
let _ = sink.send(Event::MessageTaken {
id: Some(id),
text: text.clone(),
images,
attachments,
});
let _ = sink.send(Event::AssistantText {
delta: format!("\n(taken from the queue) You said: {text}"),
@@ -874,14 +902,14 @@ impl Driver for EchoDriver {
Unqueued::Dropped
}
fn send_user_message(&self, text: String, images: Vec<ImageRef>) {
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
// Announced, because this is a message: every driver owes exactly
// one `MessageTaken` per message, and one that quietly vanishes
// from the transcript is the thing echo must not model. A command
// owes none -- the manager has already recorded that it was sent,
// and announcing it again drew the same line twice, once in each
// colour.
self.handle(text, images, true);
self.handle(text, attachments, true);
}
/// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` --