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

+54 -24
View File
@@ -57,7 +57,7 @@ use serde_json::{Value, json};
use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc;
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus, Unqueued};
use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued};
use super::process;
use super::transport::{Launch, Streams, Transport};
use crate::config::{ProviderConfig, SessionConfig};
@@ -133,7 +133,7 @@ struct Queue {
/// Written, not yet announced, oldest first, each with the id of the
/// `MessageQueued` that told the phone it was waiting -- so the
/// announcement can name which bubble it resolves.
awaiting: VecDeque<(String, String, Vec<ImageRef>)>,
awaiting: VecDeque<(String, String, Vec<AttachmentRef>)>,
/// The process is gone, so nothing can be taken up any more.
///
/// Needed because every other way out of a turn is an `Idle` this
@@ -550,20 +550,35 @@ impl ClaudeDriver {
}
impl Driver for ClaudeDriver {
fn send_user_message(&self, text: String, images: Vec<ImageRef>) {
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
let mut content = Vec::new();
for id in &images {
match attachment_block(&self.session_dir, id) {
Ok(block) => content.push(block),
Err(err) => {
let _ = self.sink.send(Event::Error {
message: format!("attachment {id} couldn't be sent: {err:#}"),
});
}
// An image goes into the message itself; the model looks at it. Any
// other file stays where the upload put it and the message says
// where, because the CLI can read a file by path and a model cannot
// be handed a trace, a log or a zip any other way. Named after the
// text, so the words come first, the way they were typed.
let mut files = Vec::new();
for id in &attachments {
let sent = if crate::media::media_type_for(id).is_some() {
attachment_block(&self.session_dir, id).map(|block| content.push(block))
} else {
attachment_path(&self.session_dir, id).map(|path| files.push(path))
};
if let Err(err) = sent {
let _ = self.sink.send(Event::Error {
message: format!("attachment {id} couldn't be sent: {err:#}"),
});
}
}
if !text.is_empty() {
content.push(json!({"type": "text", "text": text}));
let mut body = text.clone();
for path in files {
if !body.is_empty() {
body.push_str("\n\n");
}
body.push_str(&format!("Attached file: {}", path.display()));
}
if !body.is_empty() {
content.push(json!({"type": "text", "text": body}));
}
let line =
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string();
@@ -591,9 +606,13 @@ impl Driver for ClaudeDriver {
let id = super::random_hex();
queue
.awaiting
.push_back((id.clone(), text.clone(), images.clone()));
.push_back((id.clone(), text.clone(), attachments.clone()));
drop(queue);
let _ = self.sink.send(Event::MessageQueued { id, text, images });
let _ = self.sink.send(Event::MessageQueued {
id,
text,
attachments,
});
self.send_line(line);
return;
}
@@ -605,7 +624,7 @@ impl Driver for ClaudeDriver {
let _ = self.sink.send(Event::MessageTaken {
id: None,
text,
images,
attachments,
});
let _ = self.sink.send(Event::Status {
state: SessionStatus::Running,
@@ -1087,16 +1106,16 @@ fn proves_a_turn(event: &Event) -> bool {
/// [`translate_line`], and the pair is the whole of the rule -- a steer
/// announced anywhere else lands above output that predates it.
fn announce_steers(queue: &Arc<Mutex<Queue>>, sink: &EventSink) -> bool {
let taken: Vec<(String, String, Vec<ImageRef>)> = {
let taken: Vec<(String, String, Vec<AttachmentRef>)> = {
let mut queue = queue.lock().unwrap();
queue.awaiting.drain(..).collect()
};
for (id, text, images) in taken {
for (id, text, attachments) in taken {
if sink
.send(Event::MessageTaken {
id: Some(id),
text,
images,
attachments,
})
.is_err()
{
@@ -1185,17 +1204,28 @@ pub(super) fn write_resume_token(session_dir: &Path, session_id: &str) {
}
}
/// Reads an uploaded attachment into an API image content block.
fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
// Ids are server-generated hex (see routes::upload_attachment); the
// check keeps a crafted "id" from naming an arbitrary file.
/// Where an uploaded attachment is, as a path the CLI can be told.
///
/// Absolute, because the CLI's working directory is the session's and the
/// attachments are not in it. Refused rather than resolved when the id is
/// not one this server would have written -- see
/// `SessionManager::save_attachment` -- so a crafted id cannot name a file
/// outside the session.
fn attachment_path(session_dir: &Path, id: &str) -> Result<PathBuf> {
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|| id.contains("..")
{
anyhow::bail!("invalid attachment id");
}
let path = session_dir.join("attachments").join(id);
std::fs::canonicalize(&path).with_context(|| format!("find {}", path.display()))
}
/// Reads an uploaded image into an API image content block.
fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
let path = attachment_path(session_dir, id)?;
let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
use base64::Engine;
Ok(json!({