grok-build-upstream-mirror/crates/codegen/xai-grok-pager-render/src/prompt_images.rs
grokkybara[bot] 8adf9013a0 Synced from monorepo
Changes:
- grok-shell: request workspaces:read/write OAuth2 scopes
- security: fix SSRF bypass via HTTP redirect in hook runner
- fix(grok-build): enterprise STT WSS URL + API-key voice bearer
- Harden identity-change purge and sync-marker invariants
- sandbox + workspace-server: delete the legacy ready-file arm
- Show billing URL when browser cannot open
- fix(pager): show folder-trust UI in minimal mode
- fix(pager): drain task_backgrounded before no-wait headless exit
- grok-agent-sdk: stop SDK-spawned agents from staging self-updates they can never adopt
- Split settings_modal into directory module
- Delegate VS Code SSH file links
- grok-shell: release the workspace session binding when a session is removed
- keep skills reachable when their name collides with a client builtin
- Preserve semantic link targets
2026-07-16 20:27:30 +01:00

4808 lines
179 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Shared prompt-side image types and helpers.
//!
//! This is the single source of truth for image data attached to the prompt.
//! Both the view layer ([`crate::views::prompt_widget`]) and the app/dispatch
//! layer ([`crate::app::dispatch`]) consume these types, so they live here
//! rather than inside any single view or app module.
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use std::time::Instant;
use xai_ratatui_textarea::ElementId;
/// Tracing target for image-pipeline diagnostics.
///
/// Filter with `RUST_LOG=prompt_images=debug` to surface logs from
/// `insert_image`, `sync_images_with_textarea`, and the drop-classifier
/// (`try_read_dropped_paths`) — useful for RCing cross-platform paste
/// regressions from a single user's session capture.
pub const PROMPT_IMAGES_TRACING_TARGET: &str = "prompt_images";
// -------------------------------------------------------------------------
// Scrollable image viewer state
// -------------------------------------------------------------------------
/// State for a modal image viewer.
///
/// Supports deferred loading: [`open_from_path_deferred`] returns instantly
/// with `loading: true`, then [`finish_loading`] performs the heavy I/O on
/// the next tick so the UI can show a spinner while the file is read.
pub struct ImageViewerState {
/// Original encoded image bytes.
pub image_bytes: Vec<u8>,
/// Encoded bytes prepared for terminal display (e.g. JPEG converted to PNG for Kitty).
pub display_bytes: Vec<u8>,
pub mime_type: String,
pub image_width: u32,
pub image_height: u32,
pub display_number: usize,
/// Override title (e.g. filename). When `None`, uses "Image #N".
pub title: Option<String>,
/// Deferred loading in progress; display bytes not yet available.
pub loading: bool,
/// Identity used by the shared terminal overlay upload owner.
pub overlay_owner_id: u64,
/// File path for deferred loading; consumed by [`finish_loading`].
source_path: Option<PathBuf>,
/// Shared modal chrome state (close button hit-test, hover, etc.).
pub modal_state: crate::modal_window_state::ModalWindowState,
}
pub fn decode_image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
// Unrestricted: accepts any format `image` recognises so paste previews
// don't fail for non-allow-listed types.
xai_grok_tools::util::image_validate::validate_image_bytes_unrestricted(bytes, false)
.ok()
.map(|(w, h, _)| (w, h))
}
fn is_decodable_image(bytes: &[u8]) -> bool {
decode_image_dimensions(bytes).is_some()
}
impl ImageViewerState {
/// Create a viewer for a prompt-side image. Loads synchronously since
/// prompt images already have bytes in memory.
pub fn open(image: &PastedImage) -> Option<Self> {
let bytes = if let Some(ref b) = image.encoded_bytes {
b.to_vec()
} else if let Some(ref path) = image.session_image_path {
std::fs::read(path).ok()?
} else {
return None;
};
let (w, h) = decode_image_dimensions(&bytes)?;
let display_bytes = crate::terminal::image::prepare_overlay_image_bytes(&bytes)
.unwrap_or_else(|| bytes.clone());
Some(Self {
image_bytes: bytes,
display_bytes,
mime_type: image.mime_type.clone(),
image_width: w,
image_height: h,
display_number: image.display_number,
title: None,
loading: false,
overlay_owner_id: image.preview.identity(),
source_path: None,
modal_state: Default::default(),
})
}
/// Create a viewer from a file path, loading synchronously. Prefer
/// [`open_from_path_deferred`] from input handlers to avoid blocking.
pub fn open_from_path(path: &std::path::Path) -> Option<Self> {
let bytes = std::fs::read(path).ok()?;
let (w, h) = decode_image_dimensions(&bytes)?;
let display_bytes = crate::terminal::image::prepare_overlay_image_bytes(&bytes)
.unwrap_or_else(|| bytes.clone());
let mime_type = xai_grok_shared::clipboard::mime_from_bytes(&bytes).to_owned();
let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned());
Some(Self {
image_bytes: bytes,
display_bytes,
mime_type,
image_width: w,
image_height: h,
display_number: 1,
title: file_name,
loading: false,
overlay_owner_id: crate::terminal::overlay::next_owner_id(),
source_path: None,
modal_state: Default::default(),
})
}
/// Create a loading-state viewer for a file path.
///
/// Returns immediately with `loading: true`. A background thread
/// runs [`load_image_data`] and the tick handler polls for the result,
/// then calls [`apply_loaded`] to complete the load.
pub fn open_from_path_deferred(path: &std::path::Path) -> Self {
Self {
image_bytes: Vec::new(),
display_bytes: Vec::new(),
mime_type: String::new(),
image_width: 0,
image_height: 0,
display_number: 1,
title: path.file_name().map(|n| n.to_string_lossy().into_owned()),
loading: true,
overlay_owner_id: crate::terminal::overlay::next_owner_id(),
source_path: Some(path.to_path_buf()),
modal_state: Default::default(),
}
}
/// Take the source path for background loading. Returns `None` if
/// already taken or not in loading state.
pub fn take_source_path(&mut self) -> Option<PathBuf> {
if self.loading {
self.source_path.take()
} else {
None
}
}
/// Apply loaded data from a background thread.
pub fn apply_loaded(&mut self, data: LoadedImageData) {
self.image_bytes = data.image_bytes;
self.display_bytes = data.display_bytes;
self.mime_type = data.mime_type;
self.image_width = data.image_width;
self.image_height = data.image_height;
self.loading = false;
}
/// Complete the deferred load synchronously (convenience for tests).
/// Returns `false` on failure.
pub fn finish_loading(&mut self) -> bool {
if !self.loading {
return true;
}
let Some(path) = self.source_path.take() else {
return false;
};
match load_image_data(&path) {
ImageLoadResult::Loaded(data) => {
self.apply_loaded(data);
true
}
ImageLoadResult::Failed => false,
}
}
}
/// Result of a background image load.
pub enum ImageLoadResult {
Loaded(LoadedImageData),
Failed,
}
/// Data loaded from an image file, ready to apply to the viewer.
pub struct LoadedImageData {
pub image_bytes: Vec<u8>,
pub display_bytes: Vec<u8>,
pub mime_type: String,
pub image_width: u32,
pub image_height: u32,
}
/// Load image data from a file path. This is the heavy work (file read,
/// decode, format conversion) that runs on a background thread.
pub fn load_image_data(path: &std::path::Path) -> ImageLoadResult {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
tracing::warn!("image viewer: failed to read {}: {e}", path.display());
return ImageLoadResult::Failed;
}
};
let (w, h) = match decode_image_dimensions(&bytes) {
Some(dims) => dims,
None => {
tracing::warn!("image viewer: failed to decode {}", path.display());
return ImageLoadResult::Failed;
}
};
let display_bytes = crate::terminal::image::prepare_overlay_image_bytes(&bytes)
.unwrap_or_else(|| bytes.clone());
let mime_type = xai_grok_shared::clipboard::mime_from_bytes(&bytes).to_owned();
ImageLoadResult::Loaded(LoadedImageData {
image_bytes: bytes,
display_bytes,
mime_type,
image_width: w,
image_height: h,
})
}
// -------------------------------------------------------------------------
// Video viewer state
// -------------------------------------------------------------------------
/// Target frames per second for terminal video playback.
const VIDEO_FPS: f64 = 10.0;
/// Maximum pixel width for extracted frames.
const VIDEO_MAX_WIDTH: u32 = 640;
/// Modal video viewer state.
///
/// Holds pre-extracted frames and playback position. The rendering path
/// reuses the same `post_flush_escapes` pipeline as the image viewer.
pub struct VideoViewerState {
/// Pre-extracted frames (PNG for Kitty, JPEG for iTerm2).
pub frames: Vec<Vec<u8>>,
/// Current frame index.
current_frame: usize,
/// Whether playback is active.
pub playing: bool,
/// Playback frame rate.
pub fps: f64,
/// Last frame advance timestamp (for pacing).
last_frame_time: Instant,
/// Original video pixel dimensions.
pub video_width: u32,
pub video_height: u32,
/// Total duration in seconds.
pub duration_secs: f64,
/// Display title (file name).
pub title: Option<String>,
}
impl VideoViewerState {
/// Minimal viewer for tests (pager and render unit tests); the real
/// `open_from_path` needs ffmpeg and a graphics-capable terminal, neither
/// available under `cargo test`. Public so dependent crates can construct
/// a viewer without pulling in decode/graphics.
pub fn test_stub() -> Self {
Self {
frames: vec![Vec::new()],
current_frame: 0,
playing: false,
fps: 1.0,
last_frame_time: Instant::now(),
video_width: 1,
video_height: 1,
duration_secs: 0.0,
title: None,
}
}
/// Open a video file for playback. Returns `None` if ffmpeg is
/// unavailable or the video cannot be decoded.
///
/// Extracts all frames upfront — for short videos (515s at 10fps)
/// this is 50150 frames and takes ~13 seconds.
pub fn open_from_path(path: &std::path::Path) -> Option<Self> {
use crate::terminal::image::{GraphicsProtocol, detect_graphics_protocol};
let protocol = detect_graphics_protocol();
if protocol == GraphicsProtocol::None {
return None;
}
let (width, height, duration, fps) = ffprobe_metadata(path)?;
let target_fps = VIDEO_FPS.min(fps);
// PNG for Kitty (required), JPEG for iTerm2 (smaller).
let ext = match protocol {
GraphicsProtocol::Kitty => "png",
GraphicsProtocol::ITerm2 => "jpg",
GraphicsProtocol::None => return None,
};
let vf = if width > VIDEO_MAX_WIDTH {
format!("fps={target_fps},scale={}:-2", VIDEO_MAX_WIDTH)
} else {
format!("fps={target_fps}")
};
let tmp_dir = make_temp_dir();
std::fs::create_dir_all(&tmp_dir).ok()?;
let mut ffmpeg_cmd = std::process::Command::new("ffmpeg");
ffmpeg_cmd
.args(["-hide_banner", "-loglevel", "error", "-i"])
.arg(path)
.args(["-vf", &vf, "-q:v", "5"])
.arg(tmp_dir.join(format!("%06d.{ext}")))
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
xai_tty_utils::detach_std_command(&mut ffmpeg_cmd);
let status = ffmpeg_cmd.status();
match &status {
Err(e) => tracing::debug!("ffmpeg not available: {e}"),
Ok(s) if !s.success() => tracing::debug!("ffmpeg exited with {s}"),
_ => {}
}
if !status.as_ref().is_ok_and(|s| s.success()) {
let _ = std::fs::remove_dir_all(&tmp_dir);
return None;
}
let frames = load_frames(&tmp_dir, ext);
let _ = std::fs::remove_dir_all(&tmp_dir);
if frames.is_empty() {
return None;
}
Some(Self {
current_frame: 0,
playing: true,
fps: target_fps,
last_frame_time: Instant::now(),
video_width: width,
video_height: height,
duration_secs: duration,
title: path.file_name().map(|n| n.to_string_lossy().into_owned()),
frames,
})
}
/// Advance playback based on elapsed time. Returns `true` if the
/// frame changed (caller should redraw).
pub fn tick(&mut self) -> bool {
if !self.playing || self.frames.is_empty() {
return false;
}
let frame_duration = std::time::Duration::from_secs_f64(1.0 / self.fps);
if self.last_frame_time.elapsed() < frame_duration {
return false;
}
self.current_frame = (self.current_frame + 1) % self.frames.len();
self.last_frame_time = Instant::now();
true
}
/// Toggle play/pause.
pub fn toggle_play_pause(&mut self) {
self.playing = !self.playing;
if self.playing {
self.last_frame_time = Instant::now();
}
}
/// Seek forward by ~1 second.
pub fn seek_forward(&mut self) {
let skip = self.fps.round() as usize;
self.current_frame = (self.current_frame + skip).min(self.frames.len().saturating_sub(1));
self.last_frame_time = Instant::now();
}
/// Seek backward by ~1 second.
pub fn seek_backward(&mut self) {
let skip = self.fps.round() as usize;
self.current_frame = self.current_frame.saturating_sub(skip);
self.last_frame_time = Instant::now();
}
/// Current frame image data.
pub fn current_frame_data(&self) -> &[u8] {
&self.frames[self.current_frame]
}
/// Current playback position in seconds.
pub fn position_secs(&self) -> f64 {
if self.fps <= 0.0 {
return 0.0;
}
self.current_frame as f64 / self.fps
}
/// Playback progress fraction (0.01.0).
pub fn progress(&self) -> f64 {
if self.frames.len() <= 1 {
return 0.0;
}
self.current_frame as f64 / (self.frames.len() - 1) as f64
}
}
/// Extract a single poster frame from a video file via ffmpeg.
/// Returns `(image_bytes, width, height)`. The image format depends on
/// the active terminal protocol (PNG for Kitty, JPEG for iTerm2).
pub fn extract_poster_frame(path: &std::path::Path) -> Option<(Vec<u8>, u32, u32)> {
use crate::terminal::image::{GraphicsProtocol, detect_graphics_protocol};
let protocol = detect_graphics_protocol();
let ext = match protocol {
GraphicsProtocol::Kitty => "png",
GraphicsProtocol::ITerm2 => "jpg",
GraphicsProtocol::None => return None,
};
// Try seeking to 1s for a representative frame; fall back to first frame.
let try_extract = |seek: Option<&str>| {
let mut cmd = std::process::Command::new("ffmpeg");
cmd.args(["-hide_banner", "-loglevel", "error"]);
if let Some(ss) = seek {
cmd.args(["-ss", ss]);
}
cmd.args(["-i"])
.arg(path)
.args(["-frames:v", "1", "-f", "image2pipe", "-vcodec", ext])
.arg("-")
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
xai_tty_utils::detach_std_command(&mut cmd);
cmd.output()
.ok()
.filter(|o| o.status.success() && !o.stdout.is_empty())
};
let output = try_extract(Some("1")).or_else(|| try_extract(None))?;
let (w, h) = decode_image_dimensions(&output.stdout)?;
Some((output.stdout, w, h))
}
/// Create a unique temp directory path for frame extraction.
fn make_temp_dir() -> PathBuf {
std::env::temp_dir().join(format!(
"grok-video-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
/// Read all frame files from `dir` with the given extension, sorted by name.
fn load_frames(dir: &std::path::Path, ext: &str) -> Vec<Vec<u8>> {
let mut paths: Vec<_> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == ext))
.collect();
paths.sort();
paths.iter().filter_map(|p| std::fs::read(p).ok()).collect()
}
/// Probe video metadata via ffprobe. Returns `(width, height, duration, fps)`.
fn ffprobe_metadata(path: &std::path::Path) -> Option<(u32, u32, f64, f64)> {
let mut cmd = std::process::Command::new("ffprobe");
cmd.args([
"-v",
"quiet",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,r_frame_rate,duration",
"-show_entries",
"format=duration",
"-of",
"csv=p=0:s=,",
])
.arg(path)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
xai_tty_utils::detach_std_command(&mut cmd);
let output = match cmd.output() {
Ok(o) => o,
Err(e) => {
tracing::debug!("ffprobe not available: {e}");
return None;
}
};
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
// Stream line: width,height,r_frame_rate[,duration]
// Format line: duration
let lines: Vec<&str> = text.trim().lines().collect();
let parts: Vec<&str> = lines.first()?.split(',').collect();
if parts.len() < 3 {
return None;
}
let width: u32 = parts[0].trim().parse().ok()?;
let height: u32 = parts[1].trim().parse().ok()?;
let fps = parse_fraction(parts[2].trim()).unwrap_or(30.0);
// Try stream duration, fall back to format duration.
let duration = parts
.get(3)
.and_then(|d| d.trim().parse::<f64>().ok())
.or_else(|| lines.get(1).and_then(|l| l.trim().parse::<f64>().ok()))
.unwrap_or(0.0);
Some((width, height, duration, fps))
}
/// Parse a fraction like "30/1" or "30000/1001".
fn parse_fraction(s: &str) -> Option<f64> {
if let Some((num, den)) = s.split_once('/') {
let n: f64 = num.parse().ok()?;
let d: f64 = den.parse().ok()?;
if d == 0.0 {
return None;
}
Some(n / d)
} else {
s.parse::<f64>().ok()
}
}
// -------------------------------------------------------------------------
// Inline media info (for scrollback inline rendering)
// -------------------------------------------------------------------------
/// Metadata for inline media rendering in the scrollback.
/// Returned by blocks that want to display media inline.
#[derive(Debug, Clone)]
pub struct InlineMediaInfo {
/// Path to the media file.
pub path: PathBuf,
/// Pixel dimensions of the media.
pub width: u32,
pub height: u32,
/// Whether this is a video (poster frame) vs. a static image.
pub is_video: bool,
/// Alt text / description from the markdown `![alt](path)` syntax.
pub alt_text: String,
}
use xai_grok_shared::clipboard::mime_to_extension;
/// A single image pasted into the prompt.
///
/// Tracks everything needed for display, preview, persistence, and
/// eventual submission as a `ContentBlock::Image`.
#[derive(Debug, Clone)]
pub struct PastedImage {
/// The [`ElementId`] of the corresponding `KIND_IMAGE` element in the
/// `TextArea` buffer. Used to reconcile live elements against stored images.
pub element_id: ElementId,
/// 1-based display number for the current prompt (e.g. the `1` in
/// `[Image #1]`). Reset when the prompt is cleared.
pub display_number: usize,
/// MIME type of the encoded image (e.g. `"image/png"`).
pub mime_type: String,
/// Header-validated dimensions used for pre-insertion/send policy.
pub dimensions: Option<(u32, u32)>,
/// Size of the encoded image bytes.
pub byte_len: usize,
/// Encoded image bytes kept in memory. Set to `None` once the image
/// has been durably written to [`session_image_path`](Self::session_image_path)
/// to avoid holding large buffers for the lifetime of the prompt.
pub encoded_bytes: Option<Arc<[u8]>>,
/// Original user-visible path for file-path pastes.
///
/// This remains stable after persistence so previews show the path the
/// user pasted. Model/send loading uses `session_image_path` first.
pub source_path: Option<PathBuf>,
/// Temporary staging path before the image is finalized into the session
/// directory. Cleaned up when the chip is removed or on send.
pub staged_temp_path: Option<PathBuf>,
/// Final durable path under `session_dir(info)/images/`. Once set, this
/// is the canonical on-disk location and [`encoded_bytes`](Self::encoded_bytes)
/// may be released.
pub session_image_path: Option<PathBuf>,
/// Shared preview preparation state; draw only reads its resolved result.
pub preview: PromptImagePreview,
}
impl PastedImage {
pub fn preview_preparation(&self) -> Option<PromptImagePreviewPreparation> {
if !self.preview.is_pending() {
return None;
}
Some(PromptImagePreviewPreparation {
preview: self.preview.clone(),
source: self.encoded_bytes.as_ref()?.clone(),
protocol: crate::terminal::image::detect_graphics_protocol(),
dimensions: self.dimensions,
})
}
pub fn prepare_preview_blocking(&self) {
if let Some(preparation) = self.preview_preparation() {
preparation.run();
}
}
pub fn preview_dimensions(&self) -> Option<(u32, u32)> {
self.dimensions.or_else(|| self.preview.dimensions())
}
}
/// Shared readiness of the terminal pixel payload for a pasted image.
#[derive(Debug, Clone)]
pub struct PromptImagePreview {
identity: u64,
result: Arc<OnceLock<PromptImagePreviewResult>>,
}
#[derive(Debug)]
enum PromptImagePreviewResult {
Ready {
bytes: Arc<[u8]>,
dimensions: (u32, u32),
},
Failed,
Unsupported {
dimensions: (u32, u32),
},
}
impl Default for PromptImagePreview {
fn default() -> Self {
Self {
identity: crate::terminal::overlay::next_owner_id(),
result: Arc::new(OnceLock::new()),
}
}
}
impl PromptImagePreview {
pub fn identity(&self) -> u64 {
self.identity
}
pub fn is_pending(&self) -> bool {
self.result.get().is_none()
}
pub fn is_failed(&self) -> bool {
matches!(self.result.get(), Some(PromptImagePreviewResult::Failed))
}
pub fn prepared(&self) -> Option<(&[u8], (u32, u32))> {
match self.result.get()? {
PromptImagePreviewResult::Ready { bytes, dimensions } => {
Some((bytes.as_ref(), *dimensions))
}
PromptImagePreviewResult::Failed | PromptImagePreviewResult::Unsupported { .. } => None,
}
}
pub fn dimensions(&self) -> Option<(u32, u32)> {
match self.result.get()? {
PromptImagePreviewResult::Ready { dimensions, .. }
| PromptImagePreviewResult::Unsupported { dimensions } => Some(*dimensions),
PromptImagePreviewResult::Failed => None,
}
}
fn finish(&self, result: PromptImagePreviewResult) {
let _ = self.result.set(result);
}
pub fn mark_failed(&self) {
self.finish(PromptImagePreviewResult::Failed);
}
#[cfg(any(test, feature = "test-support"))]
pub fn ready_for_test(bytes: Vec<u8>, dimensions: (u32, u32)) -> Self {
let preview = Self::default();
preview.finish(PromptImagePreviewResult::Ready {
bytes: Arc::from(bytes),
dimensions,
});
preview
}
}
/// Off-thread work required to prepare one prompt image preview.
#[derive(Debug, Clone)]
pub struct PromptImagePreviewPreparation {
preview: PromptImagePreview,
source: Arc<[u8]>,
protocol: crate::terminal::image::GraphicsProtocol,
dimensions: Option<(u32, u32)>,
}
impl PromptImagePreviewPreparation {
pub fn preview(&self) -> PromptImagePreview {
self.preview.clone()
}
pub fn run(self) {
let Some(dimensions) = self
.dimensions
.or_else(|| decode_image_dimensions(&self.source))
else {
self.preview.finish(PromptImagePreviewResult::Failed);
return;
};
let result = match self.protocol {
crate::terminal::image::GraphicsProtocol::Kitty => {
let bytes =
if crate::terminal::image::kitty_format_from_bytes(&self.source).is_some() {
self.source
} else {
let Some(converted) =
crate::terminal::image::prepare_kitty_overlay_image_bytes(&self.source)
else {
self.preview.finish(PromptImagePreviewResult::Failed);
return;
};
Arc::from(converted)
};
PromptImagePreviewResult::Ready { bytes, dimensions }
}
crate::terminal::image::GraphicsProtocol::ITerm2 => PromptImagePreviewResult::Ready {
bytes: self.source,
dimensions,
},
crate::terminal::image::GraphicsProtocol::None => {
PromptImagePreviewResult::Unsupported { dimensions }
}
};
self.preview.finish(result);
}
}
// -------------------------------------------------------------------------
// Display helpers
// -------------------------------------------------------------------------
/// Build the buffer text for an image chip.
///
/// Always path-free: `[Image #1]`. Filepaths live on the [`PastedImage`]
/// record and surface only in the hover/cursor preview overlay — never in
/// the prompt-bar chip.
pub fn display_text(display_number: usize) -> String {
format!("[Image #{display_number}]")
}
/// Derive a file extension from a MIME type.
///
/// Delegates to [`xai_grok_shared::clipboard::mime_to_extension`].
pub fn extension_for_mime(mime: &str) -> &'static str {
mime_to_extension(mime)
}
// -------------------------------------------------------------------------
// Reconciliation
// -------------------------------------------------------------------------
/// Remove entries from `images` whose `element_id` is not present in
/// `live_ids`.
///
/// This is the primary mechanism that prevents deleted image chips from
/// being submitted. Call at cleanup boundaries (prompt clear, drain-for-send,
/// explicit chip deletion) rather than on every keystroke.
pub fn reconcile(images: &mut Vec<PastedImage>, live_ids: &HashSet<ElementId>) {
images.retain(|img| {
if live_ids.contains(&img.element_id) {
return true;
}
// Clean up temp-file-only staged images for removed chips.
// Session-persisted files are intentionally left as orphans in v1.
cleanup_temp_file(img);
false
});
}
/// Drain `images`, cleaning up each entry's temp file. The caller
/// is responsible for resetting `image_counter` if appropriate —
/// resetting the counter is a separate semantic concern from clearing
/// a Vec's contents. Use [`reset_counter`] when the counter should
/// also be zeroed.
pub fn drain_and_cleanup(images: &mut Vec<PastedImage>) {
for img in images.drain(..) {
cleanup_temp_file(&img);
}
}
/// Reset the monotonic image counter to 0. Pair with
/// [`drain_and_cleanup`] when a prompt is fully reset (Ctrl+C,
/// `set_text("")`, successful send).
pub fn reset_counter(image_counter: &mut usize) {
*image_counter = 0;
}
/// Drain images and reset the counter in one call. Prefer
/// [`drain_and_cleanup`] and [`reset_counter`] when only one side
/// is needed; this shim exists so "wipe everything" call sites
/// don't have to repeat the pair.
pub fn clear(images: &mut Vec<PastedImage>, image_counter: &mut usize) {
drain_and_cleanup(images);
reset_counter(image_counter);
}
/// Delete a staged temp file if it exists and no session-persisted copy
/// has been made. Session-persisted files are left intact (orphan cleanup
/// is acceptable in v1).
pub fn cleanup_temp_file(img: &PastedImage) {
if img.session_image_path.is_some() {
return; // already persisted to session dir, leave it
}
if let Some(ref path) = img.staged_temp_path {
let _ = std::fs::remove_file(path);
}
}
// -------------------------------------------------------------------------
// Construction from file path
// -------------------------------------------------------------------------
/// Image file extensions recognized when a pasted path is checked.
///
/// Formats omitted on purpose: HEIC/HEIF/AVIF/ICO/SVG. The inline
/// image overlay doesn't decode or render them today, so promoting
/// them to chips would falsely promise rendering. Drops of these
/// extensions fall through to NonImage path text instead.
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "tif"];
/// Normalize a media path by dropping the Windows `\\?\` verbatim prefix
/// (`\\?\C:\x` → `C:\x`, `\\?\UNC\srv\s` → `\\srv\s`). Applied once when a
/// scrollback media ref is built so every consumer (display, open, copy) gets
/// a path that GUI openers and the clipboard can resolve. No-op off Windows.
fn strip_verbatim_prefix(path: &std::path::Path) -> PathBuf {
let s = path.to_string_lossy();
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
PathBuf::from(format!(r"\\{rest}"))
} else if let Some(rest) = s.strip_prefix(r"\\?\") {
PathBuf::from(rest)
} else {
path.to_path_buf()
}
}
/// Drive-letter (`X:\` / `X:/`) or UNC (`\\…`) Windows path. Used to
/// short-circuit [`shell_unescape`], which would otherwise treat the
/// separator backslashes as escape characters and collapse the path.
fn looks_like_windows_path(s: &str) -> bool {
let b = s.as_bytes();
let drive = b.len() >= 3
&& b[0].is_ascii_alphabetic()
&& b[1] == b':'
&& (b[2] == b'\\' || b[2] == b'/');
drive || b.starts_with(b"\\\\")
}
/// Strip shell backslash escapes (`\X` → `X`) so terminal-pasted file
/// paths with escaped spaces / parens resolve on disk. Windows-style
/// paths pass through unchanged — see [`looks_like_windows_path`].
fn shell_unescape(s: &str) -> std::borrow::Cow<'_, str> {
if !s.contains('\\') || looks_like_windows_path(s) {
return std::borrow::Cow::Borrowed(s);
}
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some(next) => result.push(next),
None => result.push(c),
}
} else {
result.push(c);
}
}
std::borrow::Cow::Owned(result)
}
/// Strip a single pair of matching ASCII single or double quotes that
/// wrap `s`. Otherwise return `s` unchanged.
fn strip_matching_quotes(s: &str) -> &str {
let bytes = s.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return &s[1..s.len() - 1];
}
}
s
}
/// Resolve one paste token to a filesystem path.
///
/// Accepts bare paths (with optional shell backslash escapes), `file://`
/// URLs (percent-decoded by the `url` crate), and paths wrapped in a
/// single pair of `"…"` or `'…'` quotes. Returns `None` if a `file://`
/// prefix is present but the URL is not parseable as a local path.
fn token_to_path(token: &str) -> Option<PathBuf> {
let token = token.trim();
if token.is_empty() {
return None;
}
let unquoted = strip_matching_quotes(token);
if unquoted.starts_with("file://") {
let url = url::Url::parse(unquoted).ok()?;
if url.scheme() != "file" {
return None;
}
return url.to_file_path().ok();
}
let unescaped = shell_unescape(unquoted);
Some(PathBuf::from(unescaped.into_owned()))
}
/// Validate that `path` points to a readable image file and load it as
/// a [`PastedImage`]. Returns `None` if the extension isn't recognized,
/// the file is missing, empty, or whose bytes don't sniff as an image.
fn read_image_at_path(path: &std::path::Path) -> Option<PastedImage> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
if !IMAGE_EXTENSIONS.contains(&ext.as_str()) {
return None;
}
if !path.is_file() {
return None;
}
let data = std::fs::read(path).ok()?;
if data.is_empty() {
return None;
}
let mime_type = xai_grok_shared::clipboard::mime_from_bytes(&data);
if mime_type == "application/octet-stream" {
return None;
}
let dimensions = decode_image_dimensions(&data)?;
Some(PastedImage {
element_id: ElementId::from_raw(0),
display_number: 0,
mime_type: mime_type.to_owned(),
dimensions: Some(dimensions),
byte_len: data.len(),
encoded_bytes: Some(Arc::from(data)),
source_path: Some(path.to_path_buf()),
staged_temp_path: None,
session_image_path: None,
preview: PromptImagePreview::default(),
})
}
/// Whether `s` begins with a drop-style path anchor: `/`, `~/`, a
/// Windows drive (`X:\` or `X:/`), or a Windows UNC (`\\`). ASCII-only
/// so it never inspects a partial UTF-8 codepoint.
fn starts_with_path_anchor(s: &str) -> bool {
let b = s.as_bytes();
matches!(b.first(), Some(b'/'))
|| b.starts_with(b"~/")
|| (b.len() >= 3
&& b[0].is_ascii_alphabetic()
&& b[1] == b':'
&& (b[2] == b'\\' || b[2] == b'/'))
|| b.starts_with(b"\\\\")
}
/// Whether `s` begins with something the space-splitter should treat as
/// a path token boundary: a bare path anchor, a `file://` URL, or a
/// quoted form of either (quotes are stripped before re-checking).
pub fn starts_with_drop_anchor(s: &str) -> bool {
if starts_with_path_anchor(s) || s.starts_with("file://") {
return true;
}
let unq = strip_matching_quotes(s);
!std::ptr::eq(unq, s) && (starts_with_path_anchor(unq) || unq.starts_with("file://"))
}
/// Split `s` on each space that is immediately followed by a drop-style
/// anchor: a bare path start (`/`, `~/`, `X:\`) or a `file://` URL.
/// Operates on ASCII bytes (the only characters we need to match) and
/// never splits inside a multi-byte UTF-8 sequence because all match
/// bytes are ASCII.
fn split_space_before_path(s: &str) -> Vec<&str> {
let bytes = s.as_bytes();
let mut parts = Vec::new();
let mut start = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b' ' && starts_with_drop_anchor(&s[i + 1..]) {
parts.push(&s[start..i]);
start = i + 1;
}
i += 1;
}
parts.push(&s[start..]);
parts
}
/// Tokenize one trimmed line into one or more path candidates.
///
/// Returns the whole line as a single token unless every space-separated
/// part itself starts with a drop-style anchor — the all-parts gate
/// keeps prose like `"check /tmp/foo.png please"` or bash pastes like
/// `"! /tmp/foo.png"` from being mis-split. Empty input yields an empty
/// `Vec` so the caller's `flat_map` skips blank lines cleanly.
fn space_split_line(line: &str) -> Vec<&str> {
let line = line.trim();
if line.is_empty() {
return Vec::new();
}
let parts: Vec<&str> = split_space_before_path(line)
.into_iter()
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if parts.len() > 1 && parts.iter().all(|p| starts_with_drop_anchor(p)) {
parts
} else {
vec![line]
}
}
/// Normalize line endings (`\r\n`/`\r` → `\n`) without allocating when
/// the input has no carriage returns (the common case from macOS).
fn normalize_line_endings(text: &str) -> std::borrow::Cow<'_, str> {
if text.contains('\r') {
std::borrow::Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n"))
} else {
std::borrow::Cow::Borrowed(text)
}
}
/// Filter the output of [`try_read_dropped_paths`] down to image
/// entries only — kept as a stable API for prior image-only callers.
/// See [`try_read_dropped_paths`] for the full tokenisation and
/// decoding behaviour spec.
pub fn try_read_images_from_paste(text: &str) -> Vec<PastedImage> {
try_read_dropped_paths(text)
.into_iter()
.filter_map(|d| match d {
DroppedPath::Image(img) => Some(img),
DroppedPath::NonImage(_) => None,
})
.collect()
}
/// Classification of a single drop-style paste token.
///
/// Returned by [`try_read_dropped_paths`]. Images are routed to
/// `[Image #N]` chip insertion; non-images are inserted as decoded
/// absolute path text so the user can reference them by path / let the
/// agent read them.
#[derive(Debug)]
pub enum DroppedPath {
/// Token resolved to a readable image file (extension in
/// [`IMAGE_EXTENSIONS`] and bytes sniff as a known image format).
Image(PastedImage),
/// Token resolved to a `file://` URL or an existing on-disk path
/// (file *or* directory) that is not a recognised image — the
/// caller should insert this decoded path as plain text in the
/// prompt.
///
/// The stored `PathBuf` is canonicalised when possible
/// (`canonicalize()` succeeds) so symlinks resolve to their target,
/// matching the image branch's `read_image_at_path` behaviour. The
/// raw decoded path is used as a fallback when canonicalisation
/// fails (broken symlinks, permission errors, network mounts that
/// are unreachable, or `file://` URLs to non-existent paths).
#[allow(dead_code)]
NonImage(PathBuf),
}
/// Resolve one paste token to either an image chip, a non-image path
/// for text insertion, or `None` when the token does not look like a
/// drop-event path at all.
///
/// Non-image bare paths must satisfy *three* conditions to be
/// intercepted: the token must start with a drop anchor (`/`, `~/`,
/// `X:\`), the path must exist on disk (file OR directory), and the
/// raw bytes must not decode as an image (else the image branch wins).
/// The drop-anchor gate prevents arbitrary prose strings that happen
/// to coincide with a filesystem path from being intercepted from
/// inside a sentence. `file://` URLs bypass both the anchor gate and
/// the existence gate (an explicit URI is unambiguous drop intent).
///
/// Predicate order: cheap anchor/`file://` checks run first; the
/// (relatively) more expensive [`read_image_at_path`] file-read +
/// magic-byte sniff runs only for tokens that pass the gate. Bare
/// cwd-relative image filenames are intentionally NOT intercepted —
/// drag-and-drop / Finder-paste always emit absolute paths or
/// `file://` URLs, never `foo.png`-style relative refs.
///
/// **Silent fallthroughs**: a typo'd `file://` URL (missing path)
/// and an image-extension-but-garbage-bytes drop both land as text
/// with no toast. Drops can be partial (network mounts, broken
/// symlinks, corrupted exports) and a toast for every such case
/// would be noisier than the silent path-as-text behaviour the
/// user already gets.
fn try_read_dropped_path(token: &str) -> Option<DroppedPath> {
let trimmed_unq = strip_matching_quotes(token.trim());
let is_file_url = trimmed_unq.starts_with("file://");
let is_bare_anchored = starts_with_path_anchor(trimmed_unq);
if !is_file_url && !is_bare_anchored {
return None;
}
let path = token_to_path(token)?;
// Reject empty or root paths. `file:///` decodes to an empty
// path on some platforms or to `/` on others; either way it is
// never a legitimate drop target (root would force the user to
// "attach the entire filesystem").
if path.as_os_str().is_empty() || path == std::path::Path::new("/") {
return None;
}
// Reject the bytes that actually corrupt the terminal/text-paste
// pipeline — NUL, CR, LF — produced by pathological encodings
// like `file:///path%00.png`. TAB and other low-control bytes
// are legal in Unix filenames and tolerated by the TUI text
// rendering, so they pass through.
if path
.as_os_str()
.as_encoded_bytes()
.iter()
.any(|&b| b == 0 || b == b'\r' || b == b'\n')
{
return None;
}
// Image branch wins when extension + magic bytes both match —
// keeps the established "`[Image #N]` for image drops" behaviour
// for tokens that already passed the anchor / `file://` gate.
if let Some(img) = read_image_at_path(&path) {
return Some(DroppedPath::Image(img));
}
// `file://` is an unambiguous drop URI even when the file is
// missing (stale links, network mounts). Bare anchored paths
// require the target to *exist* (file or directory) so that prose
// typed at the path-anchor start, e.g. `/tmp is a dir on Unix`,
// falls through to plain text paste.
if !is_file_url && !path.exists() {
return None;
}
// Canonicalise when possible so the inserted text matches what
// other code paths see (e.g. cwd-relative comparisons). Fall back
// to the raw decoded path when canonicalisation fails (broken
// symlinks, permission issues, network mounts, `file://` URLs to
// missing files).
let resolved = dunce::canonicalize(&path).unwrap_or(path);
Some(DroppedPath::NonImage(resolved))
}
/// Parse `text` from a terminal paste into a list of [`DroppedPath`]
/// entries — images and non-image file paths interleaved in the order
/// they appeared.
///
/// This is the superset routine used by the drag-and-drop / Finder-paste
/// pipeline. It handles `file://` URLs (percent-decoded, including
/// `%20`/`%23`/`%3F` etc.), bare absolute paths, shell-escaped tokens,
/// quoted tokens, and multi-file payloads (newline- or space-separated).
/// Trailing whitespace and CRLF/CR line endings are tolerated.
///
/// Non-image bare paths are only intercepted when the token itself
/// begins with a drop anchor (`/`, `~/`, `X:\`) **and** the path
/// exists on disk. This guards against prose that happens to coincide
/// with a filesystem path being eaten from inside a sentence. See
/// [`try_read_dropped_path`] for the full predicate.
///
/// **Whole-paste-or-nothing.** A paste of
/// `"file:///foo.png\nplease look at this"` must not emit just the
/// image and silently lose the comment — "screenshot URL + caption"
/// pastes are common in practice (browser/Slack right-click
/// "Copy image address" plus hand-typed prose). The function
/// returns an empty `Vec` if *any* non-whitespace line fails to
/// resolve to a drop, so the caller falls through to plain-text
/// paste of the whole payload.
///
/// Concretely:
/// - Empty/whitespace lines are separators (skipped).
/// - For each non-empty line, all tokens emitted by
/// [`space_split_line`] must resolve. If any fail, the whole paste
/// falls through to prose.
/// - If every non-empty line fully resolves, entries are emitted in
/// source order.
///
/// Returns an empty `Vec` when no token resolves to either an image
/// or a recognised file path — callers should then treat the payload
/// as a plain text paste.
pub fn try_read_dropped_paths(text: &str) -> Vec<DroppedPath> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Vec::new();
}
let normalized = normalize_line_endings(trimmed);
let mut result = Vec::new();
for line in normalized.split('\n') {
let tokens = space_split_line(line);
if tokens.is_empty() {
// Blank line — treat as separator.
continue;
}
let resolved: Vec<DroppedPath> = tokens
.iter()
.filter_map(|t| try_read_dropped_path(t))
.collect();
// Any-line-fails-falls-through: even a single-token line that
// doesn't resolve poisons the whole paste so the user's prose
// is preserved via the caller's plain-text-paste fallback.
if resolved.len() < tokens.len() {
return Vec::new();
}
result.extend(resolved);
}
let (images, non_images) = result.iter().fold((0usize, 0usize), |(i, n), e| match e {
DroppedPath::Image(_) => (i + 1, n),
DroppedPath::NonImage(_) => (i, n + 1),
});
tracing::debug!(
target: PROMPT_IMAGES_TRACING_TARGET,
input_len = text.len(),
anchor = paste_anchor_kind(trimmed),
images,
non_images,
"try_read_dropped_paths verdict",
);
result
}
/// Categorical label for the leading bytes of a paste, used in
/// diagnostic logging only. Lossy by design — the classifier itself
/// re-derives the real predicate.
fn paste_anchor_kind(trimmed: &str) -> &'static str {
if trimmed.starts_with("file://") {
"file_url"
} else if trimmed.starts_with('/') {
"absolute"
} else if trimmed.starts_with("~/") {
"tilde"
} else if trimmed.len() >= 3
&& trimmed.as_bytes()[0].is_ascii_alphabetic()
&& &trimmed.as_bytes()[1..3] == b":\\"
{
"windows_drive"
} else if trimmed.starts_with("\\\\") {
"windows_unc"
} else {
"none"
}
}
/// Check whether `text` looks like a single file path to an image that
/// exists on disk. Returns the loaded image if so.
///
/// Thin wrapper over [`try_read_images_from_paste`] that returns `Some`
/// only when the paste resolves to exactly one image; returns `None`
/// for zero or multiple images. Trailing whitespace (including a single
/// trailing `\n` or `\r\n`) is tolerated; multi-image payloads return
/// `None` so the caller can route them through the multi-image helper
/// instead.
pub fn try_read_image_from_path(text: &str) -> Option<PastedImage> {
let mut images = try_read_images_from_paste(text);
if images.len() == 1 {
Some(images.remove(0))
} else {
None
}
}
// -------------------------------------------------------------------------
// Construction from clipboard data
// -------------------------------------------------------------------------
/// Build a `PastedImage` from raw clipboard [`ImageData`].
///
/// `element_id` and `display_number` are set to placeholder values and
/// will be overwritten by [`crate::views::prompt_widget::PromptWidget::insert_image`].
pub fn from_clipboard_data(data: &crate::clipboard::ImageData) -> PastedImage {
PastedImage {
element_id: ElementId::from_raw(0),
display_number: 0,
mime_type: data.mime_type.clone(),
dimensions: decode_image_dimensions(&data.data),
byte_len: data.data.len(),
encoded_bytes: Some(Arc::from(data.data.clone())),
source_path: None,
staged_temp_path: None,
session_image_path: None,
preview: PromptImagePreview::default(),
}
}
// -------------------------------------------------------------------------
// Session image persistence
// -------------------------------------------------------------------------
/// Persist image bytes into the session `images/` directory.
///
/// Creates the directory if it does not exist. Uses a UUID-v4 filename
/// to avoid collisions (two pastes of the same image are independent).
///
/// On success:
/// - Sets `img.session_image_path` to the written file.
/// - Leaves `img.source_path` unchanged as the original display path.
/// - Drops `encoded_bytes` from memory.
pub fn persist_to_session(
img: &mut PastedImage,
session_images_dir: &std::path::Path,
) -> anyhow::Result<()> {
let bytes = img
.encoded_bytes
.as_ref()
.ok_or_else(|| anyhow::anyhow!("no encoded bytes to persist"))?;
std::fs::create_dir_all(session_images_dir)?;
let ext = extension_for_mime(&img.mime_type);
let filename = format!("image-{}.{}", uuid::Uuid::new_v4(), ext);
let path = session_images_dir.join(&filename);
// Atomic write: a crash mid-write leaves no partially-written final file.
let tmp_path = path.with_extension(format!("{ext}.tmp"));
let write_result: anyhow::Result<()> = (|| {
use std::io::Write as _;
let mut f = std::fs::File::create(&tmp_path)?;
f.write_all(bytes)?;
f.sync_all()?;
drop(f);
std::fs::rename(&tmp_path, &path)?;
Ok(())
})();
if write_result.is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
write_result?;
img.session_image_path = Some(path);
img.encoded_bytes = None;
Ok(())
}
/// Derive the `images/` directory for a session.
///
/// Returns `None` if session identity is not yet known.
pub fn session_images_dir(
session_id: Option<&agent_client_protocol::SessionId>,
cwd: &std::path::Path,
) -> Option<PathBuf> {
let sid = session_id?;
let info = xai_grok_shared::session::info::Info {
id: sid.clone(),
cwd: cwd.to_string_lossy().into_owned(),
};
Some(xai_grok_shared::session::session_dir(&info).join("images"))
}
/// Derive the `mermaid/` cache directory for a session.
///
/// Mirrors [`session_images_dir`]: rendered diagram PNGs live alongside the
/// session's other artifacts (`events.jsonl`, `images/`) so they are owned by
/// the session and torn down with it. Returns `None` until session identity is
/// known (no diagrams are cached on disk before then).
pub fn session_mermaid_dir(
session_id: Option<&agent_client_protocol::SessionId>,
cwd: &std::path::Path,
) -> Option<PathBuf> {
let sid = session_id?;
let info = xai_grok_shared::session::info::Info {
id: sid.clone(),
cwd: cwd.to_string_lossy().into_owned(),
};
Some(xai_grok_shared::session::session_dir(&info).join("mermaid"))
}
// -------------------------------------------------------------------------
// Image loading for send
// -------------------------------------------------------------------------
const MAX_SEND_BYTES: usize = 50_000_000; // 50 MB
/// Load image bytes from a `PastedImage` (in-memory or from disk).
/// Returns `None` if the image cannot be loaded or exceeds [`MAX_SEND_BYTES`].
pub fn load_for_send(img: &PastedImage) -> Option<(Vec<u8>, String)> {
let raw_bytes = if let Some(ref b) = img.encoded_bytes {
b.to_vec()
} else if let Some(ref path) = img.session_image_path {
match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
tracing::warn!(
"image {}: failed to read {}: {e}",
img.display_number,
path.display()
);
return None;
}
}
} else {
tracing::warn!("image {}: no bytes or file path", img.display_number);
return None;
};
if raw_bytes.len() > MAX_SEND_BYTES {
tracing::warn!(
"image {}: {} bytes exceeds send limit, skipping",
img.display_number,
raw_bytes.len()
);
return None;
}
if let Some((width, height)) = img.dimensions
&& (width < 8 || height < 8)
{
tracing::warn!(
"image {}: dimensions {}x{} are below the 8x8 minimum",
img.display_number,
width,
height
);
return None;
}
Some((raw_bytes, img.mime_type.clone()))
}
// -------------------------------------------------------------------------
// ACP content block construction
// -------------------------------------------------------------------------
/// Build ACP `ContentBlock` values from prompt text and attached images,
/// with an optional fallback that re-loads orphan
/// `[Image #N: <path>]` placeholders from disk.
///
/// Behaviour for each placeholder in `text`:
/// - If a [`PastedImage`] with the matching `display_number` is present
/// in `images`, the placeholder is left untouched and the
/// `PastedImage` provides the bytes.
/// - Otherwise, if `workspace_cwd` is `Some`, attempt to load the
/// placeholder's path via the shared
/// [`xai_grok_shell::session::placeholder_images::load_placeholder_image`]
/// helper. On success: attach a `ContentBlock::Image` and leave the
/// placeholder text in place. On failure: strip the placeholder from
/// the forwarded text and emit a `tracing::warn!` (no UI alert
/// surface exists today — the warn log is the established pattern,
/// see `load_for_send` for prior art).
/// - When `workspace_cwd` is `None`, the orphan placeholder is left in
/// the text unchanged (legacy behaviour, used by unit tests).
///
/// Path validation, extension allowlist, and the 50-MB size cap come
/// from the shared helper so the TUI and the server use the same rules.
pub fn build_content_blocks_with_workspace(
text: String,
images: Vec<PastedImage>,
workspace_cwd: Option<&std::path::Path>,
) -> Vec<agent_client_protocol::ContentBlock> {
let allowed: Option<Vec<std::path::PathBuf>> =
workspace_cwd.map(xai_grok_shared::placeholder_images::default_allowed_prefixes);
build_content_blocks_with_prefixes(text, images, allowed.as_deref())
}
/// Test-injectable variant of [`build_content_blocks_with_workspace`].
///
/// Accepts an explicit `allowed_prefixes` slice so unit tests can
/// pass a hermetic prefix list and avoid reading the ambient process
/// `$HOME`. Production calls go through
/// [`build_content_blocks_with_workspace`].
pub fn build_content_blocks_with_prefixes(
text: String,
images: Vec<PastedImage>,
allowed_prefixes: Option<&[std::path::PathBuf]>,
) -> Vec<agent_client_protocol::ContentBlock> {
build_content_blocks_with_prefixes_and_caps(
text,
images,
allowed_prefixes,
xai_grok_shared::placeholder_images::MAX_PLACEHOLDER_AGGREGATE_BYTES,
)
}
/// Test-injectable variant of [`build_content_blocks_with_prefixes`]
/// that takes an explicit aggregate-bytes cap.
///
/// Mirrors the server-side
/// [`xai_grok_shell::session::placeholder_images::recover_orphan_placeholders_with_prefixes_and_caps`].
/// Aggregate-cap semantics match: `aggregate + image.len() > cap`
/// triggers the loop break (inclusive boundary — a running total
/// exactly equal to the cap is admitted).
pub fn build_content_blocks_with_prefixes_and_caps(
text: String,
images: Vec<PastedImage>,
allowed_prefixes: Option<&[std::path::PathBuf]>,
aggregate_max: usize,
) -> Vec<agent_client_protocol::ContentBlock> {
use agent_client_protocol::{ContentBlock, ImageContent, TextContent};
use base64::Engine as _;
// Phase 1: rewrite the text to strip failed-load placeholders, and
// collect successfully-loaded orphan images. PastedImage-backed
// placeholders (display_number present in `images`) are left alone.
let (rewritten_text, orphan_images) =
resolve_orphan_placeholders(text, &images, allowed_prefixes, aggregate_max);
// Phase 2: drop `[Image #N: <path>]` → `[Image #N]`. The path
// tempts the model into a redundant `Read` on its own
// attachment.
let rewritten_text =
xai_grok_shared::placeholder_images::strip_paths_from_image_placeholders(rewritten_text);
let mut blocks = Vec::with_capacity(1 + images.len() + orphan_images.len());
blocks.push(ContentBlock::Text(TextContent::new(rewritten_text)));
for img in &images {
let (bytes, mime_type) = match load_for_send(img) {
Some(loaded) => loaded,
None => continue,
};
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
// Canonicalize the display path only when no durable wire path exists.
let uri = if let Some(path) = img.session_image_path.as_ref() {
Some(format!("file://{}", path.display()))
} else {
img.source_path
.as_ref()
.and_then(|path| dunce::canonicalize(path).ok())
.map(|canonical| format!("file://{}", canonical.display()))
};
blocks.push(ContentBlock::Image(
ImageContent::new(data, mime_type)
.uri(uri)
// Record the `[Image #N]` display number so the server resolves
// the token by number, not list position. See `AttachedImages`.
.meta(Some(
xai_grok_shared::placeholder_images::display_number_meta(img.display_number),
)),
));
}
for orphan in orphan_images {
blocks.push(ContentBlock::Image(orphan));
}
blocks
}
/// Scan `text` for `[Image #N: <path>]` placeholders that lack a
/// matching [`PastedImage`] and attempt to recover them from disk.
///
/// Returns `(rewritten_text, recovered_images)`:
/// - Placeholders with a matching `PastedImage` (by `display_number`)
/// are left untouched.
/// - Orphan placeholders whose path loads successfully via the shared
/// helper are kept in the text and produce a recovered
/// `ImageContent`.
/// - Orphan placeholders whose path fails to load are stripped from
/// the text and a `tracing::warn!` is emitted.
///
/// `allowed_prefixes == None` short-circuits to the legacy behaviour:
/// the text is returned unchanged and no recovery is attempted.
fn resolve_orphan_placeholders(
text: String,
images: &[PastedImage],
allowed_prefixes: Option<&[std::path::PathBuf]>,
aggregate_max: usize,
) -> (String, Vec<agent_client_protocol::ImageContent>) {
use agent_client_protocol::ImageContent;
use base64::Engine as _;
let Some(allowed) = allowed_prefixes else {
return (text, Vec::new());
};
let attached_numbers: std::collections::HashSet<usize> =
images.iter().map(|i| i.display_number).collect();
let placeholders = xai_grok_shared::placeholder_images::extract_placeholders(&text);
if placeholders.is_empty() {
return (text, Vec::new());
}
let mut recovered: Vec<ImageContent> = Vec::new();
// Spans to delete from the text (failed loads). Recorded as
// half-open byte ranges so we can splice them out in one pass.
let mut strip_spans: Vec<(usize, usize)> = Vec::new();
let mut aggregate_bytes: usize = 0;
for ph in &placeholders {
if attached_numbers.contains(&ph.display_number) {
continue; // PastedImage already supplies these bytes.
}
match xai_grok_shared::placeholder_images::load_placeholder_image(&ph.path, allowed) {
Ok(loaded) => {
let next_total = aggregate_bytes.saturating_add(loaded.data.len());
if next_total > aggregate_max {
tracing::warn!(
path = ?ph.path,
aggregate_bytes,
per_image_bytes = loaded.data.len(),
cap = aggregate_max,
"TUI placeholder fallback: aggregate-bytes cap reached; skipping remaining orphan placeholders",
);
break;
}
aggregate_bytes = next_total;
let data = base64::engine::general_purpose::STANDARD.encode(&loaded.data);
let uri = dunce::canonicalize(std::path::Path::new(&ph.path))
.ok()
.map(|p| format!("file://{}", p.display()));
recovered.push(
ImageContent::new(data, loaded.mime_type)
.uri(uri)
// Same `[Image #N]` → number mapping as inline images.
.meta(Some(
xai_grok_shared::placeholder_images::display_number_meta(
ph.display_number,
),
)),
);
tracing::info!(
path = ?ph.path,
"TUI placeholder fallback: loaded orphan image from disk",
);
}
Err(e) => {
tracing::warn!(
path = ?ph.path,
error = %e,
"TUI placeholder fallback: orphan image failed to load; stripping placeholder text",
);
strip_spans.push(ph.span);
}
}
}
if strip_spans.is_empty() {
return (text, recovered);
}
// Splice out the failed-load spans in reverse order so earlier
// indices stay valid. Only collapse the single whitespace seam
// created by the strip itself — global collapsing of all
// 2+-space runs would mangle code blocks, indentation-sensitive
// markdown, and double-space punctuation elsewhere in the text.
let mut rewritten = text;
strip_spans.sort_by_key(|(s, _)| *s);
for (start, end) in strip_spans.into_iter().rev() {
collapse_strip_seam(&mut rewritten, start, end);
}
(rewritten, recovered)
}
/// Splice out `[start, end)` from `text` and, only if both sides of
/// the seam are ASCII whitespace, collapse the run to a single space.
/// Newlines are preserved (treated as non-collapsible boundaries).
fn collapse_strip_seam(text: &mut String, start: usize, end: usize) {
text.replace_range(start..end, "");
// After removal `start` is the seam position. Walk left/right
// over the immediate space chars only; do not cross newlines or
// non-space whitespace (tab/CR).
let bytes = text.as_bytes();
let mut left = start;
while left > 0 && bytes.get(left - 1) == Some(&b' ') {
left -= 1;
}
let mut right = start;
while right < bytes.len() && bytes.get(right) == Some(&b' ') {
right += 1;
}
if right - left > 1 {
// Replace the run with a single space if there is content on
// both sides; otherwise (seam at start/end of text) trim
// entirely.
let has_left_content = left > 0;
let has_right_content = right < bytes.len();
let replacement = if has_left_content && has_right_content {
" "
} else {
""
};
text.replace_range(left..right, replacement);
}
}
// -------------------------------------------------------------------------
// Scrollback image references
// -------------------------------------------------------------------------
/// An image file referenced in scrollback content via `![alt](path)` markdown
/// or a bare absolute path. Validated on construction: path must exist, have a
/// recognized image extension, and decode successfully.
#[derive(Debug, Clone)]
pub struct ScrollbackImageRef {
/// Absolute path to the image file on disk.
pub path: PathBuf,
/// Pixel dimensions `(width, height)`, decoded on construction.
pub dimensions: Option<(u32, u32)>,
/// Alt text from the `![alt](path)` markdown syntax (empty for bare paths).
pub alt_text: String,
}
impl ScrollbackImageRef {
/// Construct from a file path, returning `None` if the path doesn't
/// exist, isn't a file, lacks a recognized image extension, or can't
/// be decoded as an image.
pub fn from_path(path: impl Into<PathBuf>) -> Option<Self> {
Self::from_path_with_alt(path, String::new())
}
/// Construct with alt text from the markdown `![alt](path)` syntax.
pub fn from_path_with_alt(path: impl Into<PathBuf>, alt_text: String) -> Option<Self> {
let path = strip_verbatim_prefix(&path.into());
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
if !IMAGE_EXTENSIONS.contains(&ext.as_str()) {
return None;
}
if !path.is_file() {
return None;
}
let bytes = std::fs::read(&path).ok()?;
if !is_decodable_image(&bytes) {
return None;
}
let dimensions = decode_image_dimensions(&bytes);
Some(Self {
path,
dimensions,
alt_text,
})
}
}
/// Regex pattern for `![alt](path)` — captures alt text (group 1) and path (group 2).
const MARKDOWN_IMAGE_REF_PATTERN: &str = r"!\[([^\]]*)\]\(([^)\s]+)\)";
/// Whether text consists only of markdown media references (`![alt](path)`).
///
/// `resolved_ref_count` is the total number of resolved media refs
/// (images + videos) extracted from the same text. Unresolved or
/// undecodable paths are not counted, preventing false positives.
pub fn is_media_only_markdown(text: &str, resolved_ref_count: usize) -> bool {
use std::sync::LazyLock;
if resolved_ref_count == 0 {
return false;
}
static ONLY_MD_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(&format!(r"^\s*(?:{}\s*)+$", MARKDOWN_IMAGE_REF_PATTERN)).unwrap()
});
static MD_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(MARKDOWN_IMAGE_REF_PATTERN).unwrap());
if !ONLY_MD_RE.is_match(text) {
return false;
}
let unique_ref_count = MD_RE
.captures_iter(text)
.filter_map(|cap| cap.get(2).map(|m| m.as_str()))
.collect::<std::collections::HashSet<_>>()
.len();
unique_ref_count == resolved_ref_count
}
/// Extract image references from text (markdown or tool output).
///
/// Scans for `![alt](path)` patterns and bare absolute image paths.
/// Only returns references where the file exists on disk and decodes as an
/// image.
pub fn extract_image_refs(text: &str) -> Vec<ScrollbackImageRef> {
use std::sync::LazyLock;
static MD_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(MARKDOWN_IMAGE_REF_PATTERN).unwrap());
static PATH_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
let exts = IMAGE_EXTENSIONS.join("|");
// Unix absolute paths (/...) and Windows absolute paths (C:\..., C:/...,
// or \\... UNC). Drive letters accept either separator.
regex::Regex::new(&format!(
r"(?:^|[\s,])((?:/|[A-Za-z]:[\\/]|\\\\)[^\s,]+\.(?:{exts}))(?:[\s,.(]|$)"
))
.unwrap()
});
let mut refs = Vec::new();
let mut seen = std::collections::HashSet::new();
for cap in MD_RE.captures_iter(text) {
if let Some(m) = cap.get(2) {
let path_str = m.as_str();
let alt_text = cap
.get(1)
.map(|a| a.as_str().to_owned())
.unwrap_or_default();
if seen.insert(path_str.to_owned())
&& let Some(r) = ScrollbackImageRef::from_path_with_alt(path_str, alt_text)
{
refs.push(r);
}
}
}
for cap in PATH_RE.captures_iter(text) {
if let Some(m) = cap.get(1) {
let path_str = m.as_str();
if seen.insert(path_str.to_owned())
&& let Some(r) = ScrollbackImageRef::from_path(path_str)
{
refs.push(r);
}
}
}
refs
}
// -------------------------------------------------------------------------
// Scrollback video references
// -------------------------------------------------------------------------
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "webm", "mov", "avi", "mkv"];
/// A video file referenced in scrollback content via `![alt](path.mp4)` markdown
/// or a bare absolute path. Validated on construction: path must exist and have a
/// recognized video extension.
#[derive(Debug, Clone)]
pub struct ScrollbackVideoRef {
/// Absolute path to the video file on disk.
pub path: PathBuf,
/// Alt text from the `![alt](path)` markdown syntax (empty for bare paths).
pub alt_text: String,
}
impl ScrollbackVideoRef {
/// Validate that `path` exists and has a recognized video extension.
pub fn from_path(path: impl Into<PathBuf>) -> Option<Self> {
Self::from_path_with_alt(path, String::new())
}
/// Construct with alt text from the markdown `![alt](path)` syntax.
pub fn from_path_with_alt(path: impl Into<PathBuf>, alt_text: String) -> Option<Self> {
let path = strip_verbatim_prefix(&path.into());
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
if !VIDEO_EXTENSIONS.contains(&ext.as_str()) || !path.is_file() {
return None;
}
Some(Self { path, alt_text })
}
}
/// Extract video references from text (markdown `![](path)` and bare paths).
pub fn extract_video_refs(text: &str) -> Vec<ScrollbackVideoRef> {
use std::sync::LazyLock;
// Reuse the markdown image ref pattern — video_gen uses ![prompt](path.mp4).
static MD_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(MARKDOWN_IMAGE_REF_PATTERN).unwrap());
static VIDEO_PATH_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
let exts = VIDEO_EXTENSIONS.join("|");
// Unix absolute paths (/...) and Windows absolute paths (C:\..., C:/...,
// or \\... UNC). Drive letters accept either separator.
regex::Regex::new(&format!(
r"(?:^|[\s,])((?:/|[A-Za-z]:[\\/]|\\\\)[^\s,]+\.(?:{exts}))(?:[\s,.(]|$)"
))
.unwrap()
});
let mut refs = Vec::new();
let mut seen = std::collections::HashSet::new();
// MD_RE: group 1 = alt text, group 2 = path
for cap in MD_RE.captures_iter(text) {
if let Some(m) = cap.get(2) {
let path_str = m.as_str();
let alt_text = cap
.get(1)
.map(|a| a.as_str().to_owned())
.unwrap_or_default();
if seen.insert(path_str.to_owned())
&& let Some(r) = ScrollbackVideoRef::from_path_with_alt(path_str, alt_text)
{
refs.push(r);
}
}
}
// VIDEO_PATH_RE: group 1 = path (no alt text)
for cap in VIDEO_PATH_RE.captures_iter(text) {
if let Some(m) = cap.get(1) {
let path_str = m.as_str();
if seen.insert(path_str.to_owned())
&& let Some(r) = ScrollbackVideoRef::from_path(path_str)
{
refs.push(r);
}
}
}
refs
}
// =========================================================================
// Tests
// =========================================================================
#[cfg(test)]
mod tests {
use super::*;
/// The image dir is keyed off the session's cwd — the glue the cross-cwd
/// resume fix relies on: with `AgentSession.cwd` anchored to the origin cwd,
/// pasted images land under that origin, not the process cwd.
#[test]
fn session_images_dir_keys_off_the_passed_cwd() {
let id = agent_client_protocol::SessionId::new("sid-xyz");
let a = session_images_dir(Some(&id), std::path::Path::new("/origin/a")).unwrap();
let b = session_images_dir(Some(&id), std::path::Path::new("/origin/b")).unwrap();
assert_ne!(
a, b,
"same id under different cwd must map to different image dirs"
);
assert!(a.ends_with(std::path::Path::new("sid-xyz").join("images")));
assert!(session_images_dir(None, std::path::Path::new("/x")).is_none());
}
#[test]
fn strip_verbatim_prefix_normalizes_paths() {
let strip = |s| strip_verbatim_prefix(std::path::Path::new(s));
assert_eq!(strip(r"\\?\C:\x\1.jpg"), PathBuf::from(r"C:\x\1.jpg"));
assert_eq!(
strip(r"\\?\UNC\srv\s\1.jpg"),
PathBuf::from(r"\\srv\s\1.jpg")
);
// Plain paths (the only form off Windows) pass through untouched.
assert_eq!(strip("/Users/k/1.jpg"), PathBuf::from("/Users/k/1.jpg"));
}
// Helper to create a `PastedImage` with minimal required fields.
fn make_image(element_id: u64, display_number: usize) -> PastedImage {
PastedImage {
element_id: ElementId::from_raw(element_id),
display_number,
mime_type: "image/png".into(),
dimensions: None,
byte_len: 1024,
encoded_bytes: None,
source_path: None,
staged_temp_path: None,
session_image_path: None,
preview: PromptImagePreview::default(),
}
}
// ----- display_text ---------------------------------------------------
#[test]
fn display_text_format() {
assert_eq!(display_text(1), "[Image #1]");
assert_eq!(display_text(5), "[Image #5]");
assert_eq!(display_text(10), "[Image #10]");
}
// ----- extension_for_mime ---------------------------------------------
#[test]
fn extension_for_known_mimes() {
assert_eq!(extension_for_mime("image/png"), "png");
assert_eq!(extension_for_mime("image/jpeg"), "jpg");
assert_eq!(extension_for_mime("image/tiff"), "tiff");
}
#[test]
fn extension_for_unknown_mime() {
assert_eq!(extension_for_mime("application/octet-stream"), "bin");
}
fn make_persistable_image(bytes: Vec<u8>) -> PastedImage {
PastedImage {
element_id: ElementId::from_raw(1),
display_number: 1,
mime_type: "image/png".into(),
dimensions: None,
byte_len: bytes.len(),
encoded_bytes: Some(Arc::from(bytes)),
source_path: None,
staged_temp_path: None,
session_image_path: None,
preview: PromptImagePreview::default(),
}
}
/// `persist_to_session` is atomic (write tmp + rename) and clears
/// `encoded_bytes` after success.
#[test]
fn persist_to_session_writes_full_bytes() {
let dir = tempfile::tempdir().unwrap();
let payload = b"\x89PNG\r\n\x1a\nfake-but-recognizable-payload".to_vec();
let mut img = make_persistable_image(payload.clone());
persist_to_session(&mut img, dir.path()).expect("persist succeeded");
let path = img.session_image_path.as_ref().expect("path set");
let on_disk = std::fs::read(path).expect("readable");
assert_eq!(on_disk, payload);
assert!(img.encoded_bytes.is_none(), "in-memory bytes released");
// No .tmp left behind after success. `Path::extension()` returns
// `OsStr("tmp")` without the dot, so check filenames directly.
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "leaked tmp files: {leftovers:?}");
}
/// On `File::create` failure (read-only dir) `persist_to_session`
/// returns `Err` and leaves no `.tmp` behind.
#[cfg(unix)]
#[test]
fn persist_cleans_up_tmp_on_create_failure() {
use std::os::unix::fs::PermissionsExt;
let euid = unsafe { libc::geteuid() };
if euid == 0 {
return;
}
let dir = tempfile::tempdir().unwrap();
let mut img = make_persistable_image(b"abc".to_vec());
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let result = persist_to_session(&mut img, dir.path());
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(result.is_err());
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "tmp leaked: {leftovers:?}");
}
/// Integration: persist → load_for_send round-trips the bytes.
#[test]
fn persist_then_load_for_send_round_trips() {
let dir = tempfile::tempdir().unwrap();
let payload = b"\x89PNG\r\n\x1a\nround-trip-bytes-12345".to_vec();
let mut img = make_persistable_image(payload.clone());
persist_to_session(&mut img, dir.path()).expect("persist");
let (bytes, mime) = load_for_send(&img).expect("load");
assert_eq!(bytes, payload);
assert_eq!(mime, "image/png");
}
/// `encoded_bytes.is_none()` is an error.
#[test]
fn persist_no_bytes_returns_err() {
let dir = tempfile::tempdir().unwrap();
let mut img = make_persistable_image(b"x".to_vec());
img.encoded_bytes = None;
let err = persist_to_session(&mut img, dir.path()).unwrap_err();
assert!(err.to_string().contains("no encoded bytes"));
}
#[test]
fn persist_preserves_original_source_path_for_file_paste() {
let dir = tempfile::tempdir().unwrap();
let mut img = make_persistable_image(b"abc".to_vec());
let original_source = std::path::PathBuf::from("/tmp/ephemeral/original.png");
img.source_path = Some(original_source.clone());
persist_to_session(&mut img, dir.path()).expect("persist");
assert_eq!(img.source_path.as_ref(), Some(&original_source));
assert_ne!(
img.source_path.as_ref(),
img.session_image_path.as_ref(),
"display and durable paths have distinct ownership"
);
}
/// Write into a path that cannot be created returns `Err`.
#[cfg(unix)]
#[test]
fn persist_readonly_dir_returns_err() {
use std::os::unix::fs::PermissionsExt;
// Root bypasses read-only mode; skip.
let euid = unsafe { libc::geteuid() };
if euid == 0 {
return;
}
let dir = tempfile::tempdir().unwrap();
let readonly = dir.path().join("ro");
std::fs::create_dir(&readonly).unwrap();
std::fs::set_permissions(&readonly, std::fs::Permissions::from_mode(0o500)).unwrap();
let mut img = make_persistable_image(b"abc".to_vec());
let res = persist_to_session(&mut img, &readonly);
let _ = std::fs::set_permissions(&readonly, std::fs::Permissions::from_mode(0o700));
assert!(res.is_err(), "expected write to read-only dir to fail");
}
// ----- shell_unescape -------------------------------------------------
#[test]
fn shell_unescape_spaces() {
assert_eq!(
shell_unescape(r"/path/to/my\ file.png"),
"/path/to/my file.png"
);
}
#[test]
fn shell_unescape_parens() {
assert_eq!(
shell_unescape(r"/Downloads/screenshot\ \(2\).png"),
"/Downloads/screenshot (2).png"
);
}
#[test]
fn shell_unescape_no_escapes() {
assert_eq!(shell_unescape("/simple/path.png"), "/simple/path.png");
}
#[test]
fn shell_unescape_trailing_backslash() {
assert_eq!(shell_unescape("trailing\\"), "trailing\\");
}
#[test]
fn shell_unescape_literal_backslash() {
assert_eq!(shell_unescape(r"path\\name"), r"path\name");
}
// ----- shell_unescape / Windows-path round-trip ----------------------
//
// `\` is a path separator on Windows, not a shell escape. The
// unescape must skip Windows-looking inputs or it would collapse
// `C:\Users\Alice\image.png` to `C:UsersAliceimage.png`.
#[test]
fn shell_unescape_preserves_windows_drive_letter() {
assert_eq!(
shell_unescape(r"C:\Users\Alice\image.png"),
r"C:\Users\Alice\image.png"
);
// Forward-slash drive paths (MSYS2 / MinGW) also pass through.
assert_eq!(shell_unescape("C:/x/y.png"), "C:/x/y.png");
}
#[test]
fn shell_unescape_preserves_windows_unc() {
assert_eq!(
shell_unescape(r"\\server\share\image.png"),
r"\\server\share\image.png"
);
}
#[test]
fn looks_like_windows_path_positive_cases() {
assert!(looks_like_windows_path(r"C:\Users\Alice"));
assert!(looks_like_windows_path(r"d:\Downloads"));
assert!(looks_like_windows_path("C:/Users/Alice"));
assert!(looks_like_windows_path(r"\\server\share\file.png"));
assert!(looks_like_windows_path(r"\\?\C:\long\path"));
}
#[test]
fn looks_like_windows_path_negative_cases() {
assert!(!looks_like_windows_path("/Users/Alice/file.png"));
assert!(!looks_like_windows_path("~/Downloads/file.png"));
assert!(!looks_like_windows_path("file://path"));
assert!(!looks_like_windows_path("C:"));
assert!(!looks_like_windows_path("C:foo"));
assert!(!looks_like_windows_path(r"\foo"));
assert!(!looks_like_windows_path(""));
}
#[test]
fn token_to_path_round_trips_quoted_windows_path() {
// Windows Terminal wraps paths-with-spaces in double quotes;
// quote stripping then shell_unescape-skip must leave the
// path intact for downstream `is_file()`.
let path = token_to_path("\"C:\\Users\\Alice\\My Folder\\image.png\"").unwrap();
assert_eq!(
path,
std::path::PathBuf::from(r"C:\Users\Alice\My Folder\image.png")
);
}
// ----- try_read_image_from_path ----------------------------------------
#[test]
fn try_read_image_with_escaped_parens() {
let dir = tempfile::tempdir().unwrap();
let real_path = dir.path().join("screenshot (2).png");
let png = make_test_png(10, 10);
std::fs::write(&real_path, &png).unwrap();
// Simulate what the terminal pastes: escaped spaces and parens
let escaped = format!("{}/screenshot\\ \\(2\\).png", dir.path().display());
let result = try_read_image_from_path(&escaped);
assert!(
result.is_some(),
"should recognize path with escaped parens"
);
let img = result.unwrap();
assert_eq!(img.mime_type, "image/png");
assert!(
img.source_path.is_some(),
"source_path should be set for file-path images"
);
}
#[test]
fn try_read_image_with_escaped_spaces() {
let dir = tempfile::tempdir().unwrap();
let real_path = dir.path().join("my file.png");
let png = make_test_png(10, 10);
std::fs::write(&real_path, &png).unwrap();
let escaped = format!("{}/my\\ file.png", dir.path().display());
let result = try_read_image_from_path(&escaped);
assert!(result.is_some(), "should recognize path with escaped space");
assert!(result.unwrap().source_path.is_some());
}
// ----- single-file resilience (drop with trailing whitespace / quotes /
// file:// URLs) ---------------------------------------------------
/// Writes a real PNG at `path`. Helper to keep the multi-file tests tidy.
fn write_png(path: &std::path::Path, w: u32, h: u32) {
std::fs::write(path, make_test_png(w, h)).unwrap();
}
#[test]
fn try_read_image_bare_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
let result = try_read_image_from_path(&p.display().to_string()).unwrap();
assert!(result.source_path.is_some());
assert_eq!(result.mime_type, "image/png");
}
#[test]
fn try_read_image_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
let pasted = format!("{}\n", p.display());
assert!(
try_read_image_from_path(&pasted).is_some(),
"single trailing newline must not break drop"
);
}
#[test]
fn try_read_image_trailing_crlf() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
let pasted = format!("{}\r\n", p.display());
assert!(try_read_image_from_path(&pasted).is_some());
}
#[test]
fn try_read_image_double_quoted_path() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
let pasted = format!("\"{}\"", p.display());
assert!(try_read_image_from_path(&pasted).is_some());
}
#[test]
fn try_read_image_single_quoted_path() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
let pasted = format!("'{}'", p.display());
assert!(try_read_image_from_path(&pasted).is_some());
}
// ----- file:// URL parsing -------------------------------------------
#[test]
fn try_read_image_file_url() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
let url = format!("file://{}", p.display());
assert!(try_read_image_from_path(&url).is_some());
}
#[test]
fn try_read_image_file_url_percent_encoded() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("has space.png");
write_png(&p, 2, 2);
// Build a file:// URL with %20 in place of the literal space.
let url = format!(
"file://{}/has%20space.png",
dir.path().display().to_string().replace(' ', "%20")
);
assert!(
try_read_image_from_path(&url).is_some(),
"percent-encoded file:// URL must decode to a real path"
);
}
#[test]
fn try_read_image_file_url_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
let pasted = format!("file://{}\n", p.display());
assert!(try_read_image_from_path(&pasted).is_some());
}
#[test]
fn try_read_image_file_url_quoted() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
let pasted = format!("\"file://{}\"", p.display());
assert!(try_read_image_from_path(&pasted).is_some());
}
// ----- multi-file drop -----------------------------------------------
/// Non-image paths are canonicalized before insertion.
fn canon(p: &std::path::Path) -> PathBuf {
dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
#[test]
fn multi_file_newline_separated() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
write_png(&a, 2, 2);
write_png(&b, 2, 2);
let pasted = format!("{}\n{}", a.display(), b.display());
let images = try_read_images_from_paste(&pasted);
assert_eq!(images.len(), 2);
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
}
#[test]
fn multi_file_space_separated_absolute_paths() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
write_png(&a, 2, 2);
write_png(&b, 2, 2);
let pasted = format!("{} {}", a.display(), b.display());
let images = try_read_images_from_paste(&pasted);
assert_eq!(images.len(), 2);
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
}
#[test]
fn multi_file_mixed_line_endings() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
let c = dir.path().join("c.png");
write_png(&a, 2, 2);
write_png(&b, 2, 2);
write_png(&c, 2, 2);
let pasted = format!("{}\r\n{}\n{}", a.display(), b.display(), c.display());
let images = try_read_images_from_paste(&pasted);
assert_eq!(images.len(), 3);
}
#[test]
fn multi_file_file_urls_and_bare_paths_interleaved() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
let c = dir.path().join("c.png");
write_png(&a, 2, 2);
write_png(&b, 2, 2);
write_png(&c, 2, 2);
let pasted = format!(
"file://{}\n{}\nfile://{}",
a.display(),
b.display(),
c.display()
);
let images = try_read_images_from_paste(&pasted);
assert_eq!(images.len(), 3);
}
/// The whole-paste-or-nothing rule (preserves prose) causes the
/// *entire* paste to fall through to plain text when any line
/// fails to resolve. A non-image text file IS still a valid
/// drop (it becomes a NonImage entry) — but a truly missing
/// path breaks the batch.
#[test]
fn multi_file_missing_middle_path_falls_through() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let c = dir.path().join("c.png");
write_png(&a, 2, 2);
write_png(&c, 2, 2);
let missing = dir.path().join("missing_zzz.png");
let pasted = format!("{}\n{}\n{}", a.display(), missing.display(), c.display());
let images = try_read_images_from_paste(&pasted);
assert!(
images.is_empty(),
"missing middle path must cause whole-paste fall-through; \
got {images:?}",
);
}
/// A non-image file in the middle IS a valid drop (becomes
/// `NonImage`), so the batch still produces 2 images plus 1
/// NonImage path — nothing is silently lost.
#[test]
fn multi_file_non_image_middle_still_emits_all_entries() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let txt = dir.path().join("notes.txt");
let c = dir.path().join("c.png");
write_png(&a, 2, 2);
std::fs::write(&txt, b"not an image").unwrap();
write_png(&c, 2, 2);
let pasted = format!("{}\n{}\n{}", a.display(), txt.display(), c.display());
let entries = dropped_paths(&pasted);
assert_eq!(entries.len(), 3);
// Pin the source-order of the variants, not just the
// counts. The drop classifier inserts in source order and
// order determines the final prompt layout — a regression
// that scrambled the order would still pass a count-only
// assertion.
assert!(
matches!(entries[0], DroppedPath::Image(_)),
"entries[0] must be Image; got {:?}",
entries[0],
);
assert!(
matches!(entries[1], DroppedPath::NonImage(_)),
"entries[1] must be NonImage; got {:?}",
entries[1],
);
assert!(
matches!(entries[2], DroppedPath::Image(_)),
"entries[2] must be Image; got {:?}",
entries[2],
);
}
// ----- negatives — must not auto-attach ------------------------------
#[test]
fn free_prose_containing_slash_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
// The path exists, but the surrounding prose attaches extra
// tokens that prevent the path from validating.
let pasted = format!("check out {} for the bug", p.display());
let images = try_read_images_from_paste(&pasted);
assert!(
images.is_empty(),
"free prose containing an image path must not auto-attach"
);
}
#[test]
fn missing_single_path_returns_empty() {
let images = try_read_images_from_paste("/tmp/does_not_exist_zzz.png");
assert!(images.is_empty());
assert!(try_read_image_from_path("/tmp/does_not_exist_zzz.png").is_none());
}
#[test]
fn relative_path_returns_empty() {
// Relative path "foo.png" is not a real file at the test cwd.
assert!(try_read_images_from_paste("foo.png").is_empty());
assert!(try_read_image_from_path("foo.png").is_none());
}
#[test]
fn random_multi_line_text_returns_empty() {
assert!(try_read_images_from_paste("line one\nline two").is_empty());
}
// ----- additional edge cases ----
#[test]
fn bash_mode_prefix_not_treated_as_image() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
// "! /tmp/foo.png" is a bash-mode prefix paste, not a 2-token
// file drop. The all-parts-anchored gate must reject the split
// and leave the whole payload as one (invalid) token so the
// caller can detect the `! ` prefix.
let pasted = format!("! {}", p.display());
assert!(try_read_images_from_paste(&pasted).is_empty());
}
#[test]
fn prose_ending_in_png_is_not_attached() {
let dir = tempfile::tempdir().unwrap();
let foo = dir.path().join("foo.png");
let bar = dir.path().join("bar.png");
write_png(&foo, 2, 2);
write_png(&bar, 2, 2);
// Prose that *ends* with an image extension is a footgun.
// With the all-parts-anchored gate, the first part
// "see" doesn't anchor and we fall back to a single token that
// fails validation as a path.
let pasted = format!("see {} referenced in {}", foo.display(), bar.display());
assert!(try_read_images_from_paste(&pasted).is_empty());
}
#[test]
fn leading_space_before_single_path_attaches() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
// Leading space followed by a path produces a split-empty + path
// pair; after trim+filter the parts collapse to a single token
// and the path still attaches. Pins down this behavior.
let pasted = format!(" {}", p.display());
assert!(try_read_image_from_path(&pasted).is_some());
}
#[test]
fn newline_wins_space_inside_line_not_split() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let bc = dir.path().join("b.png c.png"); // a single file with a space in its name
let other = dir.path().join("d.png");
write_png(&a, 2, 2);
write_png(&bc, 2, 2);
write_png(&other, 2, 2);
// Mixed payload: newline-split wins; the second line is NOT
// further space-split, so "b.png c.png" is one filename. Pins
// down the "newline wins, space is only a single-line fallback"
// rule.
let pasted = format!("{}\n{}\n{}", a.display(), bc.display(), other.display());
let images = try_read_images_from_paste(&pasted);
assert_eq!(
images.len(),
3,
"second line must be treated as a single filename with a space"
);
}
#[test]
fn quoted_path_with_internal_backslash_escape() {
let dir = tempfile::tempdir().unwrap();
// Inside the quotes the user supplied a backslash escape. Our
// code still runs `shell_unescape` after stripping quotes — this
// diverges from POSIX shell semantics (where backslash inside
// double quotes is mostly literal) but is consistent with the
// pre-existing single-image wrapper and the common drag-and-drop
// flow. The test pins down the actual behavior.
let p = dir.path().join("my file.png");
write_png(&p, 2, 2);
let pasted = format!("\"{}/my\\ file.png\"", dir.path().display());
assert!(try_read_image_from_path(&pasted).is_some());
}
// ----- file:// URL edge cases ----------------------------
#[test]
fn file_url_with_localhost_host() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
// `file://localhost/...` is accepted by the `url` crate and
// yields the same local path as `file:///...`. Pins behavior.
let pasted = format!("file://localhost{}", p.display());
assert!(try_read_image_from_path(&pasted).is_some());
}
#[test]
fn file_url_with_query_string_accepted() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
// `url::Url::to_file_path()` on `file:///…/foo.png?q=1` strips
// the query, so the file is found and the image attaches. Pins
// the current `url` crate contract; a future change that, say,
// started including the query in the path component would trip
// this assertion.
let pasted = format!("file://{}?q=1", p.display());
assert!(try_read_image_from_path(&pasted).is_some());
}
#[test]
fn file_url_with_fragment_accepted() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("foo.png");
write_png(&p, 2, 2);
// Fragments are stripped by `url::Url::to_file_path()`, so this
// resolves to the same local path. Pins behavior.
let pasted = format!("file://{}#frag", p.display());
assert!(try_read_image_from_path(&pasted).is_some());
}
// ----- multi-file space-separated mixed file:// + bare ---
#[test]
fn multi_file_space_separated_file_url_then_bare() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
write_png(&a, 2, 2);
write_png(&b, 2, 2);
let pasted = format!("file://{} {}", a.display(), b.display());
let images = try_read_images_from_paste(&pasted);
assert_eq!(images.len(), 2, "file:// + bare path split must work");
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
}
#[test]
fn multi_file_space_separated_bare_then_file_url() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
write_png(&a, 2, 2);
write_png(&b, 2, 2);
let pasted = format!("{} file://{}", a.display(), b.display());
let images = try_read_images_from_paste(&pasted);
assert_eq!(images.len(), 2, "bare + file:// path split must work");
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
}
#[test]
fn multi_file_mixed_newline_and_space_separated() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
let c = dir.path().join("c.png");
let d = dir.path().join("d.png");
write_png(&a, 2, 2);
write_png(&b, 2, 2);
write_png(&c, 2, 2);
write_png(&d, 2, 2);
let pasted = format!(
"{} {}\n{} {}",
a.display(),
b.display(),
c.display(),
d.display()
);
let images = try_read_images_from_paste(&pasted);
assert_eq!(images.len(), 4, "mixed newline+space must flatten to 4");
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
assert_eq!(images[2].source_path.as_ref().unwrap(), &c);
assert_eq!(images[3].source_path.as_ref().unwrap(), &d);
}
/// A valid drop line followed by a prose comment line causes
/// the whole paste to fall through to plain text — the dominant
/// real-world use case is "screenshot URL + caption" and the
/// caption must survive verbatim instead of being silently
/// dropped on the floor.
#[test]
fn multi_file_valid_line_plus_prose_line_falls_through() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
write_png(&a, 2, 2);
write_png(&b, 2, 2);
let pasted = format!(
"{} {}\nbut please ignore the second screenshot",
a.display(),
b.display()
);
let images = try_read_images_from_paste(&pasted);
assert!(
images.is_empty(),
"valid-drop-plus-prose paste must fall through so the prose \
survives via plain-text-paste; got {images:?}",
);
}
#[test]
fn multi_file_space_separated_both_file_urls() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.png");
let b = dir.path().join("b.png");
write_png(&a, 2, 2);
write_png(&b, 2, 2);
let pasted = format!("file://{} file://{}", a.display(), b.display());
let images = try_read_images_from_paste(&pasted);
assert_eq!(images.len(), 2, "two file:// URLs must split on space");
assert_eq!(images[0].source_path.as_ref().unwrap(), &a);
assert_eq!(images[1].source_path.as_ref().unwrap(), &b);
}
#[test]
fn file_url_single_slash_rejected_at_anchor_gate() {
// Single-slash `file:` URLs lack the `file://` prefix the
// anchor gate requires, and don't start with a path
// anchor (`/`, `~/`, `X:\`) either — `file:` starts with the
// ASCII letter `f`. So they fall through to plain text paste
// regardless of filesystem state. Pins observable wrapper
// behaviour against accidentally relaxing the anchor gate to
// also accept `file:` (single-slash). A `url`-crate upgrade
// that started or stopped accepting `file:/...` at parse
// time would still satisfy this assertion because the
// anchor gate now rejects single-slash strings *before* any
// URL parsing runs.
let pasted = "file:/tmp/should_not_exist_abc_grok_pager.png";
assert!(try_read_image_from_path(pasted).is_none());
}
#[test]
fn file_url_lookalike_scheme_rejected() {
// `file_url://` does NOT start with the literal `file://`, so
// the URL branch is skipped and the bare-path branch runs;
// the resulting "path" can't be on disk → None.
let pasted = "file_url:///tmp/foo.png";
assert!(try_read_image_from_path(pasted).is_none());
}
// ----- try_read_dropped_paths -----------------------------------------
fn dropped_paths(text: &str) -> Vec<DroppedPath> {
try_read_dropped_paths(text)
}
fn dropped_image_paths(text: &str) -> Vec<PathBuf> {
dropped_paths(text)
.into_iter()
.filter_map(|d| match d {
DroppedPath::Image(img) => img.source_path,
_ => None,
})
.collect()
}
#[cfg(unix)]
#[test]
fn image_source_path_preserves_user_visible_symlink() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target.png");
let visible = dir.path().join("visible.png");
write_png(&target, 8, 8);
std::os::unix::fs::symlink(&target, &visible).unwrap();
let images = try_read_images_from_paste(&visible.display().to_string());
assert_eq!(images.len(), 1);
assert_eq!(images[0].source_path.as_deref(), Some(visible.as_path()));
assert_ne!(visible, dunce::canonicalize(&visible).unwrap());
}
fn dropped_non_image_paths(text: &str) -> Vec<PathBuf> {
dropped_paths(text)
.into_iter()
.filter_map(|d| match d {
DroppedPath::NonImage(p) => Some(p),
_ => None,
})
.collect()
}
#[test]
fn dropped_path_non_image_file_url_returns_decoded_path() {
// A `file://` URL pointing at a non-image file should produce a
// NonImage entry with the decoded absolute path — not be silently
// ignored or routed to an image chip.
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("notes.txt");
std::fs::write(&txt, b"hello").unwrap();
let url = format!("file://{}", txt.display());
let non_images = dropped_non_image_paths(&url);
assert_eq!(non_images.len(), 1);
assert_eq!(non_images[0], canon(&txt));
// No image chip should be created for a .txt file.
assert!(dropped_image_paths(&url).is_empty());
}
#[test]
fn dropped_path_non_image_bare_path_returns_decoded_path() {
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("data.json");
std::fs::write(&txt, b"{}").unwrap();
let non_images = dropped_non_image_paths(&txt.display().to_string());
assert_eq!(non_images.len(), 1);
assert_eq!(non_images[0], canon(&txt));
}
#[test]
fn paste_anchor_kind_classifies_each_form() {
assert_eq!(paste_anchor_kind("file:///tmp/x.png"), "file_url");
assert_eq!(paste_anchor_kind("/tmp/x.png"), "absolute");
assert_eq!(paste_anchor_kind("~/Downloads/x.png"), "tilde");
assert_eq!(
paste_anchor_kind("C:\\Users\\Alice\\x.png"),
"windows_drive"
);
assert_eq!(paste_anchor_kind("hello world"), "none");
assert_eq!(paste_anchor_kind(""), "none");
// ~ without slash is just prose, not a tilde anchor.
assert_eq!(paste_anchor_kind("~lonely"), "none");
// Lowercase Windows drive letters also accepted.
assert_eq!(paste_anchor_kind("d:\\path"), "windows_drive");
// Windows UNC paths (`\\server\share\...`) get their own bucket.
assert_eq!(paste_anchor_kind(r"\\server\share\x.png"), "windows_unc");
}
#[test]
fn starts_with_path_anchor_accepts_unc() {
assert!(starts_with_path_anchor(r"\\server\share\file.png"));
assert!(starts_with_path_anchor(r"\\?\C:\Users\Alice"));
// Single backslash is not a UNC prefix and not a path anchor.
assert!(!starts_with_path_anchor(r"\foo"));
}
#[test]
fn dropped_path_image_file_url_returns_image() {
let dir = tempfile::tempdir().unwrap();
let img = dir.path().join("pic.png");
write_png(&img, 2, 2);
let url = format!("file://{}", img.display());
let images = dropped_image_paths(&url);
assert_eq!(images.len(), 1);
assert_eq!(images[0], img);
assert!(dropped_non_image_paths(&url).is_empty());
}
#[test]
fn dropped_path_percent_encoded_space_round_trips() {
// `%20` must decode to a real space; the path with spaces must
// survive intact (no truncation at the space).
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("My Documents");
std::fs::create_dir_all(&sub).unwrap();
let txt = sub.join("notes report.txt");
std::fs::write(&txt, b"x").unwrap();
// Construct the `file://` URL with `%20` for every space.
let encoded = txt.display().to_string().replace(' ', "%20");
let url = format!("file://{}", encoded);
let non_images = dropped_non_image_paths(&url);
assert_eq!(
non_images.len(),
1,
"percent-encoded space must round-trip; got {non_images:?}"
);
assert_eq!(non_images[0], canon(&txt));
}
#[test]
fn dropped_path_percent_encoded_hash_round_trips() {
// `%23` decodes to `#`. The URL parser must not treat the
// suffix as a fragment.
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("notes#draft.txt");
std::fs::write(&txt, b"x").unwrap();
let encoded = txt.display().to_string().replace('#', "%23");
let url = format!("file://{}", encoded);
let non_images = dropped_non_image_paths(&url);
assert_eq!(
non_images.len(),
1,
"percent-encoded `#` must round-trip; got {non_images:?}"
);
assert_eq!(non_images[0], canon(&txt));
}
#[test]
fn dropped_path_percent_encoded_question_round_trips() {
// `%3F` decodes to `?`. The URL parser must not treat the
// suffix as a query string.
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("query?file.txt");
std::fs::write(&txt, b"x").unwrap();
let encoded = txt.display().to_string().replace('?', "%3F");
let url = format!("file://{}", encoded);
let non_images = dropped_non_image_paths(&url);
assert_eq!(
non_images.len(),
1,
"percent-encoded `?` must round-trip; got {non_images:?}"
);
assert_eq!(non_images[0], canon(&txt));
}
#[test]
fn dropped_path_multi_file_mixed_image_and_non_image() {
// Drop one image + one text file. Image should become an
// image chip, non-image should become a decoded path —
// *neither* should be silently dropped on the floor.
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("a.png");
let txt = dir.path().join("b.txt");
write_png(&png, 2, 2);
std::fs::write(&txt, b"hi").unwrap();
let pasted = format!("{}\n{}", png.display(), txt.display());
let entries = dropped_paths(&pasted);
assert_eq!(entries.len(), 2, "both entries must be reported");
let mut saw_image = false;
let mut saw_non_image = false;
for entry in entries {
match entry {
DroppedPath::Image(img) => {
assert_eq!(img.source_path.as_ref().unwrap(), &png);
saw_image = true;
}
DroppedPath::NonImage(p) => {
assert_eq!(p, canon(&txt));
saw_non_image = true;
}
}
}
assert!(saw_image && saw_non_image);
}
#[test]
fn dropped_path_multi_file_url_mixed() {
// Same as above but both tokens are `file://` URLs.
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("img.png");
let txt = dir.path().join("doc.md");
write_png(&png, 2, 2);
std::fs::write(&txt, b"# hi").unwrap();
let pasted = format!("file://{} file://{}", png.display(), txt.display());
let entries = dropped_paths(&pasted);
assert_eq!(entries.len(), 2);
// Pin the variant of each token — a regression that collapses
// both into Image (or both into NonImage) would otherwise be
// missed by a bare `len() == 2` assertion.
let mut saw_image = false;
let mut saw_non_image = false;
for entry in entries {
match entry {
DroppedPath::Image(img) => {
assert_eq!(img.source_path.as_ref().unwrap(), &png);
saw_image = true;
}
DroppedPath::NonImage(p) => {
assert_eq!(p, canon(&txt));
saw_non_image = true;
}
}
}
assert!(saw_image && saw_non_image);
}
#[test]
fn dropped_path_two_non_image_file_urls_both_intercepted() {
// The most likely real-world Finder multi-select drop pattern
// for source code review: drag two text files into the TUI.
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.md");
let b = dir.path().join("b.md");
std::fs::write(&a, b"# a").unwrap();
std::fs::write(&b, b"# b").unwrap();
let pasted = format!("file://{}\nfile://{}", a.display(), b.display());
let entries = dropped_paths(&pasted);
assert_eq!(entries.len(), 2);
let paths: Vec<_> = entries
.into_iter()
.map(|d| match d {
DroppedPath::NonImage(p) => p,
_ => panic!("expected both as NonImage"),
})
.collect();
assert_eq!(paths[0], canon(&a));
assert_eq!(paths[1], canon(&b));
}
#[test]
fn dropped_path_plus_sign_not_decoded_as_space() {
// RFC 3986 path-style decoding preserves `+`; only
// application/x-www-form-urlencoded decoding maps `+` → ` `.
// Filenames with `+` are common (`C++ Source.cpp`, `5+5.txt`).
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("c++ source.cpp");
std::fs::write(&txt, b"int main() {}").unwrap();
// No percent-encoding: literal `+` in the URL.
// (Spaces still need to be encoded.)
let encoded = txt.display().to_string().replace(' ', "%20");
let url = format!("file://{}", encoded);
let non_images = dropped_non_image_paths(&url);
assert_eq!(non_images.len(), 1, "got {non_images:?}");
let got = &non_images[0];
assert_eq!(got, &canon(&txt));
// Belt-and-braces: the decoded path must still contain a `+`
// character, not a stray space.
assert!(
got.to_string_lossy().contains('+'),
"`+` must survive decoding intact; got {got:?}"
);
}
/// Build a `file://` URL by URL-encoding `dir.path()` as a path
/// segment first, then appending the (already-encoded) leaf. Used
/// by tests so a `TMPDIR` resolving under a path with characters
/// that need percent-encoding (e.g. `+` or space) doesn't yield a
/// silently-unparseable URL.
fn build_file_url(dir: &std::path::Path, encoded_leaf: &str) -> String {
let base = url::Url::from_file_path(dir)
.expect("tempdir path must round-trip through url::Url::from_file_path");
let url = format!("{}/{}", base.as_str().trim_end_matches('/'), encoded_leaf);
assert!(
url::Url::parse(&url).is_ok(),
"constructed URL must parse: {url}"
);
url
}
#[test]
fn dropped_path_multibyte_utf8_percent_encoded_round_trips() {
// macOS Finder emits `%XX` triplets for each UTF-8 byte of
// non-ASCII filename characters. `…` (U+2026) encodes as
// `%E2%80%A6`. The full triplet sequence must decode to the
// original codepoint, not be partially decoded or dropped.
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("ellipsis…file.md");
std::fs::write(&txt, b"# hi").unwrap();
let url = build_file_url(dir.path(), "ellipsis%E2%80%A6file.md");
let non_images = dropped_non_image_paths(&url);
assert_eq!(non_images.len(), 1, "got {non_images:?}");
assert_eq!(non_images[0], canon(&txt));
}
#[test]
fn dropped_path_mixed_case_percent_hex_equivalent() {
// RFC 3986 §2.1: `%2F` and `%2f` are equivalent. Some
// producers emit lowercase, some uppercase — both must
// round-trip to the same decoded path. Uses the ellipsis
// codepoint `…` (UTF-8 bytes `E2 80 A6`) so the percent
// triplets contain letters and case actually matters.
let dir = tempfile::tempdir().unwrap();
let ellipsis_file = dir.path().join("e…e.txt");
std::fs::write(&ellipsis_file, b"x").unwrap();
let upper = build_file_url(dir.path(), "e%E2%80%A6e.txt");
let lower = build_file_url(dir.path(), "e%e2%80%a6e.txt");
let up = dropped_non_image_paths(&upper);
let lo = dropped_non_image_paths(&lower);
assert_eq!(up, lo, "mixed-case %XX must decode identically");
assert_eq!(up.len(), 1);
assert_eq!(up[0], canon(&ellipsis_file));
}
#[test]
fn dropped_path_invalid_percent_sequence_tolerated_outcome() {
// `%ZZ` is not a valid percent escape. The workspace-pinned
// `url` crate is lenient: it accepts the URL and `to_file_path`
// returns the path with the literal `%ZZ` triplet preserved.
// Two outcomes are acceptable: (a) the parser preserves the
// literal `%ZZ` and we emit a single `NonImage` with `%ZZ` in
// the path, or (b) the parser rejects the URL and we emit
// empty Vec → caller falls through to plain text paste. Any
// *third* outcome — empty Vec without falling through, or
// partial decoding of the suffix — must fail the test.
//
// Hermeticity: build the URL under a tempfile so a hostile
// `/tmp/bad%ZZname.txt` left around by a previous test can't
// change the variant emitted.
let dir = tempfile::tempdir().unwrap();
let base = url::Url::from_file_path(dir.path()).unwrap();
let url = format!("{}/bad%ZZname.txt", base.as_str().trim_end_matches('/'));
let entries = dropped_paths(&url);
let ok = entries.is_empty()
|| (entries.len() == 1
&& matches!(&entries[0], DroppedPath::NonImage(p) if p.to_string_lossy().contains("%ZZ")));
assert!(
ok,
"%ZZ outcome must be either empty-Vec or single NonImage with literal `%ZZ`; got {entries:?}"
);
}
#[test]
fn dropped_path_extension_says_image_bytes_say_no_falls_to_non_image() {
// `.png` extension but bytes are garbage — `read_image_at_path`
// rejects via `mime_from_bytes` returning octet-stream, so the
// NonImage gate fires and the user gets the path text instead
// of a chip.
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join("corrupt.png");
std::fs::write(&fake, b"this is not a PNG").unwrap();
let url = format!("file://{}", fake.display());
let entries = dropped_paths(&url);
assert_eq!(entries.len(), 1);
match &entries[0] {
DroppedPath::NonImage(p) => assert_eq!(p, &canon(&fake)),
other => panic!("expected NonImage fallthrough, got {other:?}"),
}
}
/// A bare cwd-relative image filename like `foo.png` (no `/`,
/// no `~/`, no `X:\`, no `file://`) is rejected by the anchor
/// gate BEFORE `read_image_at_path` runs — so even if `foo.png`
/// happened to exist in the test process's cwd, it would not be
/// intercepted as an Image. The contrast case (an absolute-path
/// `file://` URL to the same content) is intercepted,
/// documenting the asymmetry next to executable code.
#[test]
fn dropped_path_bare_cwd_relative_image_name_not_intercepted() {
// Create a real PNG at <tempdir>/foo.png. The bare name
// `foo.png` must NOT be intercepted: the anchor gate
// short-circuits before `read_image_at_path` runs.
let dir = tempfile::tempdir().unwrap();
let abs = dir.path().join("foo.png");
write_png(&abs, 2, 2);
// Bare relative name → rejected at the anchor gate.
let bare = dropped_paths("foo.png");
assert!(
bare.is_empty(),
"bare cwd-relative image name must NOT be intercepted; got {bare:?}"
);
// Same content via an absolute `file://` URL → accepted as Image.
let url = format!("file://{}", abs.display());
let via_url = dropped_paths(&url);
assert_eq!(via_url.len(), 1);
assert!(
matches!(via_url[0], DroppedPath::Image(_)),
"absolute file:// to the same PNG must be intercepted as Image; got {via_url:?}"
);
}
/// `file:///` (empty path) and `file://` (root path) are
/// pathological — never legitimate drop URIs. They must be
/// rejected upstream of the existence check so the fallback
/// branch can't emit a bogus `NonImage("/")` entry.
#[test]
fn file_url_with_empty_or_root_path_is_rejected() {
// `file:///` parses to path `/` on Unix. Rejected by the
// root-path gate.
let entries = dropped_paths("file:///");
assert!(
entries.is_empty(),
"file:/// must be rejected; got {entries:?}",
);
// `file://` (no path component) is also rejected — either
// url-parse returns None for the empty path, or the empty-
// OsStr gate catches it.
let entries = dropped_paths("file://");
assert!(
entries.is_empty(),
"file:// must be rejected; got {entries:?}",
);
}
/// A `file://` URL with a percent-encoded NUL or embedded
/// CR/LF byte decodes to a path that corrupts the terminal/text
/// pipeline when inserted as text. Reject these at parse time
/// so the prompt never sees them.
///
/// The gate is intentionally narrow (NUL, CR, LF) — TAB and
/// other low-control bytes are legal in Unix filenames and the
/// TUI's text path renders them fine.
#[test]
fn file_url_with_nul_byte_path_is_rejected() {
let entries = dropped_paths("file:///tmp/path%00.png");
assert!(
entries.is_empty(),
"NUL-byte path must be rejected; got {entries:?}",
);
// CR and LF likewise corrupt the paste pipeline; reject them.
let entries = dropped_paths("file:///tmp/cr%0Dpath.txt");
assert!(
entries.is_empty(),
"CR-byte path must be rejected; got {entries:?}",
);
let entries = dropped_paths("file:///tmp/lf%0Apath.txt");
assert!(
entries.is_empty(),
"LF-byte path must be rejected; got {entries:?}",
);
}
/// HEIC/HEIF/AVIF/ICO are intentionally NOT in `IMAGE_EXTENSIONS`
/// — the inline overlay doesn't render them, so we fall through
/// to NonImage path text instead of falsely promoting a chip.
#[test]
fn unsupported_image_extensions_fall_to_non_image() {
for ext in ["heic", "heif", "avif", "ico"] {
assert!(!IMAGE_EXTENSIONS.contains(&ext), "{ext} must be omitted");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(format!("img.{ext}"));
std::fs::write(&path, b"any bytes").unwrap();
let url = format!("file://{}", path.display());
let entries = dropped_paths(&url);
assert_eq!(entries.len(), 1, "{ext}: {entries:?}");
assert!(
matches!(entries[0], DroppedPath::NonImage(_)),
"{ext} must fall through to NonImage, got {:?}",
entries[0],
);
}
}
/// SVG is intentionally NOT in `IMAGE_EXTENSIONS` (XML/text
/// formats aren't sniffed as images and the inline overlay does
/// not render SVG). An `.svg` drop must fall through to NonImage
/// so the user gets a path string they can pass to the agent.
#[test]
fn svg_extension_not_intercepted_as_image() {
assert!(!IMAGE_EXTENSIONS.contains(&"svg"));
let dir = tempfile::tempdir().unwrap();
let svg = dir.path().join("logo.svg");
std::fs::write(
&svg,
br#"<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"/>"#,
)
.unwrap();
let url = format!("file://{}", svg.display());
let entries = dropped_paths(&url);
assert_eq!(entries.len(), 1);
assert!(
matches!(entries[0], DroppedPath::NonImage(_)),
"SVG must fall through to NonImage; got {:?}",
entries[0],
);
}
#[test]
fn dropped_path_trailing_spaces_tolerated() {
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("trail3.txt");
std::fs::write(&txt, b"x").unwrap();
let url = format!("file://{} ", txt.display());
let non_images = dropped_non_image_paths(&url);
assert_eq!(non_images.len(), 1, "got {non_images:?}");
assert_eq!(non_images[0], canon(&txt));
}
#[test]
fn dropped_path_trailing_tab_and_newline_tolerated() {
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("trail4.txt");
std::fs::write(&txt, b"x").unwrap();
let url = format!("file://{}\t\n", txt.display());
let non_images = dropped_non_image_paths(&url);
assert_eq!(non_images.len(), 1, "got {non_images:?}");
assert_eq!(non_images[0], canon(&txt));
}
#[test]
fn dropped_path_empty_line_between_file_urls_tolerated() {
// Double newline between two `file://` URLs. `tokenize_paste`
// is supposed to filter the empty intermediate token; pin
// that for the `DroppedPath` flow.
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("blank1.txt");
let b = dir.path().join("blank2.txt");
std::fs::write(&a, b"x").unwrap();
std::fs::write(&b, b"x").unwrap();
let pasted = format!("file://{}\n\nfile://{}", a.display(), b.display());
let non_images = dropped_non_image_paths(&pasted);
assert_eq!(non_images.len(), 2, "got {non_images:?}");
assert_eq!(non_images[0], canon(&a));
assert_eq!(non_images[1], canon(&b));
}
#[test]
fn dropped_path_double_space_between_file_urls_tolerated() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("dbl1.txt");
let b = dir.path().join("dbl2.txt");
std::fs::write(&a, b"x").unwrap();
std::fs::write(&b, b"x").unwrap();
let pasted = format!("file://{} file://{}", a.display(), b.display());
let non_images = dropped_non_image_paths(&pasted);
assert_eq!(non_images.len(), 2, "got {non_images:?}");
assert_eq!(non_images[0], canon(&a));
assert_eq!(non_images[1], canon(&b));
}
#[test]
fn dropped_path_directory_via_file_url_intercepted_as_non_image() {
// A directory dropped as a `file://` URL is intercepted as
// NonImage (the NonImage branch uses `path.exists()`, not
// `is_file()`). Useful for "drag a folder into the TUI" UX.
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("a_folder");
std::fs::create_dir_all(&sub).unwrap();
let url = format!("file://{}", sub.display());
let entries = dropped_paths(&url);
assert_eq!(entries.len(), 1);
assert!(matches!(entries[0], DroppedPath::NonImage(_)));
}
#[test]
fn dropped_path_directory_via_bare_path_intercepted_as_non_image() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("another_folder");
std::fs::create_dir_all(&sub).unwrap();
let entries = dropped_paths(&sub.display().to_string());
assert_eq!(entries.len(), 1);
assert!(matches!(entries[0], DroppedPath::NonImage(_)));
}
#[test]
fn dropped_path_prose_with_embedded_existing_path_not_truncated() {
// /etc/hosts exists on every Unix-y machine. A sentence that
// mentions it must NOT be truncated to just the path token.
// (Regression sentinel for "prose-with-real-paths" — the
// full sentence isn't a valid file, so the all-anchored
// tokenizer gate falls back to a single-token line which
// doesn't exist on disk and hence is rejected.)
let entries = dropped_paths("I read /etc/passwd and got confused");
assert!(
entries.is_empty(),
"prose with embedded existing path must fall through to plain text; got {entries:?}"
);
let entries = dropped_paths("Look at /etc/hosts to debug DNS");
assert!(
entries.is_empty(),
"prose with embedded existing path must fall through to plain text; got {entries:?}"
);
}
#[test]
fn try_read_images_from_paste_equals_image_filtered_dropped_paths() {
// `try_read_images_from_paste` is now a thin filter over
// `try_read_dropped_paths`. Lock in the delegation invariant
// for several input shapes so a regression that diverges
// only in one shape (e.g. the empty-paste case) would still
// break the public-API contract visibly.
let dir = tempfile::tempdir().unwrap();
let png1 = dir.path().join("img1.png");
let png2 = dir.path().join("img2.png");
let txt1 = dir.path().join("doc1.md");
let txt2 = dir.path().join("doc2.md");
write_png(&png1, 2, 2);
write_png(&png2, 2, 2);
std::fs::write(&txt1, b"# x").unwrap();
std::fs::write(&txt2, b"# y").unwrap();
// Cover: empty, prose, image-only, non-image-only,
// mixed-image-and-non-image, multi-image, multi-non-image.
let inputs: Vec<String> = vec![
String::new(),
"hello world this is just prose".to_string(),
format!("file://{}", png1.display()),
format!("file://{}", txt1.display()),
format!("file://{} file://{}", png1.display(), txt1.display()),
format!("file://{}\nfile://{}", png1.display(), png2.display()),
format!("file://{}\nfile://{}", txt1.display(), txt2.display()),
];
for input in &inputs {
let images = try_read_images_from_paste(input);
let dropped = try_read_dropped_paths(input);
let image_paths_via_dropped: Vec<PathBuf> = dropped
.into_iter()
.filter_map(|d| match d {
DroppedPath::Image(img) => img.source_path,
DroppedPath::NonImage(_) => None,
})
.collect();
let image_paths_via_filter: Vec<PathBuf> = images
.into_iter()
.filter_map(|img| img.source_path)
.collect();
assert_eq!(
image_paths_via_filter, image_paths_via_dropped,
"delegation invariant must hold for input {input:?}"
);
}
}
/// A single line that space-splits into multiple anchored
/// tokens where some don't resolve must NOT silently emit only
/// the resolving ones. The entire line falls through so the
/// unresolved substring isn't dropped into the void.
#[test]
fn dropped_path_per_line_partial_resolution_falls_through() {
let dir = tempfile::tempdir().unwrap();
// Hermeticity: `space_split_line` splits on each space that
// precedes a drop anchor. A `$TMPDIR` containing a space
// would inject extra split points into our `bogus` token and
// break the intended two-token tokenisation. macOS' default
// `/var/folders/...` and Linux' default `/tmp/...` are safe;
// a CI sandbox with `TMPDIR=/some path/foo` is not. Fail
// loudly rather than silently producing the wrong test
// shape.
assert!(
!dir.path().to_string_lossy().contains(' '),
"this test assumes no-space TMPDIR; got {:?}",
dir.path()
);
let real = dir.path().join("real_dir");
std::fs::create_dir_all(&real).unwrap();
// Build a line that space-splits to [bogus_anchored, real_dir].
// `bogus_anchored` is `<tmpdir>/nope nope nope` — anchored
// (starts with `/`) so the all-anchored gate keeps it, but
// doesn't exist on disk.
let bogus = dir.path().join("nope nope nope");
let pasted = format!("{} {}", bogus.display(), real.display());
// bogus.exists() is false; real.exists() is true. After the
// per-line all-or-nothing gate the line should emit *nothing*
// so the caller falls through to plain text paste — the user
// sees the verbatim string in the prompt rather than only
// `real_dir` appearing.
let entries = dropped_paths(&pasted);
assert!(
entries.is_empty(),
"partial-resolution within a single line must fall through; got {entries:?}"
);
}
/// A drop-line + prose-line paste falls through to plain text
/// so the prose isn't silently lost — the screenshot-URL-
/// plus-caption pattern is the dominant use case and naive
/// per-line independence would eat the caption.
#[test]
fn dropped_path_url_plus_prose_falls_through() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.txt");
std::fs::write(&real, b"x").unwrap();
let pasted = format!(
"file://{}\nbut please ignore the second comment line",
real.display()
);
let entries = dropped_paths(&pasted);
assert!(
entries.is_empty(),
"URL-plus-prose paste must fall through; got {entries:?}",
);
}
/// The canonical case: a screenshot URL followed by a
/// hand-typed caption must NOT lose the caption — the whole
/// paste falls through to plain text.
#[test]
fn dropped_path_url_plus_caption_falls_through_for_plain_paste() {
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("screenshot.png");
write_png(&png, 2, 2);
let pasted = format!("file://{}\nplease look at this", png.display());
let entries = dropped_paths(&pasted);
assert!(
entries.is_empty(),
"screenshot URL + caption must fall through to plain text \
paste; got {entries:?}",
);
}
#[test]
fn dropped_path_single_token_existing_bare_path_intercepted() {
// The flip side of the prose test: a single bare anchored
// path that exists IS a drop. Use a hermetic tempfile-backed
// directory so the test doesn't depend on `/tmp` existing or
// having any particular contents in a CI sandbox.
let dir = tempfile::tempdir().unwrap();
let entries = dropped_paths(&dir.path().display().to_string());
// The directory exists; the NonImage gate uses `path.exists()`
// (not `is_file()`), so directories qualify.
assert_eq!(entries.len(), 1);
assert!(matches!(entries[0], DroppedPath::NonImage(_)));
}
#[test]
fn dropped_path_trailing_newline_tolerated_non_image() {
// Trailing-newline tolerance must extend to non-image paths.
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("trail.txt");
std::fs::write(&txt, b"x").unwrap();
let url = format!("file://{}\n", txt.display());
let non_images = dropped_non_image_paths(&url);
assert_eq!(non_images.len(), 1);
assert_eq!(non_images[0], canon(&txt));
}
#[test]
fn dropped_path_trailing_crlf_tolerated_non_image() {
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("trail2.txt");
std::fs::write(&txt, b"x").unwrap();
let url = format!("file://{}\r\n", txt.display());
let non_images = dropped_non_image_paths(&url);
assert_eq!(non_images.len(), 1);
assert_eq!(non_images[0], canon(&txt));
}
#[test]
fn dropped_path_prose_not_intercepted() {
// Free-form text must NOT be intercepted as a drop path.
let entries = dropped_paths("hello world this is just text");
assert!(entries.is_empty());
}
#[test]
fn dropped_path_nonexistent_bare_path_not_intercepted() {
// A bare path to a non-existent file is NOT intercepted: the
// user might just have typed `/etc/passwd` as part of prose.
// (The image branch already filters on file existence; this
// mirrors that for the non-image branch.)
let entries = dropped_paths("/tmp/definitely_does_not_exist_xyz_grok_pager.txt");
assert!(
entries.is_empty(),
"bare nonexistent path must fall through to prose; got {entries:?}"
);
}
/// Regression sentinel for the `canonicalize().unwrap_or(path)`
/// fallback inside `try_read_dropped_path`. A `file://` URL is an
/// unambiguous drop URI even when the target is missing (stale
/// path, network mount). `canonicalize()` returns `Err` for
/// missing targets — the fallback branch must emit the raw
/// decoded path as `NonImage` so the user still gets a usable
/// path string.
#[test]
fn dropped_path_nonexistent_file_url_still_intercepted() {
// Use a tempdir-rooted nonexistent path so a developer's or
// CI sandbox's filesystem can't accidentally make the path
// resolve (`/tmp/definitely_does_not_exist...` could be
// present on a noisy machine).
let dir = tempfile::tempdir().unwrap();
let nonexistent = dir.path().join("does/not/exist/at/all.txt");
let url = format!("file://{}", nonexistent.display());
let entries = dropped_paths(&url);
assert_eq!(entries.len(), 1);
// Explicit variant check first so a regression that
// collapsed `NonImage` into `Image` (or returned no entries)
// fails with a clean error rather than a destructuring panic.
assert!(
matches!(entries[0], DroppedPath::NonImage(_)),
"expected NonImage variant; got {:?}",
entries[0]
);
match &entries[0] {
DroppedPath::NonImage(p) => {
assert_eq!(p, &nonexistent);
}
_ => unreachable!("matches! above asserted the variant"),
}
}
// ----- reconcile ------------------------------------------------------
#[test]
fn reconcile_keeps_live_images() {
let mut images = vec![make_image(1, 1), make_image(2, 2), make_image(3, 3)];
let live: HashSet<ElementId> = [ElementId::from_raw(1), ElementId::from_raw(3)].into();
reconcile(&mut images, &live);
assert_eq!(images.len(), 2);
assert_eq!(images[0].display_number, 1);
assert_eq!(images[1].display_number, 3);
}
#[test]
fn reconcile_removes_all_when_empty_live_set() {
let mut images = vec![make_image(1, 1), make_image(2, 2)];
let live: HashSet<ElementId> = HashSet::new();
reconcile(&mut images, &live);
assert!(images.is_empty());
}
#[test]
fn reconcile_noop_when_all_live() {
let mut images = vec![make_image(1, 1), make_image(2, 2)];
let live: HashSet<ElementId> = [ElementId::from_raw(1), ElementId::from_raw(2)].into();
reconcile(&mut images, &live);
assert_eq!(images.len(), 2);
}
#[test]
fn reconcile_noop_on_empty_images() {
let mut images: Vec<PastedImage> = Vec::new();
let live: HashSet<ElementId> = [ElementId::from_raw(1)].into();
reconcile(&mut images, &live);
assert!(images.is_empty());
}
// ----- clear ----------------------------------------------------------
#[test]
fn clear_resets_images_and_counter() {
let mut images = vec![make_image(1, 1), make_image(2, 2)];
let mut counter = 5usize;
clear(&mut images, &mut counter);
assert!(images.is_empty());
assert_eq!(counter, 0);
}
// ----- persist_to_session ------------------------------------------------
#[test]
fn persist_writes_file_and_clears_bytes() {
let dir = tempfile::tempdir().unwrap();
let images_dir = dir.path().join("images");
let png_bytes = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0];
let mut img = PastedImage {
element_id: ElementId::from_raw(1),
display_number: 1,
mime_type: "image/png".into(),
dimensions: None,
byte_len: png_bytes.len(),
encoded_bytes: Some(Arc::from(png_bytes.clone())),
source_path: None,
staged_temp_path: None,
session_image_path: None,
preview: PromptImagePreview::default(),
};
persist_to_session(&mut img, &images_dir).unwrap();
// File was written
let path = img.session_image_path.as_ref().unwrap();
assert!(path.exists());
assert!(path.to_string_lossy().ends_with(".png"));
assert_eq!(std::fs::read(path).unwrap(), png_bytes);
// In-memory bytes released
assert!(img.encoded_bytes.is_none());
// source_path stays None for clipboard pastes — the chip
// should show `[Image #1]` without an internal path.
assert!(
img.source_path.is_none(),
"clipboard paste source_path should remain None"
);
}
#[test]
fn persist_creates_directory() {
let dir = tempfile::tempdir().unwrap();
let images_dir = dir.path().join("deep").join("nested").join("images");
assert!(!images_dir.exists());
let mut img = PastedImage {
element_id: ElementId::from_raw(1),
display_number: 1,
mime_type: "image/jpeg".into(),
dimensions: None,
byte_len: 4,
encoded_bytes: Some(Arc::from(vec![0xff, 0xd8, 0xff, 0xe0])),
source_path: None,
staged_temp_path: None,
session_image_path: None,
preview: PromptImagePreview::default(),
};
persist_to_session(&mut img, &images_dir).unwrap();
assert!(images_dir.exists());
let path = img.session_image_path.as_ref().unwrap();
assert!(path.to_string_lossy().ends_with(".jpg"));
}
#[test]
fn persist_fails_without_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut img = make_image(1, 1); // encoded_bytes is None
let result = persist_to_session(&mut img, dir.path());
assert!(result.is_err());
}
// ----- persist_to_session path ownership --------------------------------
#[test]
fn persist_clipboard_image_keeps_source_path_none() {
// Clipboard paste (Copy Image in browser/Slack): source_path
// starts as None and should stay None after persistence so the
// chip shows `[Image #1]` without an internal session path.
let dir = tempfile::tempdir().unwrap();
let images_dir = dir.path().join("images");
let png = make_test_png(100, 80);
let mut img = PastedImage {
element_id: ElementId::from_raw(1),
display_number: 1,
mime_type: "image/png".into(),
dimensions: Some((100, 80)),
byte_len: png.len(),
encoded_bytes: Some(Arc::from(png)),
source_path: None, // clipboard paste — no original path
staged_temp_path: None,
session_image_path: None,
preview: PromptImagePreview::default(),
};
persist_to_session(&mut img, &images_dir).unwrap();
// source_path stays None for clipboard pastes.
assert!(
img.source_path.is_none(),
"clipboard paste should not gain a source_path"
);
// session_image_path is set (bytes are persisted for reload).
assert!(img.session_image_path.is_some());
// Display text shows no path.
let text = display_text(1);
assert_eq!(text, "[Image #1]");
}
#[test]
fn persist_file_image_keeps_original_and_durable_paths_distinct() {
let dir = tempfile::tempdir().unwrap();
let images_dir = dir.path().join("images");
let png = make_test_png(50, 50);
let original_path = PathBuf::from("/tmp/ephemeral-screenshot.png");
let mut img = PastedImage {
element_id: ElementId::from_raw(1),
display_number: 1,
mime_type: "image/png".into(),
dimensions: Some((50, 50)),
byte_len: png.len(),
encoded_bytes: Some(Arc::from(png)),
source_path: Some(original_path.clone()),
staged_temp_path: None,
session_image_path: None,
preview: PromptImagePreview::default(),
};
persist_to_session(&mut img, &images_dir).unwrap();
let session_path = img.session_image_path.as_ref().unwrap();
assert_eq!(img.source_path.as_ref(), Some(&original_path));
assert_ne!(
img.source_path.as_ref(),
Some(session_path),
"source_path is the original display path, not the durable copy"
);
}
// ----- from_clipboard_data -----------------------------------------------
#[test]
fn from_clipboard_data_populates_fields() {
let data = crate::clipboard::ImageData {
data: vec![1, 2, 3, 4],
mime_type: "image/png".into(),
};
let img = from_clipboard_data(&data);
assert_eq!(img.mime_type, "image/png");
assert_eq!(img.byte_len, 4);
assert!(img.encoded_bytes.is_some());
assert_eq!(img.encoded_bytes.as_ref().unwrap().len(), 4);
}
#[test]
fn from_clipboard_prepares_preview_before_render() {
let _guard = crate::terminal::image::set_protocol_for_test(
crate::terminal::image::GraphicsProtocol::Kitty,
);
let data = crate::clipboard::ImageData {
data: make_test_png(16, 12),
mime_type: "image/png".into(),
};
let img = from_clipboard_data(&data);
assert!(img.preview.is_pending());
img.preview_preparation().unwrap().run();
let (bytes, dimensions) = img.preview.prepared().expect("valid PNG becomes ready");
assert_eq!(dimensions, (16, 12));
assert!(crate::terminal::image::kitty_format_from_bytes(bytes).is_some());
}
#[test]
fn from_clipboard_marks_corrupt_preview_failed_without_losing_send_bytes() {
let _guard = crate::terminal::image::set_protocol_for_test(
crate::terminal::image::GraphicsProtocol::Kitty,
);
let data = crate::clipboard::ImageData {
data: b"\x89PNG\r\n\x1a\ncorrupt".to_vec(),
mime_type: "image/png".into(),
};
let img = from_clipboard_data(&data);
img.preview_preparation().unwrap().run();
assert!(img.preview.is_failed());
assert_eq!(img.encoded_bytes.as_deref(), Some(data.data.as_slice()));
}
// ----- test PNG helper --------------------------------------------------
/// Generate a valid minimal PNG of the given dimensions.
fn make_test_png(width: u32, height: u32) -> Vec<u8> {
use image::{ImageBuffer, Rgba};
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_pixel(width, height, Rgba([128, 64, 32, 255]));
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
buf
}
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
use image::{ImageBuffer, Rgb};
let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
ImageBuffer::from_pixel(width, height, Rgb([128, 64, 32]));
let mut buf = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut buf),
image::ImageFormat::Jpeg,
)
.unwrap();
buf
}
/// Create a `PastedImage` with real PNG bytes.
fn make_real_image(width: u32, height: u32) -> PastedImage {
let png = make_test_png(width, height);
PastedImage {
element_id: ElementId::from_raw(1),
display_number: 1,
mime_type: "image/png".into(),
dimensions: Some((width, height)),
byte_len: png.len(),
encoded_bytes: Some(Arc::from(png)),
source_path: None,
staged_temp_path: None,
session_image_path: None,
preview: PromptImagePreview::default(),
}
}
// ----- load_for_send ------------------------------------------------
#[test]
fn load_small_image_passes_through() {
let img = make_real_image(100, 80);
let original_len = img.encoded_bytes.as_ref().unwrap().len();
let (bytes, mime) = load_for_send(&img).unwrap();
assert_eq!(mime, "image/png");
assert_eq!(bytes.len(), original_len);
}
#[test]
fn load_rejects_image_below_eight_pixel_minimum() {
let img = make_real_image(1, 1);
assert!(load_for_send(&img).is_none());
}
#[test]
fn load_from_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.png");
let png = make_test_png(50, 50);
std::fs::write(&path, &png).unwrap();
let img = PastedImage {
element_id: ElementId::from_raw(1),
display_number: 1,
mime_type: "image/png".into(),
dimensions: Some((50, 50)),
byte_len: png.len(),
encoded_bytes: None, // bytes released
source_path: None,
staged_temp_path: None,
session_image_path: Some(path),
preview: PromptImagePreview::default(),
};
let (bytes, _) = load_for_send(&img).unwrap();
assert_eq!(bytes.len(), png.len());
}
#[test]
fn load_returns_none_for_missing_data() {
let img = make_image(1, 1); // no bytes, no file
assert!(load_for_send(&img).is_none());
}
// ----- build_content_blocks_with_workspace --------------------------------
fn build_blocks_no_workspace(
text: String,
images: Vec<PastedImage>,
) -> Vec<agent_client_protocol::ContentBlock> {
build_content_blocks_with_workspace(text, images, None)
}
#[test]
fn build_blocks_text_only() {
let blocks = build_blocks_no_workspace("hello".into(), vec![]);
assert_eq!(blocks.len(), 1);
assert!(matches!(
&blocks[0],
agent_client_protocol::ContentBlock::Text(_)
));
}
#[test]
fn build_blocks_with_in_memory_image() {
let img = make_real_image(100, 80);
let blocks = build_blocks_no_workspace("look at this [Image #1]".into(), vec![img]);
assert_eq!(blocks.len(), 2);
if let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] {
assert_eq!(ic.mime_type, "image/png");
assert!(!ic.data.is_empty());
assert!(ic.uri.is_none());
} else {
panic!("expected Image block");
}
}
#[test]
fn build_blocks_with_file_image() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.png");
let png = make_test_png(60, 40);
std::fs::write(&path, &png).unwrap();
let img = PastedImage {
element_id: ElementId::from_raw(1),
display_number: 1,
mime_type: "image/png".into(),
dimensions: Some((60, 40)),
byte_len: png.len(),
encoded_bytes: None,
source_path: None,
staged_temp_path: None,
session_image_path: Some(path.clone()),
preview: PromptImagePreview::default(),
};
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
assert_eq!(blocks.len(), 2);
if let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] {
assert!(!ic.data.is_empty());
// The durable session copy is surfaced through `uri` even for
// clipboard pastes (no `source_path`). This is the reference
// `image_edit` resolves `[Image #N]` against; vision is
// unaffected because `pick_user_image_url` never forwards a
// `file://` URI to the model.
assert_eq!(
ic.uri.as_deref(),
Some(format!("file://{}", path.display()).as_str()),
"clipboard images must carry the durable session path as a file:// URI"
);
} else {
panic!("expected Image block");
}
}
#[test]
fn build_blocks_omits_uri_for_stale_source_path() {
let mut img = make_real_image(100, 80);
img.source_path = Some(PathBuf::from("/Users/test/logo.png"));
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
assert_eq!(blocks.len(), 2);
if let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] {
assert!(ic.uri.is_none());
assert!(!ic.data.is_empty());
} else {
panic!("expected Image block");
}
}
#[cfg(unix)]
#[test]
fn build_blocks_canonicalizes_source_only_for_wire_uri() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target.png");
let visible = dir.path().join("visible.png");
std::fs::write(&target, make_test_png(8, 8)).unwrap();
std::os::unix::fs::symlink(&target, &visible).unwrap();
let mut img = make_real_image(8, 8);
img.source_path = Some(visible.clone());
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
let agent_client_protocol::ContentBlock::Image(image) = &blocks[1] else {
panic!("expected image");
};
let canonical_target = dunce::canonicalize(&target).unwrap();
assert_eq!(
image.uri.as_deref(),
Some(format!("file://{}", canonical_target.display()).as_str())
);
assert_ne!(visible, target);
}
#[test]
fn build_blocks_sets_display_number_meta() {
let mut img = make_real_image(40, 30);
img.display_number = 3;
let blocks = build_blocks_no_workspace("text [Image #3]".into(), vec![img]);
assert_eq!(blocks.len(), 2);
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
panic!("expected Image block");
};
assert_eq!(
xai_grok_shared::placeholder_images::display_number_from_meta(ic.meta.as_ref()),
Some(3),
"image block _meta must carry the real display number for token resolution"
);
}
#[test]
fn build_blocks_uri_prefers_durable_session_path() {
let mut img = make_real_image(100, 80);
img.source_path = Some(PathBuf::from("/Users/test/original.png"));
img.session_image_path = Some(PathBuf::from("/Users/test/.grok/session/image.png"));
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
assert_eq!(blocks.len(), 2);
if let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] {
assert_eq!(
ic.uri.as_deref(),
Some("file:///Users/test/.grok/session/image.png"),
"model URI must prefer the durable session path"
);
} else {
panic!("expected Image block");
}
}
#[test]
fn build_blocks_skips_missing_image() {
let img = make_image(1, 1); // no bytes, no file path
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
// Only the text block; image was skipped.
assert_eq!(blocks.len(), 1);
}
#[test]
fn build_blocks_one_bad_one_good() {
let bad = make_image(1, 1); // no bytes
let good = make_real_image(50, 50);
let blocks = build_blocks_no_workspace("text".into(), vec![bad, good]);
// Text + 1 good image; bad image skipped.
assert_eq!(blocks.len(), 2);
}
// ----- Orphan placeholder fallback ----------------------------------
//
// These tests go through `build_content_blocks_with_prefixes` with
// an explicit hermetic prefix list, so they do NOT read the
// ambient process `$HOME`. CI runners with unusual `HOME`
// settings cannot flip the outcomes.
#[test]
fn build_blocks_orphan_placeholder_loaded_from_disk() {
use base64::Engine as _;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("orphan.png");
let on_disk = make_test_png(20, 20);
std::fs::write(&path, &on_disk).unwrap();
let text = format!(
"look at [Image #1: {}] please",
dunce::canonicalize(&path).unwrap().display(),
);
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
let blocks = build_content_blocks_with_prefixes(text, vec![], Some(&allowed));
// Text block + 1 recovered image.
assert_eq!(blocks.len(), 2);
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
panic!("expected recovered Image block");
};
assert_eq!(ic.mime_type, "image/png");
assert!(!ic.data.is_empty());
assert!(
ic.uri.as_deref().unwrap().starts_with("file://"),
"uri should be file:// URI form, got {:?}",
ic.uri
);
// Base64-encoded `data` must round-trip back to the on-disk
// PNG bytes. A regression emitting raw bytes or
// double-encoding would fail this assertion.
let decoded = base64::engine::general_purpose::STANDARD
.decode(&ic.data)
.expect("data must be valid base64");
assert_eq!(decoded, on_disk);
// Placeholder anchor stays but the path is now stripped — the
// image is already attached inline, so the model has no reason
// to call `Read` on the path (and the path component would
// tempt it to). The bracketed `[Image #N]` form preserves the
// positional anchor inside the prose.
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
panic!("first block must be text");
};
assert!(
t.text.contains("[Image #1]"),
"anchor should be preserved on successful recovery, got: {}",
t.text
);
assert!(
!t.text.contains("[Image #1:"),
"the path-bearing form must be stripped once the image is attached, got: {}",
t.text
);
assert!(
!t.text.contains("orphan.png"),
"file name leaked through after path strip, got: {}",
t.text
);
}
// Regression: when the user types `[Image #N: <path>]` and the file
// exists at paste time, the prompt-widget creates a PastedImage and
// the image is attached inline. The path in the prompt text must
// then be stripped to `[Image #N]` so the model doesn't follow up
// with a redundant `Read` tool call on the same file.
#[test]
fn build_blocks_strips_path_when_pasted_image_attached() {
let mut img = make_real_image(40, 30);
img.display_number = 1;
img.source_path = Some(std::path::PathBuf::from(
"/Users/me/Desktop/Screenshot 2026-05-22 at 16.01.21.png",
));
let text = "what is that?[Image #1: /Users/me/Desktop/Screenshot 2026-05-22 at 16.01.21.png] thanks".to_string();
// Hermetic: no orphan-recovery path needed (PastedImage matches).
let blocks = build_content_blocks_with_prefixes(text, vec![img], Some(&[]));
assert_eq!(blocks.len(), 2, "expected text + 1 inline image");
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
panic!("first block must be text");
};
assert_eq!(
t.text, "what is that?[Image #1] thanks",
"placeholder path must be stripped while the anchor survives"
);
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
panic!("second block must be the inline image");
};
assert_eq!(ic.mime_type, "image/png");
assert!(!ic.data.is_empty(), "inline image must carry base64 bytes");
}
#[test]
fn build_blocks_orphan_placeholder_missing_file_is_stripped_with_warn() {
let dir = tempfile::tempdir().unwrap();
let bogus = dir.path().join("nope.png");
let text = format!("before [Image #4: {}] after", bogus.display());
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
let blocks = build_content_blocks_with_prefixes(text, vec![], Some(&allowed));
// No image attached, only text block.
assert_eq!(blocks.len(), 1);
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
panic!("expected text block");
};
// Pin the exact post-strip text — the strip seam (space
// before + space after the placeholder) collapses to a
// single space.
assert_eq!(t.text, "before after");
}
#[test]
fn collapse_strip_seam_preserves_code_block_indentation() {
// A naive implementation could collapse ALL 2+-space runs
// in the text after a single strip; indented code further
// down the text must survive intact.
let mut text = String::from("hello [Image #1: /tmp/x.png] world\n fn foo() {}\n");
let span = (text.find("[Image").unwrap(), text.find("]").unwrap() + 1);
collapse_strip_seam(&mut text, span.0, span.1);
// The strip seam (spacespace) collapses to one space, while
// the 4-space code indentation further down is untouched.
assert_eq!(text, "hello world\n fn foo() {}\n");
}
#[test]
fn collapse_strip_seam_preserves_double_space_elsewhere() {
// Punctuation double-space far from the strip seam stays.
let mut text = String::from("a. b [Image #1: /x.png] c. d");
let span = (text.find("[Image").unwrap(), text.find("]").unwrap() + 1);
collapse_strip_seam(&mut text, span.0, span.1);
assert_eq!(text, "a. b c. d");
}
#[test]
fn build_blocks_orphan_skipped_when_pasted_image_present() {
// PastedImage with display_number 1 is attached; the matching
// placeholder must NOT trigger an on-disk load even when the
// placeholder's path doesn't exist.
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist.png");
let img = make_real_image(40, 40);
let text = format!("see [Image #1: {}]", missing.display());
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
let blocks = build_content_blocks_with_prefixes(text, vec![img], Some(&allowed));
// Text + the PastedImage's own block; no orphan recovery
// (skipped because `display_number` matches).
assert_eq!(blocks.len(), 2);
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
panic!("first block must be text");
};
// Phase 2 universal strip: the anchor `[Image #1]` survives so
// the model can place the inline image, but the path is gone
// even though no orphan-recovery loaded it (a PastedImage
// already provided the bytes). This avoids the "model calls
// Read on the path even though the image is attached" pattern.
assert!(
t.text.contains("[Image #1]"),
"anchor must survive when a PastedImage backs the placeholder, got: {}",
t.text
);
assert!(
!t.text.contains("[Image #1:"),
"path must be stripped even when the load was skipped, got: {}",
t.text
);
}
#[test]
fn build_blocks_no_workspace_falls_back_to_legacy_behavior() {
// Without a workspace cwd, orphan placeholders are not loaded
// from disk (legacy behaviour preserved). The Phase 2 path
// strip still runs — it is independent of the allowlist —
// because the model-facing prompt should never contain the
// path-bearing form regardless of whether the load happened.
let text = "look at [Image #2: /nowhere/missing.png]";
let blocks = build_content_blocks_with_workspace(text.into(), vec![], None);
// Text block only — no recovery without a workspace.
assert_eq!(blocks.len(), 1);
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
panic!("expected text block");
};
assert!(
t.text.contains("[Image #2]"),
"anchor must survive the no-workspace path, got: {}",
t.text
);
assert!(
!t.text.contains("[Image #2:"),
"path strip is unconditional once the regex matches, got: {}",
t.text
);
}
// ----- TUI aggregate-cap injectable variant + tests -----------------
//
// Mirrors the server-side
// `recover_orphan_placeholders_with_prefixes_and_caps` tests so a
// refactor of the TUI loop (e.g. moving `aggregate_bytes += ...`
// before the cap check, or swapping `break` for `continue`) is
// caught here even though the cap constant is shared.
/// Two orphan placeholders, aggregate cap admits exactly one.
/// Asserts the second placeholder did NOT load (only one image
/// block in the output) and the first one did.
#[test]
fn build_blocks_orphan_aggregate_cap_breaks_loop() {
let dir = tempfile::tempdir().unwrap();
let p1 = dir.path().join("a.png");
let p2 = dir.path().join("b.png");
let png = make_test_png(20, 20);
std::fs::write(&p1, &png).unwrap();
std::fs::write(&p2, &png).unwrap();
let c1 = dunce::canonicalize(&p1).unwrap();
let c2 = dunce::canonicalize(&p2).unwrap();
let text = format!("[Image #1: {}] [Image #2: {}]", c1.display(), c2.display());
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
// Cap admits the first image but not the cumulative second.
let blocks =
build_content_blocks_with_prefixes_and_caps(text, vec![], Some(&allowed), png.len());
// Text + 1 recovered image (not 2).
assert_eq!(blocks.len(), 2);
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
panic!("expected recovered Image block");
};
let attached_uri = ic.uri.as_deref().unwrap();
assert!(
attached_uri.contains("a.png"),
"first placeholder must be the one kept, got: {attached_uri}"
);
// Cap-breach is a `break` path, not an `Err`-path strip —
// the rejected placeholder's anchor must survive in the
// prompt. Symmetric to the single-image inclusive-boundary
// pin in
// `build_blocks_orphan_aggregate_cap_inclusive_boundary_rejects_at_one_below`.
//
// Phase 2 universal path-strip: the `: <path>` component is
// stripped uniformly across every surviving placeholder, so
// the anchor `[Image #2]` is what survives. Intent: anchor
// preserved so the model still sees the in-prose position;
// path is gone because the image isn't attached and a bare
// path would tempt a `Read`.
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
panic!("expected text block");
};
assert!(
t.text.contains("[Image #2]"),
"anchor of rejected placeholder must survive cap breach, got: {}",
t.text
);
assert!(
!t.text.contains("[Image #2:"),
"path of rejected placeholder must be stripped, got: {}",
t.text
);
}
/// Inclusive boundary: cap == single image size admits the image.
#[test]
fn build_blocks_orphan_aggregate_cap_inclusive_boundary() {
use base64::Engine as _;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("one.png");
let png = make_test_png(20, 20);
std::fs::write(&path, &png).unwrap();
let canon = dunce::canonicalize(&path).unwrap();
let text = format!("[Image #1: {}]", canon.display());
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
let blocks =
build_content_blocks_with_prefixes_and_caps(text, vec![], Some(&allowed), png.len());
assert_eq!(blocks.len(), 2);
// Symmetric to
// `build_blocks_orphan_placeholder_loaded_from_disk` —
// decode the base64 data and assert byte-for-byte equality
// with the on-disk PNG so a regression emitting wrong bytes
// at the inclusive boundary is caught.
let agent_client_protocol::ContentBlock::Image(ic) = &blocks[1] else {
panic!("expected recovered Image block");
};
assert_eq!(ic.mime_type, "image/png");
let decoded = base64::engine::general_purpose::STANDARD
.decode(&ic.data)
.expect("data must be valid base64");
assert_eq!(decoded, png);
}
/// Reject side: cap == image size - 1 rejects the image.
///
/// **Text-side contract.** Aggregate-cap breach is a `break`
/// path in `resolve_orphan_placeholders`, not a per-image
/// `Err` path. Only `Err`-path failures strip the placeholder
/// text; cap-breach intentionally **leaves the placeholder
/// text intact** because the load itself succeeded (the file
/// is valid, just doesn't fit in the budget). The test pins
/// both halves of this contract: no image block AND
/// placeholder text preserved.
#[test]
fn build_blocks_orphan_aggregate_cap_inclusive_boundary_rejects_at_one_below() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("one.png");
let png = make_test_png(20, 20);
std::fs::write(&path, &png).unwrap();
let canon = dunce::canonicalize(&path).unwrap();
let text = format!("[Image #1: {}]", canon.display());
let allowed = [dunce::canonicalize(dir.path()).unwrap()];
let blocks = build_content_blocks_with_prefixes_and_caps(
text,
vec![],
Some(&allowed),
png.len() - 1,
);
// Cap below image size → no recovered image; only the text
// block remains.
assert_eq!(blocks.len(), 1);
let agent_client_protocol::ContentBlock::Text(t) = &blocks[0] else {
panic!("expected text block");
};
// Placeholder anchor is NOT stripped on aggregate-cap
// breach (cap-breach is a `break` path, not a load `Err`).
// Pinning the preservation half of the contract.
//
// Phase 2 path-strip update: the bracketed anchor
// `[Image #N]` survives, but the `: <path>` component is
// stripped uniformly across every surviving placeholder. The
// model can still see *where* in the prose the image was
// referenced via the anchor; the path metadata is no longer
// leaked because no image is actually attached.
assert!(
t.text.contains("[Image #1]"),
"anchor must survive aggregate-cap breach, got: {}",
t.text
);
assert!(
!t.text.contains("[Image #1:"),
"path-bearing form must be stripped, got: {}",
t.text
);
}
// ----- T8: cleanup and lifecycle edge cases ------------------------------
#[test]
fn clear_deletes_staged_temp_file() {
let dir = tempfile::tempdir().unwrap();
let tmp_path = dir.path().join("staged.png");
std::fs::write(&tmp_path, b"fake").unwrap();
assert!(tmp_path.exists());
let mut images = vec![PastedImage {
element_id: ElementId::from_raw(1),
display_number: 1,
mime_type: "image/png".into(),
dimensions: None,
byte_len: 4,
encoded_bytes: None,
source_path: None,
staged_temp_path: Some(tmp_path.clone()),
session_image_path: None, // not yet persisted to session
preview: PromptImagePreview::default(),
}];
let mut counter = 1;
clear(&mut images, &mut counter);
assert!(images.is_empty());
assert!(!tmp_path.exists(), "staged temp file should be deleted");
}
#[test]
fn clear_preserves_session_persisted_file() {
let dir = tempfile::tempdir().unwrap();
let session_path = dir.path().join("image-abc.png");
std::fs::write(&session_path, b"real").unwrap();
let mut images = vec![PastedImage {
element_id: ElementId::from_raw(1),
display_number: 1,
mime_type: "image/png".into(),
dimensions: None,
byte_len: 4,
encoded_bytes: None,
source_path: None,
staged_temp_path: None,
session_image_path: Some(session_path.clone()),
preview: PromptImagePreview::default(),
}];
let mut counter = 1;
clear(&mut images, &mut counter);
assert!(session_path.exists(), "session file should NOT be deleted");
}
#[test]
fn reconcile_deletes_staged_temp_for_removed_chip() {
let dir = tempfile::tempdir().unwrap();
let tmp_path = dir.path().join("orphan.png");
std::fs::write(&tmp_path, b"orphan").unwrap();
let mut images = vec![PastedImage {
element_id: ElementId::from_raw(42),
display_number: 1,
mime_type: "image/png".into(),
dimensions: None,
byte_len: 6,
encoded_bytes: None,
source_path: None,
staged_temp_path: Some(tmp_path.clone()),
session_image_path: None,
preview: PromptImagePreview::default(),
}];
// Element 42 is no longer live.
let live: HashSet<ElementId> = HashSet::new();
reconcile(&mut images, &live);
assert!(images.is_empty());
assert!(!tmp_path.exists(), "staged temp file should be cleaned up");
}
#[test]
fn reconcile_preserves_session_file_for_removed_chip() {
let dir = tempfile::tempdir().unwrap();
let session_path = dir.path().join("persisted.png");
std::fs::write(&session_path, b"keep").unwrap();
let mut images = vec![PastedImage {
element_id: ElementId::from_raw(42),
display_number: 1,
mime_type: "image/png".into(),
dimensions: None,
byte_len: 4,
encoded_bytes: None,
source_path: None,
staged_temp_path: None,
session_image_path: Some(session_path.clone()),
preview: PromptImagePreview::default(),
}];
let live: HashSet<ElementId> = HashSet::new();
reconcile(&mut images, &live);
assert!(images.is_empty());
assert!(
session_path.exists(),
"session-persisted file should remain"
);
}
// ----- ScrollbackImageRef ------------------------------------------------
#[test]
fn scrollback_ref_from_valid_image_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.png");
std::fs::write(&path, make_test_png(100, 80)).unwrap();
let r = ScrollbackImageRef::from_path(&path).unwrap();
assert_eq!(r.path, path);
}
#[test]
fn scrollback_ref_rejects_non_image_extension() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.txt");
std::fs::write(&path, b"hello").unwrap();
assert!(ScrollbackImageRef::from_path(&path).is_none());
}
#[test]
fn scrollback_ref_rejects_undecodable_image_extension() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("not-an-image.png");
std::fs::write(&path, b"hello").unwrap();
assert!(ScrollbackImageRef::from_path(&path).is_none());
}
#[test]
fn scrollback_ref_accepts_valid_jpeg_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("photo.jpg");
std::fs::write(&path, make_test_jpeg(25, 20)).unwrap();
let r = ScrollbackImageRef::from_path(&path).unwrap();
assert_eq!(r.path, path);
}
#[test]
fn extract_skips_undecodable_image_paths() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("not-an-image.png");
std::fs::write(&path, b"hello").unwrap();
let text = format!("![bad]({})", path.display());
let refs = extract_image_refs(&text);
assert!(refs.is_empty());
}
#[test]
fn scrollback_ref_rejects_missing_file() {
assert!(ScrollbackImageRef::from_path("/nonexistent/image.png").is_none());
}
// ----- extract_image_refs ------------------------------------------------
#[test]
fn extract_markdown_image_ref() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hero.png");
std::fs::write(&path, make_test_png(50, 50)).unwrap();
let text = format!("Here is the image: ![hero]({})", path.display());
let refs = extract_image_refs(&text);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].path, path);
}
#[test]
fn extract_bare_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("photo.png");
std::fs::write(&path, make_test_png(25, 20)).unwrap();
let text = format!("saved to {} (1234 bytes)", path.display());
let refs = extract_image_refs(&text);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].path, path);
}
#[test]
fn extract_bare_absolute_jpeg_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("photo.jpg");
std::fs::write(&path, make_test_jpeg(25, 20)).unwrap();
let text = format!("saved to {} (1234 bytes)", path.display());
let refs = extract_image_refs(&text);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].path, path);
}
#[test]
fn media_only_markdown_accepts_image_ref_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("generated.jpg");
std::fs::write(&path, make_test_jpeg(25, 20)).unwrap();
let text = format!(" \n![generated]({})\n", path.display());
let refs = extract_image_refs(&text);
assert_eq!(refs.len(), 1);
assert!(is_media_only_markdown(&text, refs.len()));
}
#[test]
fn media_only_markdown_rejects_mixed_text() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("generated.jpg");
std::fs::write(&path, make_test_jpeg(25, 20)).unwrap();
let text = format!("Here is an image: ![generated]({})", path.display());
let refs = extract_image_refs(&text);
assert_eq!(refs.len(), 1);
assert!(!is_media_only_markdown(&text, refs.len()));
}
#[test]
fn extract_deduplicates_same_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dup.png");
std::fs::write(&path, make_test_png(10, 10)).unwrap();
let text = format!("![a]({p}) and ![b]({p})", p = path.display());
let refs = extract_image_refs(&text);
assert_eq!(refs.len(), 1);
}
#[test]
fn extract_skips_nonexistent_paths() {
let text = "![img](/tmp/nonexistent_abc123.png)";
let refs = extract_image_refs(text);
assert!(refs.is_empty());
}
// ----- open_from_path ----------------------------------------------------
#[test]
fn open_from_path_valid_image() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("viewer.png");
let png = make_test_png(200, 150);
std::fs::write(&path, &png).unwrap();
let viewer = ImageViewerState::open_from_path(&path).unwrap();
assert_eq!(viewer.image_width, 200);
assert_eq!(viewer.image_height, 150);
assert_eq!(viewer.display_number, 1);
assert_eq!(viewer.image_bytes, png);
assert_eq!(viewer.display_bytes, viewer.image_bytes);
}
#[test]
fn open_from_path_valid_jpeg_image() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("viewer.jpg");
let jpeg = make_test_jpeg(200, 150);
std::fs::write(&path, &jpeg).unwrap();
let viewer = ImageViewerState::open_from_path(&path).unwrap();
assert_eq!(viewer.image_width, 200);
assert_eq!(viewer.image_height, 150);
assert_eq!(viewer.image_bytes, jpeg);
}
#[test]
fn open_from_path_missing_file() {
assert!(
ImageViewerState::open_from_path(std::path::Path::new("/no/such/file.png")).is_none()
);
}
// ----- open_from_path_deferred -------------------------------------------
#[test]
fn deferred_open_starts_in_loading_state() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("deferred.png");
let png = make_test_png(80, 60);
std::fs::write(&path, &png).unwrap();
let viewer = ImageViewerState::open_from_path_deferred(&path);
assert!(viewer.loading);
assert!(viewer.image_bytes.is_empty());
assert_eq!(viewer.image_width, 0);
assert_eq!(viewer.title.as_deref(), Some("deferred.png"));
}
#[test]
fn deferred_finish_loading_succeeds() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("deferred.png");
let png = make_test_png(80, 60);
std::fs::write(&path, &png).unwrap();
let mut viewer = ImageViewerState::open_from_path_deferred(&path);
assert!(viewer.finish_loading());
assert!(!viewer.loading);
assert_eq!(viewer.image_width, 80);
assert_eq!(viewer.image_height, 60);
assert_eq!(viewer.image_bytes, png);
}
#[test]
fn deferred_finish_loading_missing_file_fails() {
let mut viewer =
ImageViewerState::open_from_path_deferred(std::path::Path::new("/no/such/file.png"));
assert!(!viewer.finish_loading());
}
#[test]
fn deferred_finish_loading_idempotent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("idem.png");
std::fs::write(&path, make_test_png(10, 10)).unwrap();
let mut viewer = ImageViewerState::open_from_path_deferred(&path);
assert!(viewer.finish_loading());
// Second call is a no-op, returns true.
assert!(viewer.finish_loading());
assert!(!viewer.loading);
}
}