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

+65 -11
View File
@@ -33,7 +33,7 @@ use crate::config::{
};
use claude::ClaudeDriver;
use driver::{
Driver, Event, EventSink, ImageRef, SessionCommand, SessionStatus, Unqueued, context_after,
AttachmentRef, Driver, Event, EventSink, SessionCommand, SessionStatus, Unqueued, context_after,
};
use echo::EchoDriver;
use llama::LlamaDriver;
@@ -417,14 +417,14 @@ impl LiveSession {
/// The message is deliberately not recorded here. Sent into a running
/// turn it waits, and writing it down on the way past would put it
/// above output that happened before the session ever saw it.
pub fn send_message(&self, text: String, images: Vec<ImageRef>) {
pub fn send_message(&self, text: String, attachments: Vec<AttachmentRef>) {
// The attachments ride *on* the message rather than as `Image`
// events emitted just before it. They used to be the latter, which
// drew a person's screenshot as a row floating above the bubble
// that sent it, and left the phone inferring from adjacency which
// message an image went with -- a thing the sender already knew.
self.ask("take a message", |driver| {
driver.send_user_message(text, images)
driver.send_user_message(text, attachments)
});
}
@@ -488,11 +488,24 @@ impl LiveSession {
/// Stores one uploaded attachment, returning the id `POST /message`
/// references it by. Removed with the session directory on delete --
/// the same path out as everything else in it.
pub fn save_attachment(&self, bytes: &[u8], content_type: &str) -> Result<String> {
// An unrecognized type is almost always a phone photo whose
// content type the picker didn't set; jpg is the useful guess.
let extension = crate::media::extension_for(content_type).unwrap_or("jpg");
let name = format!("{}.{extension}", random_hex());
///
/// An image is named `<hex>.<extension>` and nothing else, since the
/// model is shown the picture rather than told its name. Anything else
/// keeps the name it arrived with after the hex: the session is told
/// the path, and a trace called `trace-komodo-….perfetto-trace` says
/// more to it than `3f9a…` would. The name is cleaned to characters a
/// path and a URL both take unquoted, and the hex keeps two uploads of
/// the same name apart. `AttachmentRef` documents the two shapes.
pub fn save_attachment(
&self,
bytes: &[u8],
content_type: &str,
file_name: Option<&str>,
) -> Result<AttachmentRef> {
let name = match crate::media::extension_for(content_type) {
Some(extension) => format!("{}.{extension}", random_hex()),
None => format!("{}-{}", random_hex(), safe_file_name(file_name)),
};
let dir = self.dir().join("attachments");
wg_app_link::private::create_dir(&dir)?;
std::fs::write(dir.join(&name), bytes)
@@ -1442,14 +1455,19 @@ impl SessionManager {
/// Started before the message rather than after, because starting
/// replaces the driver and the driver that takes the message has to be
/// the one with a process behind it.
pub fn send_message(&self, id: &str, text: String, images: Vec<ImageRef>) -> Result<()> {
pub fn send_message(
&self,
id: &str,
text: String,
attachments: Vec<AttachmentRef>,
) -> Result<()> {
// Only `Exited` starts anything -- see `start_if_exited`. A session
// this cannot say has exited keeps the behaviour it always had: the
// message goes to the driver, which answers for it.
self.start_if_exited(id)?;
self.session(id)
.with_context(|| format!("no session {id}"))?
.send_message(text, images);
.send_message(text, attachments);
Ok(())
}
@@ -1767,6 +1785,34 @@ fn names<'a>(all: impl Iterator<Item = &'a str>) -> String {
/// 8 random bytes, hex -- short enough for a URL, unique enough forever at
/// this scale.
/// A file name reduced to what an attachment id may hold: letters, digits,
/// `.`, `-` and `_`, no run of dots that could read as a parent directory,
/// at most [`FILE_NAME_LIMIT`] characters keeping the tail (the extension
/// is what identifies a file), and `file` when nothing usable is left.
fn safe_file_name(name: Option<&str>) -> String {
let cleaned: String = name
.unwrap_or_default()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let cleaned = cleaned.replace("..", "_").trim_matches('.').to_string();
if cleaned.is_empty() {
return "file".to_string();
}
let excess = cleaned.chars().count().saturating_sub(FILE_NAME_LIMIT);
cleaned.chars().skip(excess).collect()
}
/// Longer than any name a person types, shorter than what a filesystem
/// refuses once the hex and a dash are in front of it.
const FILE_NAME_LIMIT: usize = 120;
pub fn random_hex() -> String {
use rand::Rng;
let mut bytes = [0u8; 8];
@@ -2232,7 +2278,15 @@ async fn pump(
// the position is the only way a reader can put it back where it
// happened -- see `Event::PeerMessage::turn_start`.
let event = match event {
Event::MessageTaken { id, text, images } => Event::UserMessage { id, text, images },
Event::MessageTaken {
id,
text,
attachments,
} => Event::UserMessage {
id,
text,
attachments,
},
Event::PeerMessage { from, text, .. } => Event::PeerMessage {
from,
text,