1 Commits

Author SHA1 Message Date
Yeachan-Heo
5b046836b9 Enable local image prompts without breaking text-only CLI flows
The Rust CLI now recognizes explicit local image references in prompt text,
encodes supported image files as base64, and serializes mixed text/image
content blocks for the API. The request conversion path was kept narrow so
existing runtime/session structures remain stable while prompt mode and user
text conversion gain multimodal support.

Constraint: Must support PNG, JPG/JPEG, GIF, and WebP without adding broad runtime abstractions
Constraint: Existing text-only prompt behavior and API tool flows must keep working unchanged
Rejected: Add only explicit --image CLI flags | does not satisfy auto-detect image refs in prompt text
Rejected: Persist native image blocks in runtime session model | broader refactor than needed for prompt support
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep image parsing scoped to outbound user prompt adaptation unless session persistence truly needs multimodal history
Tested: cargo fmt --all; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: Live remote multimodal request against Anthropic API
2026-04-01 00:59:16 +00:00
4 changed files with 410 additions and 271 deletions

View File

@@ -11,7 +11,7 @@ pub use error::ApiError;
pub use sse::{parse_frame, SseParser};
pub use types::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
ImageSource, InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
};

View File

@@ -64,6 +64,9 @@ pub enum InputContentBlock {
Text {
text: String,
},
Image {
source: ImageSource,
},
ToolUse {
id: String,
name: String,
@@ -77,6 +80,14 @@ pub enum InputContentBlock {
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageSource {
#[serde(rename = "type")]
pub kind: String,
pub media_type: String,
pub data: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolResultContentBlock {

View File

@@ -4,8 +4,8 @@ use std::time::Duration;
use api::{
AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OutputContentBlock,
StreamEvent, ToolChoice, ToolDefinition,
ImageSource, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest,
OutputContentBlock, StreamEvent, ToolChoice, ToolDefinition,
};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -75,6 +75,39 @@ async fn send_message_posts_json_and_parses_response() {
assert_eq!(body["tool_choice"]["type"], json!("auto"));
}
#[test]
fn image_content_blocks_serialize_with_base64_source() {
let request = MessageRequest {
model: "claude-3-7-sonnet-latest".to_string(),
max_tokens: 64,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Image {
source: ImageSource {
kind: "base64".to_string(),
media_type: "image/png".to_string(),
data: "AQID".to_string(),
},
}],
}],
system: None,
tools: None,
tool_choice: None,
stream: false,
};
let json = serde_json::to_value(request).expect("request should serialize");
assert_eq!(json["messages"][0]["content"][0]["type"], json!("image"));
assert_eq!(
json["messages"][0]["content"][0]["source"],
json!({
"type": "base64",
"media_type": "image/png",
"data": "AQID"
})
);
}
#[tokio::test]
async fn stream_message_parses_sse_events_with_tool_use() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

@@ -11,8 +11,8 @@ use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use api::{
resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, ImageSource,
InputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
};
@@ -22,9 +22,9 @@ use commands::{
use compat_harness::{extract_manifest, UpstreamPaths};
use render::{Spinner, TerminalRenderer};
use runtime::{
clear_oauth_credentials, format_usd, generate_pkce_pair, generate_state, load_system_prompt,
parse_oauth_callback_request_target, pricing_for_model, save_oauth_credentials, ApiClient,
ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
@@ -36,12 +36,12 @@ const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
const DEFAULT_MAX_TOKENS: u32 = 32;
const DEFAULT_DATE: &str = "2026-03-31";
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const COST_WARNING_FRACTION: f64 = 0.8;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const BUILD_TARGET: Option<&str> = option_env!("TARGET");
const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
type AllowedToolSet = BTreeSet<String>;
const IMAGE_REF_PREFIX: &str = "@";
fn main() {
if let Err(error) = run() {
@@ -71,8 +71,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
output_format,
allowed_tools,
permission_mode,
max_cost_usd,
} => LiveCli::new(model, false, allowed_tools, permission_mode, max_cost_usd)?
} => LiveCli::new(model, false, allowed_tools, permission_mode)?
.run_turn_with_output(&prompt, output_format)?,
CliAction::Login => run_login()?,
CliAction::Logout => run_logout()?,
@@ -80,14 +79,13 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
model,
allowed_tools,
permission_mode,
max_cost_usd,
} => run_repl(model, allowed_tools, permission_mode, max_cost_usd)?,
} => run_repl(model, allowed_tools, permission_mode)?,
CliAction::Help => print_help(),
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Eq)]
enum CliAction {
DumpManifests,
BootstrapPlan,
@@ -106,7 +104,6 @@ enum CliAction {
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
},
Login,
Logout,
@@ -114,7 +111,6 @@ enum CliAction {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
},
// prompt-mode formatting is only supported for non-interactive runs
Help,
@@ -144,7 +140,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut output_format = CliOutputFormat::Text;
let mut permission_mode = default_permission_mode();
let mut wants_version = false;
let mut max_cost_usd: Option<f64> = None;
let mut allowed_tool_values = Vec::new();
let mut rest = Vec::new();
let mut index = 0;
@@ -180,13 +175,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode = parse_permission_mode_arg(value)?;
index += 2;
}
"--max-cost" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --max-cost".to_string())?;
max_cost_usd = Some(parse_max_cost_arg(value)?);
index += 2;
}
flag if flag.starts_with("--output-format=") => {
output_format = CliOutputFormat::parse(&flag[16..])?;
index += 1;
@@ -195,10 +183,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode = parse_permission_mode_arg(&flag[18..])?;
index += 1;
}
flag if flag.starts_with("--max-cost=") => {
max_cost_usd = Some(parse_max_cost_arg(&flag[11..])?);
index += 1;
}
"--allowedTools" | "--allowed-tools" => {
let value = args
.get(index + 1)
@@ -232,7 +216,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model,
allowed_tools,
permission_mode,
max_cost_usd,
});
}
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
@@ -259,7 +242,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format,
allowed_tools,
permission_mode,
max_cost_usd,
})
}
other if !other.starts_with('/') => Ok(CliAction::Prompt {
@@ -268,7 +250,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format,
allowed_tools,
permission_mode,
max_cost_usd,
}),
other => Err(format!("unknown subcommand: {other}")),
}
@@ -332,18 +313,6 @@ fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
.map(permission_mode_from_label)
}
fn parse_max_cost_arg(value: &str) -> Result<f64, String> {
let parsed = value
.parse::<f64>()
.map_err(|_| format!("invalid value for --max-cost: {value}"))?;
if !parsed.is_finite() || parsed <= 0.0 {
return Err(format!(
"--max-cost must be a positive finite USD amount: {value}"
));
}
Ok(parsed)
}
fn permission_mode_from_label(mode: &str) -> PermissionMode {
match mode {
"read-only" => PermissionMode::ReadOnly,
@@ -710,78 +679,22 @@ fn format_permissions_switch_report(previous: &str, next: &str) -> String {
)
}
fn format_cost_report(model: &str, usage: TokenUsage, max_cost_usd: Option<f64>) -> String {
let estimate = usage_cost_estimate(model, usage);
fn format_cost_report(usage: TokenUsage) -> String {
format!(
"Cost
Model {model}
Input tokens {}
Output tokens {}
Cache create {}
Cache read {}
Total tokens {}
Input cost {}
Output cost {}
Cache create usd {}
Cache read usd {}
Estimated cost {}
Budget {}",
Total tokens {}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
usage.total_tokens(),
format_usd(estimate.input_cost_usd),
format_usd(estimate.output_cost_usd),
format_usd(estimate.cache_creation_cost_usd),
format_usd(estimate.cache_read_cost_usd),
format_usd(estimate.total_cost_usd()),
format_budget_line(estimate.total_cost_usd(), max_cost_usd),
)
}
fn usage_cost_estimate(model: &str, usage: TokenUsage) -> runtime::UsageCostEstimate {
pricing_for_model(model).map_or_else(
|| usage.estimate_cost_usd(),
|pricing| usage.estimate_cost_usd_with_pricing(pricing),
)
}
fn usage_cost_total(model: &str, usage: TokenUsage) -> f64 {
usage_cost_estimate(model, usage).total_cost_usd()
}
fn format_budget_line(cost_usd: f64, max_cost_usd: Option<f64>) -> String {
match max_cost_usd {
Some(limit) => format!("{} / {}", format_usd(cost_usd), format_usd(limit)),
None => format!("{} (unlimited)", format_usd(cost_usd)),
}
}
fn budget_notice_message(
model: &str,
usage: TokenUsage,
max_cost_usd: Option<f64>,
) -> Option<String> {
let limit = max_cost_usd?;
let cost = usage_cost_total(model, usage);
if cost >= limit {
Some(format!(
"cost budget exceeded: cumulative={} budget={}",
format_usd(cost),
format_usd(limit)
))
} else if cost >= limit * COST_WARNING_FRACTION {
Some(format!(
"approaching cost budget: cumulative={} budget={}",
format_usd(cost),
format_usd(limit)
))
} else {
None
}
}
fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
format!(
"Session resumed
@@ -925,7 +838,6 @@ fn run_resume_command(
},
default_permission_mode().as_str(),
&status_context(Some(session_path))?,
None,
)),
})
}
@@ -933,7 +845,7 @@ fn run_resume_command(
let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_cost_report("restored-session", usage, None)),
message: Some(format_cost_report(usage)),
})
}
SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
@@ -980,9 +892,8 @@ fn run_repl(
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode, max_cost_usd)?;
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
let mut editor = input::LineEditor::new(" ", slash_command_completion_candidates());
println!("{}", cli.startup_banner());
@@ -1035,7 +946,6 @@ struct LiveCli {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
system_prompt: Vec<String>,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
session: SessionHandle,
@@ -1047,7 +957,6 @@ impl LiveCli {
enable_tools: bool,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?;
let session = create_managed_session_handle()?;
@@ -1063,7 +972,6 @@ impl LiveCli {
model,
allowed_tools,
permission_mode,
max_cost_usd,
system_prompt,
runtime,
session,
@@ -1074,10 +982,9 @@ impl LiveCli {
fn startup_banner(&self) -> String {
format!(
"Rusty Claude CLI\n Model {}\n Permission mode {}\n Cost budget {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.",
"Rusty Claude CLI\n Model {}\n Permission mode {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.",
self.model,
self.permission_mode.as_str(),
self.max_cost_usd.map_or_else(|| "none".to_string(), format_usd),
env::current_dir().map_or_else(
|_| "<unknown>".to_string(),
|path| path.display().to_string(),
@@ -1087,7 +994,6 @@ impl LiveCli {
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
self.enforce_budget_before_turn()?;
let mut spinner = Spinner::new();
let mut stdout = io::stdout();
spinner.tick(
@@ -1098,14 +1004,13 @@ impl LiveCli {
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
match result {
Ok(summary) => {
Ok(_) => {
spinner.finish(
"Claude response complete",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
println!();
self.print_budget_notice(summary.usage);
self.persist_session()?;
Ok(())
}
@@ -1132,16 +1037,13 @@ impl LiveCli {
}
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
self.enforce_budget_before_turn()?;
let client = AnthropicClient::from_auth(resolve_cli_auth_source()?);
let request = MessageRequest {
model: self.model.clone(),
max_tokens: DEFAULT_MAX_TOKENS,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: input.to_string(),
}],
content: prompt_to_content_blocks(input, &env::current_dir()?)?,
}],
system: (!self.system_prompt.is_empty()).then(|| self.system_prompt.join("\n\n")),
tools: None,
@@ -1159,27 +1061,17 @@ impl LiveCli {
})
.collect::<Vec<_>>()
.join("");
let usage = TokenUsage {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
cache_read_input_tokens: response.usage.cache_read_input_tokens,
};
println!(
"{}",
json!({
"message": text,
"model": self.model,
"usage": {
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
"cache_read_input_tokens": usage.cache_read_input_tokens,
},
"cost_usd": usage_cost_total(&self.model, usage),
"cumulative_cost_usd": usage_cost_total(&self.model, usage),
"max_cost_usd": self.max_cost_usd,
"budget_warning": budget_notice_message(&self.model, usage, self.max_cost_usd),
"input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens,
"cache_creation_input_tokens": response.usage.cache_creation_input_tokens,
"cache_read_input_tokens": response.usage.cache_read_input_tokens,
}
})
);
Ok(())
@@ -1249,28 +1141,6 @@ impl LiveCli {
Ok(())
}
fn enforce_budget_before_turn(&self) -> Result<(), Box<dyn std::error::Error>> {
let Some(limit) = self.max_cost_usd else {
return Ok(());
};
let cost = usage_cost_total(&self.model, self.runtime.usage().cumulative_usage());
if cost >= limit {
return Err(format!(
"cost budget exceeded before starting turn: cumulative={} budget={}",
format_usd(cost),
format_usd(limit)
)
.into());
}
Ok(())
}
fn print_budget_notice(&self, usage: TokenUsage) {
if let Some(message) = budget_notice_message(&self.model, usage, self.max_cost_usd) {
eprintln!("warning: {message}");
}
}
fn print_status(&self) {
let cumulative = self.runtime.usage().cumulative_usage();
let latest = self.runtime.usage().current_turn_usage();
@@ -1287,7 +1157,6 @@ impl LiveCli {
},
self.permission_mode.as_str(),
&status_context(Some(&self.session.path)).expect("status context should load"),
self.max_cost_usd,
)
);
}
@@ -1405,10 +1274,7 @@ impl LiveCli {
fn print_cost(&self) {
let cumulative = self.runtime.usage().cumulative_usage();
println!(
"{}",
format_cost_report(&self.model, cumulative, self.max_cost_usd)
);
println!("{}", format_cost_report(cumulative));
}
fn resume_session(
@@ -1686,10 +1552,7 @@ fn format_status_report(
usage: StatusUsage,
permission_mode: &str,
context: &StatusContext,
max_cost_usd: Option<f64>,
) -> String {
let latest_cost = usage_cost_total(model, usage.latest);
let cumulative_cost = usage_cost_total(model, usage.cumulative);
[
format!(
"Status
@@ -1697,27 +1560,19 @@ fn format_status_report(
Permission mode {permission_mode}
Messages {}
Turns {}
Estimated tokens {}
Cost budget {}",
usage.message_count,
usage.turns,
usage.estimated_tokens,
format_budget_line(cumulative_cost, max_cost_usd),
Estimated tokens {}",
usage.message_count, usage.turns, usage.estimated_tokens,
),
format!(
"Usage
Latest total {}
Latest cost {}
Cumulative input {}
Cumulative output {}
Cumulative total {}
Cumulative cost {}",
Cumulative total {}",
usage.latest.total_tokens(),
format_usd(latest_cost),
usage.cumulative.input_tokens,
usage.cumulative.output_tokens,
usage.cumulative.total_tokens(),
format_usd(cumulative_cost),
),
format!(
"Workspace
@@ -2165,7 +2020,7 @@ impl ApiClient for AnthropicRuntimeClient {
let message_request = MessageRequest {
model: self.model.clone(),
max_tokens: DEFAULT_MAX_TOKENS,
messages: convert_messages(&request.messages),
messages: convert_messages(&request.messages)?,
system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
tools: self.enable_tools.then(|| {
filter_tool_specs(self.allowed_tools.as_ref())
@@ -2444,7 +2299,10 @@ fn tool_permission_specs() -> Vec<ToolSpec> {
mvp_tool_specs()
}
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
fn convert_messages(messages: &[ConversationMessage]) -> Result<Vec<InputMessage>, RuntimeError> {
let cwd = env::current_dir().map_err(|error| {
RuntimeError::new(format!("failed to resolve current directory: {error}"))
})?;
messages
.iter()
.filter_map(|message| {
@@ -2455,43 +2313,231 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
let content = message
.blocks
.iter()
.map(|block| match block {
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
},
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
},
})
.collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage {
role: role.to_string(),
content,
})
.try_fold(Vec::new(), |mut acc, block| {
match block {
ContentBlock::Text { text } => {
if message.role == MessageRole::User {
acc.extend(
prompt_to_content_blocks(text, &cwd)
.map_err(RuntimeError::new)?,
);
} else {
acc.push(InputContentBlock::Text { text: text.clone() });
}
}
ContentBlock::ToolUse { id, name, input } => {
acc.push(InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
});
}
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => acc.push(InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
}),
}
Ok::<_, RuntimeError>(acc)
});
match content {
Ok(content) if !content.is_empty() => Some(Ok(InputMessage {
role: role.to_string(),
content,
})),
Ok(_) => None,
Err(error) => Some(Err(error)),
}
})
.collect()
}
fn prompt_to_content_blocks(input: &str, cwd: &Path) -> Result<Vec<InputContentBlock>, String> {
let mut blocks = Vec::new();
let mut text_buffer = String::new();
let mut chars = input.char_indices().peekable();
while let Some((index, ch)) = chars.next() {
if ch == '!' && input[index..].starts_with("![") {
if let Some((alt_end, path_start, path_end)) = parse_markdown_image_ref(input, index) {
let _ = alt_end;
flush_text_block(&mut blocks, &mut text_buffer);
let path = &input[path_start..path_end];
blocks.push(load_image_block(path, cwd)?);
while let Some((next_index, _)) = chars.peek() {
if *next_index < path_end + 1 {
let _ = chars.next();
} else {
break;
}
}
continue;
}
}
if ch == '@' && is_ref_boundary(input[..index].chars().next_back()) {
let path_end = find_path_end(input, index + 1);
if path_end > index + 1 {
let candidate = &input[index + 1..path_end];
if looks_like_image_ref(candidate, cwd) {
flush_text_block(&mut blocks, &mut text_buffer);
blocks.push(load_image_block(candidate, cwd)?);
while let Some((next_index, _)) = chars.peek() {
if *next_index < path_end {
let _ = chars.next();
} else {
break;
}
}
continue;
}
}
}
text_buffer.push(ch);
}
flush_text_block(&mut blocks, &mut text_buffer);
if blocks.is_empty() {
blocks.push(InputContentBlock::Text {
text: input.to_string(),
});
}
Ok(blocks)
}
fn parse_markdown_image_ref(input: &str, start: usize) -> Option<(usize, usize, usize)> {
let after_bang = input.get(start + 2..)?;
let alt_end_offset = after_bang.find("](")?;
let path_start = start + 2 + alt_end_offset + 2;
let remainder = input.get(path_start..)?;
let path_end_offset = remainder.find(')')?;
let path_end = path_start + path_end_offset;
Some((start + 2 + alt_end_offset, path_start, path_end))
}
fn is_ref_boundary(ch: Option<char>) -> bool {
ch.is_none_or(char::is_whitespace)
}
fn find_path_end(input: &str, start: usize) -> usize {
input[start..]
.char_indices()
.find_map(|(offset, ch)| (ch.is_whitespace()).then_some(start + offset))
.unwrap_or(input.len())
}
fn looks_like_image_ref(candidate: &str, cwd: &Path) -> bool {
let resolved = resolve_prompt_path(candidate, cwd);
media_type_for_path(Path::new(candidate)).is_some()
|| resolved.is_file()
|| candidate.contains(std::path::MAIN_SEPARATOR)
|| candidate.starts_with("./")
|| candidate.starts_with("../")
}
fn flush_text_block(blocks: &mut Vec<InputContentBlock>, text_buffer: &mut String) {
if text_buffer.is_empty() {
return;
}
blocks.push(InputContentBlock::Text {
text: std::mem::take(text_buffer),
});
}
fn load_image_block(path_ref: &str, cwd: &Path) -> Result<InputContentBlock, String> {
let resolved = resolve_prompt_path(path_ref, cwd);
let media_type = media_type_for_path(&resolved).ok_or_else(|| {
format!(
"unsupported image format for reference {IMAGE_REF_PREFIX}{path_ref}; supported: png, jpg, jpeg, gif, webp"
)
})?;
let bytes = fs::read(&resolved).map_err(|error| {
format!(
"failed to read image reference {}: {error}",
resolved.display()
)
})?;
Ok(InputContentBlock::Image {
source: ImageSource {
kind: "base64".to_string(),
media_type: media_type.to_string(),
data: encode_base64(&bytes),
},
})
}
fn resolve_prompt_path(path_ref: &str, cwd: &Path) -> PathBuf {
let path = Path::new(path_ref);
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
}
fn media_type_for_path(path: &Path) -> Option<&'static str> {
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
match extension.as_str() {
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
_ => None,
}
}
fn encode_base64(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::new();
let mut index = 0;
while index + 3 <= bytes.len() {
let block = (u32::from(bytes[index]) << 16)
| (u32::from(bytes[index + 1]) << 8)
| u32::from(bytes[index + 2]);
output.push(TABLE[((block >> 18) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 12) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 6) & 0x3F) as usize] as char);
output.push(TABLE[(block & 0x3F) as usize] as char);
index += 3;
}
match bytes.len().saturating_sub(index) {
1 => {
let block = u32::from(bytes[index]) << 16;
output.push(TABLE[((block >> 18) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 12) & 0x3F) as usize] as char);
output.push('=');
output.push('=');
}
2 => {
let block = (u32::from(bytes[index]) << 16) | (u32::from(bytes[index + 1]) << 8);
output.push(TABLE[((block >> 18) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 12) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 6) & 0x3F) as usize] as char);
output.push('=');
}
_ => {}
}
output
}
fn print_help() {
println!("rusty-claude-cli v{VERSION}");
println!();
println!("Usage:");
println!(" rusty-claude-cli [--model MODEL] [--max-cost USD] [--allowedTools TOOL[,TOOL...]]");
println!(" rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]");
println!(" Start the interactive REPL");
println!(" rusty-claude-cli [--model MODEL] [--max-cost USD] [--output-format text|json] prompt TEXT");
println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT");
println!(" Send one prompt and exit");
println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT");
println!(" Shorthand non-interactive prompt mode");
@@ -2507,7 +2553,6 @@ fn print_help() {
println!(" --model MODEL Override the active model");
println!(" --output-format FORMAT Non-interactive output format: text or json");
println!(" --permission-mode MODE Set read-only, workspace-write, or danger-full-access");
println!(" --max-cost USD Warn at 80% of budget and stop at/exceeding the budget");
println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)");
println!(" --version, -V Print version and build information locally");
println!();
@@ -2534,17 +2579,18 @@ fn print_help() {
#[cfg(test)]
mod tests {
use super::{
budget_notice_message, filter_tool_specs, format_compact_report, format_cost_report,
format_init_report, format_model_report, format_model_switch_report,
format_permissions_report, format_permissions_switch_report, format_resume_report,
format_status_report, format_tool_call_start, format_tool_result,
normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report,
render_init_claude_md, render_memory_report, render_repl_help,
resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand,
StatusUsage, DEFAULT_MODEL,
filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
format_model_report, format_model_switch_report, format_permissions_report,
format_permissions_switch_report, format_resume_report, format_status_report,
format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args,
parse_git_status_metadata, render_config_report, render_init_claude_md,
render_memory_report, render_repl_help, resume_supported_slash_commands, status_context,
CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
};
use api::InputContentBlock;
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn defaults_to_repl_when_no_args() {
@@ -2554,7 +2600,6 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: None,
}
);
}
@@ -2574,7 +2619,6 @@ mod tests {
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: None,
}
);
}
@@ -2596,7 +2640,6 @@ mod tests {
output_format: CliOutputFormat::Json,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: None,
}
);
}
@@ -2622,32 +2665,10 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::ReadOnly,
max_cost_usd: None,
}
);
}
#[test]
fn parses_max_cost_flag() {
let args = vec!["--max-cost=1.25".to_string()];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::Repl {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: Some(1.25),
}
);
}
#[test]
fn rejects_invalid_max_cost_flag() {
let error = parse_args(&["--max-cost".to_string(), "0".to_string()])
.expect_err("zero max cost should be rejected");
assert!(error.contains("--max-cost must be a positive finite USD amount"));
}
#[test]
fn parses_allowed_tools_flags_with_aliases_and_lists() {
let args = vec![
@@ -2666,7 +2687,6 @@ mod tests {
.collect()
),
permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: None,
}
);
}
@@ -2824,24 +2844,18 @@ mod tests {
#[test]
fn cost_report_uses_sectioned_layout() {
let report = format_cost_report(
"claude-sonnet",
runtime::TokenUsage {
input_tokens: 20,
output_tokens: 8,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 1,
},
None,
);
let report = format_cost_report(runtime::TokenUsage {
input_tokens: 20,
output_tokens: 8,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 1,
});
assert!(report.contains("Cost"));
assert!(report.contains("Input tokens 20"));
assert!(report.contains("Output tokens 8"));
assert!(report.contains("Cache create 3"));
assert!(report.contains("Cache read 1"));
assert!(report.contains("Total tokens 32"));
assert!(report.contains("Estimated cost"));
assert!(report.contains("Budget $0.0010 (unlimited)"));
}
#[test]
@@ -2923,7 +2937,6 @@ mod tests {
project_root: Some(PathBuf::from("/tmp")),
git_branch: Some("main".to_string()),
},
Some(1.0),
);
assert!(status.contains("Status"));
assert!(status.contains("Model claude-sonnet"));
@@ -2931,7 +2944,6 @@ mod tests {
assert!(status.contains("Messages 7"));
assert!(status.contains("Latest total 10"));
assert!(status.contains("Cumulative total 31"));
assert!(status.contains("Cost budget $0.0009 / $1.0000"));
assert!(status.contains("Cwd /tmp/project"));
assert!(status.contains("Project root /tmp"));
assert!(status.contains("Git branch main"));
@@ -2940,22 +2952,6 @@ mod tests {
assert!(status.contains("Memory files 4"));
}
#[test]
fn budget_notice_warns_near_limit() {
let message = budget_notice_message(
"claude-sonnet",
runtime::TokenUsage {
input_tokens: 60_000,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
Some(1.0),
)
.expect("budget warning expected");
assert!(message.contains("approaching cost budget"));
}
#[test]
fn config_report_supports_section_views() {
let report = render_config_report(Some("env")).expect("config report should render");
@@ -2993,8 +2989,8 @@ mod tests {
fn status_context_reads_real_workspace_metadata() {
let context = status_context(None).expect("status context should load");
assert!(context.cwd.is_absolute());
assert!(context.discovered_config_files >= context.loaded_config_files);
assert!(context.discovered_config_files >= 1);
assert!(context.discovered_config_files >= 3);
assert!(context.loaded_config_files <= context.discovered_config_files);
}
#[test]
@@ -3077,11 +3073,110 @@ mod tests {
},
];
let converted = super::convert_messages(&messages);
let converted = super::convert_messages(&messages).expect("messages should convert");
assert_eq!(converted.len(), 3);
assert_eq!(converted[1].role, "assistant");
assert_eq!(converted[2].role, "user");
}
#[test]
fn prompt_to_content_blocks_keeps_text_only_prompt() {
let blocks = super::prompt_to_content_blocks("hello world", Path::new("."))
.expect("text prompt should parse");
assert_eq!(
blocks,
vec![InputContentBlock::Text {
text: "hello world".to_string()
}]
);
}
#[test]
fn prompt_to_content_blocks_embeds_at_image_refs() {
let temp = temp_fixture_dir("at-image-ref");
let image_path = temp.join("sample.png");
std::fs::write(&image_path, [1_u8, 2, 3]).expect("fixture write");
let prompt = format!("describe @{} please", image_path.display());
let blocks = super::prompt_to_content_blocks(&prompt, Path::new("."))
.expect("image ref should parse");
assert!(matches!(
&blocks[0],
InputContentBlock::Text { text } if text == "describe "
));
assert!(matches!(
&blocks[1],
InputContentBlock::Image { source }
if source.kind == "base64"
&& source.media_type == "image/png"
&& source.data == "AQID"
));
assert!(matches!(
&blocks[2],
InputContentBlock::Text { text } if text == " please"
));
}
#[test]
fn prompt_to_content_blocks_embeds_markdown_image_refs() {
let temp = temp_fixture_dir("markdown-image-ref");
let image_path = temp.join("sample.webp");
std::fs::write(&image_path, [255_u8]).expect("fixture write");
let prompt = format!("see ![asset]({}) now", image_path.display());
let blocks = super::prompt_to_content_blocks(&prompt, Path::new("."))
.expect("markdown image ref should parse");
assert!(matches!(
&blocks[1],
InputContentBlock::Image { source }
if source.media_type == "image/webp" && source.data == "/w=="
));
}
#[test]
fn prompt_to_content_blocks_rejects_unsupported_formats() {
let temp = temp_fixture_dir("unsupported-image-ref");
let image_path = temp.join("sample.bmp");
std::fs::write(&image_path, [1_u8]).expect("fixture write");
let prompt = format!("describe @{}", image_path.display());
let error = super::prompt_to_content_blocks(&prompt, Path::new("."))
.expect_err("unsupported image ref should fail");
assert!(error.contains("unsupported image format"));
}
#[test]
fn convert_messages_expands_user_text_image_refs() {
let temp = temp_fixture_dir("convert-message-image-ref");
let image_path = temp.join("sample.gif");
std::fs::write(&image_path, [71_u8, 73, 70]).expect("fixture write");
let messages = vec![ConversationMessage::user_text(format!(
"inspect @{}",
image_path.display()
))];
let converted = super::convert_messages(&messages).expect("messages should convert");
assert_eq!(converted.len(), 1);
assert!(matches!(
&converted[0].content[1],
InputContentBlock::Image { source }
if source.media_type == "image/gif" && source.data == "R0lG"
));
}
fn temp_fixture_dir(label: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should advance")
.as_nanos();
let path = std::env::temp_dir().join(format!("rusty-claude-cli-{label}-{unique}"));
std::fs::create_dir_all(&path).expect("temp dir should exist");
path
}
#[test]
fn repl_help_mentions_history_completion_and_multiline() {
let help = render_repl_help();