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:
1 parent
4bc69e8f9c
commit
6180663f14
25 files changed
+672
-165
No files matched your search
@@ -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!({
|
||||
|
||||
Reference in new issue
Block a user