fix(tui/images): harden image drop + clipped rendering

This commit is contained in:
James Peter
2026-01-08 14:22:09 +10:00
parent 43397e21b5
commit b76b245518
3 changed files with 137 additions and 31 deletions
+50 -8
View File
@@ -5918,9 +5918,35 @@ impl ChatWidget<'_> {
// Removed: pending insert sequencing is not used under strict ordering.
pub(crate) fn register_pasted_image(&mut self, placeholder: String, path: std::path::PathBuf) {
let persisted = self.persist_user_image_if_needed(&path).unwrap_or(path);
self.pending_images.insert(placeholder, persisted);
self.request_redraw();
let persisted = self
.persist_user_image_if_needed(&path)
.unwrap_or_else(|| path.clone());
if persisted.exists() && persisted.is_file() {
self.pending_images.insert(placeholder, persisted);
self.request_redraw();
return;
}
// Some terminals (notably on macOS) can drop a "promised" file path
// (e.g., from Preview) that doesn't actually exist on disk. Fall back
// to reading the image directly from the clipboard.
match crate::clipboard_paste::paste_image_to_temp_png() {
Ok((clipboard_path, _info)) => {
let clipboard_persisted = self
.persist_user_image_if_needed(&clipboard_path)
.unwrap_or(clipboard_path);
self.pending_images.insert(placeholder, clipboard_persisted);
self.push_background_tail("Used clipboard image (dropped file path was missing).");
self.request_redraw();
}
Err(err) => {
tracing::warn!(
"dropped/pasted image path missing ({}); clipboard fallback failed: {}",
persisted.display(),
err
);
}
}
}
fn persist_user_image_if_needed(&self, path: &std::path::Path) -> Option<PathBuf> {
@@ -6003,14 +6029,30 @@ impl ChatWidget<'_> {
}
}
let placeholder = mat.as_str();
if placeholder.starts_with("[image:") {
if let Some(path) = self.pending_images.remove(placeholder) {
// Emit the placeholder marker verbatim followed by the image so the LLM sees placement
ordered_items.push(InputItem::Text {
text: placeholder.to_string(),
});
ordered_items.push(InputItem::LocalImage { path });
if path.exists() && path.is_file() {
// Emit the placeholder marker verbatim followed by the image so the LLM sees placement
ordered_items.push(InputItem::Text {
text: placeholder.to_string(),
});
ordered_items.push(InputItem::LocalImage { path });
} else {
tracing::warn!(
"pending image placeholder {} resolved to missing path {}",
placeholder,
path.display()
);
self.push_background_tail(format!(
"Dropped image file went missing; not attaching ({})",
path.display()
));
ordered_items.push(InputItem::Text {
text: placeholder.to_string(),
});
}
} else {
// Unknown placeholder: preserve as text
ordered_items.push(InputItem::Text {
+45 -20
View File
@@ -23,7 +23,7 @@ use ratatui::prelude::Style;
use ratatui::text::{Line, Text};
use ratatui::widgets::{Paragraph, Widget, Wrap};
use ratatui_image::{Image, Resize};
use ratatui_image::picker::Picker;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::FilterType;
use image::ImageReader;
use std::cell::RefCell;
@@ -1134,14 +1134,23 @@ impl BrowserSessionCell {
return;
}
let path = Path::new(path_str);
let rows_to_copy = (visible_bottom - visible_top) as u16;
if rows_to_copy == 0 {
return;
}
let dest_x = area.x + accent_width + left_pad;
let dest_y = area.y + (visible_top - viewport_top) as u16;
let placeholder_area = Rect {
x: dest_x,
y: dest_y,
width: screenshot_width,
height: rows_to_copy,
};
if !path.exists() {
let placeholder_area = Rect {
x: area.x + accent_width + left_pad,
y: area.y,
width: screenshot_width,
height: layout.height_rows.min(area.height as usize) as u16,
};
self.render_screenshot_placeholder(path, placeholder_area, buf);
return;
}
@@ -1151,28 +1160,44 @@ impl BrowserSessionCell {
return;
}
let picker = self.ensure_picker();
let supports_partial_render = matches!(picker.protocol_type(), ProtocolType::Halfblocks);
let is_partially_visible = visible_top != shot_top || visible_bottom != shot_bottom;
if is_partially_visible && !supports_partial_render {
self.render_screenshot_placeholder(path, placeholder_area, buf);
return;
}
if !is_partially_visible {
let protocol_target = Rect::new(0, 0, screenshot_width, full_height);
if self.ensure_protocol(path, protocol_target, &picker).is_err() {
self.render_screenshot_placeholder(path, placeholder_area, buf);
return;
}
let dest_target = Rect::new(dest_x, dest_y, screenshot_width, full_height);
if let Some((_, _, protocol)) = self.cached_image_protocol.borrow_mut().as_mut() {
let image = Image::new(protocol);
image.render(dest_target, buf);
} else {
self.render_screenshot_placeholder(path, placeholder_area, buf);
}
return;
}
if !supports_partial_render {
self.render_screenshot_placeholder(path, placeholder_area, buf);
return;
}
let offscreen = match self.render_screenshot_buffer(path, screenshot_width, full_height) {
Ok(buffer) => buffer,
Err(_) => {
let placeholder_area = Rect {
x: area.x + accent_width + left_pad,
y: area.y,
width: screenshot_width,
height: layout.height_rows.min(area.height as usize) as u16,
};
self.render_screenshot_placeholder(path, placeholder_area, buf);
return;
}
};
let src_start_row = (visible_top - shot_top) as u16;
let rows_to_copy = (visible_bottom - visible_top) as u16;
if rows_to_copy == 0 {
return;
}
let dest_x = area.x + accent_width + left_pad;
let dest_y = area.y + (visible_top - viewport_top) as u16;
let area_bottom = area.y + area.height;
let area_right = area.x + area.width;
+42 -3
View File
@@ -22,7 +22,7 @@ use ::image::ImageReader;
use ::image::image_dimensions;
use ratatui::widgets::{Paragraph, Wrap};
use ratatui_image::{Image, Resize};
use ratatui_image::picker::Picker;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::FilterType;
use std::cell::RefCell;
use std::path::Path;
@@ -525,6 +525,7 @@ impl ImageOutputCell {
return;
}
let rows_to_copy = (visible_bottom - visible_top) as u16;
if rows_to_copy == 0 {
return;
@@ -532,9 +533,14 @@ impl ImageOutputCell {
let dest_x = area.x + accent_width + left_pad;
let dest_y = area.y + (visible_top - viewport_top) as u16;
let placeholder_area = Rect {
x: dest_x,
y: dest_y,
width: image_width,
height: rows_to_copy,
};
if !path.exists() {
let placeholder_area = Rect { x: dest_x, y: dest_y, width: image_width, height: rows_to_copy };
self.render_image_placeholder(path, placeholder_area, buf);
return;
}
@@ -544,10 +550,43 @@ impl ImageOutputCell {
return;
}
let picker = self.ensure_picker();
let supports_partial_render = matches!(picker.protocol_type(), ProtocolType::Halfblocks);
let is_partially_visible = visible_top != shot_top || visible_bottom != shot_bottom;
if is_partially_visible && !supports_partial_render {
// Graphical terminal protocols (kitty/sixel/iterm2) render with escape sequences that
// can't be safely truncated mid-image without risking cursor movement/overdraw.
// Prefer a placeholder over broken scroll/clipping.
self.render_image_placeholder(path, placeholder_area, buf);
return;
}
// For full-image rendering, draw directly into the final buffer so protocols that rely on
// escape sequences behave correctly.
if !is_partially_visible {
let protocol_target = Rect::new(0, 0, image_width, full_height);
if self.ensure_protocol(path, protocol_target, &picker).is_err() {
self.render_image_placeholder(path, placeholder_area, buf);
return;
}
let dest_target = Rect::new(dest_x, dest_y, image_width, full_height);
if let Some((_, _, protocol)) = self.cached_image_protocol.borrow_mut().as_mut() {
let image = Image::new(protocol);
image.render(dest_target, buf);
} else {
self.render_image_placeholder(path, placeholder_area, buf);
}
return;
}
if !supports_partial_render {
self.render_image_placeholder(path, placeholder_area, buf);
return;
}
let offscreen = match self.render_image_buffer(path, image_width, full_height) {
Ok(buffer) => buffer,
Err(_) => {
let placeholder_area = Rect { x: dest_x, y: dest_y, width: image_width, height: rows_to_copy };
self.render_image_placeholder(path, placeholder_area, buf);
return;
}