Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ fuzzy-matcher = "0.3"
schemars = "1.1"
similar = "2.4"
glob = "0.3"
mime_guess = "2.0"
mime = "0.3"
rustyline = "14.0"
grep = "0.3"
ignore = "0.4"
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ A fast, minimal CLI for interacting with LLMs via OpenRouter.

- **Fuzzy model selection** - `/sonnet`, `/gpt4`, `/haiku`
- **File references** - `@file.txt` to include, `!file.txt` to write, `@!file.txt` to read+write
- **Multimodal inputs** - `@file.pdf`, `@file.png`, `@file.jpg`, `@file.gif`, and `@file.webp` are sent as native attachments
- **Interactive chat** - Multi-turn conversations with `--chat`
- **STDIN piping** - `cat code.rs | zo "review this"`
- **Streaming output** - Syntax-highlighted markdown in real-time
Expand All @@ -23,6 +24,10 @@ zo /sonnet "Explain async/await"
# Include files
zo "@main.rs Review this code"

# Include a PDF or image
zo "@document.pdf Summarize this"
zo "@diagram.png Describe this image"

#$ Pipeline
git diff | zo 'Summarize these changes'

Expand Down
13 changes: 9 additions & 4 deletions src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@

use anyhow::{Context, Result};
use openrouter_rs::OpenRouterClient;
use openrouter_rs::api::chat::Content;
use std::io::{self, BufRead, Write};

use crate::config::InlineColors;
use crate::input::parse_file_patterns;
use crate::models::ModelEntry;
use crate::readline::ChatReadline;
use crate::session::{Session, build_user_message};
use crate::session::{Session, build_typed_user_message};
use crate::shell::ShellRuntime;
use crate::tools::ToolAccess;

Expand Down Expand Up @@ -72,7 +73,7 @@ pub async fn run_chat_session(
.context("Failed to parse file patterns from initial prompt")?;

// Build first message combining file references, prompt, and STDIN
let first_message = build_user_message(
let first_message = build_typed_user_message(
&initial_file_refs,
&final_initial_prompt,
options.initial_stdin.as_deref(),
Expand Down Expand Up @@ -105,7 +106,11 @@ pub async fn run_chat_session(
println!("Type 'exit', 'quit', or press Ctrl+D to end the conversation.\n");

// Send first message only if there's content
let has_initial_content = !first_message.trim().is_empty();
let has_initial_content = match &first_message.content {
Content::Text(content) => !content.trim().is_empty(),
Content::Parts(parts) => !parts.is_empty(),
_ => true,
};
if has_initial_content {
match session.send_message(first_message).await {
Ok(_) => {
Expand Down Expand Up @@ -155,7 +160,7 @@ pub async fn run_chat_session(
}

// Build message with file references (use expanded prompt)
let message = build_user_message(&file_refs, &final_input_prompt, None);
let message = build_typed_user_message(&file_refs, &final_input_prompt, None);

// Send message and get response
match session.send_message(message).await {
Expand Down
176 changes: 147 additions & 29 deletions src/input.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use anyhow::{Context, Result, bail};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use glob::glob;
use mime::Mime;
use std::collections::HashMap;
use std::fs;
use std::io::{self, IsTerminal, Read};
Expand Down Expand Up @@ -246,14 +248,21 @@ fn resolve_file_pattern_impl(pattern: &str, allow_missing: bool) -> Result<Vec<S
Ok(matches)
}

/// The content of a file referenced with @-syntax
#[derive(Debug, Clone, PartialEq)]
pub enum FileReferenceKind {
/// UTF-8 text content to include in the regular text context.
Text(String),
/// PDF or image content encoded as an OpenRouter data URL.
Attachment { mime_type: String, data_url: String },
}

/// A file referenced with @-syntax
#[derive(Debug, Clone, PartialEq)]
pub struct FileReference {
/// The filename as specified by the user
pub filename: String,

/// The contents of the file
pub content: String,
pub kind: FileReferenceKind,
}

/// An output file specified with !file or @!file syntax
Expand Down Expand Up @@ -288,6 +297,61 @@ pub struct ParsedInput {
pub output_files: Vec<OutputFileSpec>,
}

fn attachment_mime(path: &str) -> Option<&'static str> {
match mime_guess::from_path(path)
.first()
.as_ref()
.map(Mime::essence_str)
{
Some("application/pdf") => Some("application/pdf"),
Some("image/png") => Some("image/png"),
Some("image/jpeg") => Some("image/jpeg"),
Some("image/gif") => Some("image/gif"),
Some("image/webp") => Some("image/webp"),
_ => None,
}
}

/// Read an input file as text or an encoded PDF/image attachment.
///
/// `is_input_output` is true for `@!` references, which may name a new text file.
fn read_input_file(filename: &str, is_input_output: bool) -> Result<FileReference> {
if let Some(attachment_mime) = attachment_mime(filename) {
if is_input_output {
bail!("Input/output file '{}' must be a text file", filename);
}
let bytes = fs::read(filename).with_context(|| {
format!(
"Could not read file '{}'. Make sure it exists and is readable.",
filename
)
})?;
let data_url = format!("data:{attachment_mime};base64,{}", BASE64.encode(bytes));
return Ok(FileReference {
filename: filename.to_string(),
kind: FileReferenceKind::Attachment {
mime_type: attachment_mime.to_string(),
data_url,
},
});
}

let content = if is_input_output {
read_file_if_exists(filename)?
} else {
fs::read_to_string(filename).with_context(|| {
format!(
"Could not read file '{}'. Make sure the file exists and is readable as UTF-8.",
filename
)
})?
};
Ok(FileReference {
filename: filename.to_string(),
kind: FileReferenceKind::Text(content),
})
}

/// Parse all file patterns (@, !, @!) in a single pass
///
/// This unified parser handles:
Expand Down Expand Up @@ -438,23 +502,10 @@ pub fn parse_file_patterns(
// Build input file references (@ and @!)
if pattern.syntax_type.is_input() {
for filename in &pattern.resolved_files {
let content = if pattern.syntax_type == FileSyntaxType::InputOutput {
// @! syntax - file might not exist yet
read_file_if_exists(filename)?
} else {
// @ syntax - file must exist
fs::read_to_string(filename).with_context(|| {
format!(
"Could not read file '{}'. Make sure the file exists and is readable.",
filename
)
})?
};

file_references.push(FileReference {
filename: filename.clone(),
content,
});
file_references.push(read_input_file(
filename,
pattern.syntax_type == FileSyntaxType::InputOutput,
)?);
}
}

Expand Down Expand Up @@ -778,12 +829,65 @@ mod tests {
let (_prompt, refs, _outputs) = result.unwrap();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].filename, "test_file_single.txt");
assert_eq!(refs[0].content.trim(), "test content");
assert!(
matches!(&refs[0].kind, FileReferenceKind::Text(content) if content.trim() == "test content")
);

// Cleanup
std::fs::remove_file(temp_file).ok();
}

#[test]
fn test_parse_file_patterns_classifies_pdf() {
// Create a temporary file for testing
use std::io::Write;
let pdf = "test_attachment.pdf";
let mut file = std::fs::File::create(pdf).unwrap();
writeln!(file, "pdf").unwrap();

let result = parse_file_patterns("@test_attachment.pdf");
assert!(result.is_ok());
let (_, refs, _) = result.unwrap();
assert_eq!(refs.len(), 1);
assert!(matches!(
&refs[0].kind,
FileReferenceKind::Attachment { mime_type, .. } if mime_type == "application/pdf"
));
assert!(
matches!(&refs[0].kind, FileReferenceKind::Attachment { data_url, .. }
if data_url.starts_with("data:application/pdf;base64,"))
);

// Cleanup
std::fs::remove_file(pdf).ok();
}

#[test]
fn test_parse_file_patterns_classifies_image() {
// Create a temporary file for testing
use std::io::Write;
let image = "test_attachment.png";
let mut file = std::fs::File::create(image).unwrap();
writeln!(file, "png").unwrap();

let result = parse_file_patterns("@test_attachment.png");
assert!(result.is_ok());
let (_, refs, _) = result.unwrap();
assert_eq!(refs.len(), 1);
assert!(matches!(
&refs[0].kind,
FileReferenceKind::Attachment { mime_type, .. } if mime_type == "image/png"
));
assert!(matches!(
&refs[0].kind,
FileReferenceKind::Attachment { data_url, .. }
if data_url.starts_with("data:image/png;base64,")
));

// Cleanup
std::fs::remove_file(image).ok();
}

#[test]
fn test_parse_file_patterns_input_multiple_files() {
// Create temporary files for testing
Expand Down Expand Up @@ -813,8 +917,12 @@ mod tests {
.find(|r| r.filename == "test_file_multi2.txt")
.unwrap();

assert_eq!(file1_ref.content.trim(), "content 1");
assert_eq!(file2_ref.content.trim(), "content 2");
assert!(
matches!(&file1_ref.kind, FileReferenceKind::Text(content) if content.trim() == "content 1")
);
assert!(
matches!(&file2_ref.kind, FileReferenceKind::Text(content) if content.trim() == "content 2")
);

// Cleanup
std::fs::remove_file(temp_file1).ok();
Expand Down Expand Up @@ -854,7 +962,9 @@ mod tests {
let (_prompt, refs, _outputs) = result.unwrap();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].filename, "test_file_end.txt");
assert_eq!(refs[0].content.trim(), "ending content");
assert!(
matches!(&refs[0].kind, FileReferenceKind::Text(content) if content.trim() == "ending content")
);

// Cleanup
std::fs::remove_file(temp_file).ok();
Expand Down Expand Up @@ -983,7 +1093,9 @@ mod tests {
// Should have one file reference (because @! includes input)
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].filename, "test_output_ref.txt");
assert_eq!(refs[0].content.trim(), "test content");
assert!(
matches!(&refs[0].kind, FileReferenceKind::Text(content) if content.trim() == "test content")
);

// Should also have one output file
assert_eq!(outputs.len(), 1);
Expand Down Expand Up @@ -1027,8 +1139,12 @@ mod tests {
.find(|r| r.filename == "test_output_ref2.txt")
.unwrap();

assert_eq!(input_ref.content.trim(), "input content");
assert_eq!(output_ref.content.trim(), "output content");
assert!(
matches!(&input_ref.kind, FileReferenceKind::Text(content) if content.trim() == "input content")
);
assert!(
matches!(&output_ref.kind, FileReferenceKind::Text(content) if content.trim() == "output content")
);

// Should have one output file (only @! file)
assert_eq!(outputs.len(), 1);
Expand Down Expand Up @@ -1674,7 +1790,7 @@ mod tests {
}
#[cfg(test)]
mod integration_test {
use crate::input::parse_input;
use crate::input::{FileReferenceKind, parse_input};
use std::fs;
use std::io::Write;

Expand Down Expand Up @@ -1712,7 +1828,9 @@ mod integration_test {
"Should have 1 file reference"
);
assert_eq!(parsed.file_references[0].filename, test_file);
assert_eq!(parsed.file_references[0].content.trim(), "existing content");
assert!(
matches!(&parsed.file_references[0].kind, FileReferenceKind::Text(content) if content.trim() == "existing content")
);

// Cleanup
fs::remove_file(test_file).ok();
Expand Down
8 changes: 6 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,11 @@ fn display_debug_info(
if !parsed_input.file_references.is_empty() {
println!("\nFile References:");
for file_ref in &parsed_input.file_references {
println!(" - {}", file_ref.filename);
if let input::FileReferenceKind::Attachment { mime_type, .. } = &file_ref.kind {
println!(" - {} ({})", file_ref.filename, mime_type);
} else {
println!(" - {} (text)", file_ref.filename);
}
}
} else {
println!("\nFile References: (none)");
Expand Down Expand Up @@ -630,7 +634,7 @@ async fn main() -> Result<()> {
}

// Build user message
let user_message = session::build_user_message(
let user_message = session::build_typed_user_message(
&parsed_input.file_references,
&parsed_input.prompt,
parsed_input.stdin_content.as_deref(),
Expand Down
Loading