mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
## What changed Render local viewed images and generated image artifacts as bounded thumbnails in the rich fullscreen transcript using Kitty or Sixel graphics. Viewed images appear when their activity is expanded or the detailed presentation is active; generated images appear alongside their saved-path entry. Keep previews clipped to the viewport, retain graphics across unrelated redraws, and remove them when overlays open. Bound image decoding and cache memory, and preserve text-only entries for remote workspaces, unsupported terminals, missing files, and oversized images. Image rows do not enter searchable or selectable text. ## Testing Add layout and snapshot coverage for disclosure, viewport clipping, rewrapping, raw-mode fallback, deleted files, and oversized rasters. Add terminal coverage for retained Kitty graphics, repainting after a clear, local-file transport, and overlay cleanup. GitOrigin-RevId: fb2ec146331acfcfc82b2b283616671c9ee93757
170 lines
6.7 KiB
Rust
170 lines
6.7 KiB
Rust
//! Bounded image previews for the owned fullscreen transcript.
|
|
//!
|
|
//! Only decoded local image pixels reach the terminal encoders. Previews are never source text;
|
|
//! their reserved rows belong to layout. The terminal retains visible placements and any local
|
|
//! transport files until those placements change.
|
|
|
|
use std::io::Write;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Result;
|
|
use crossterm::cursor::MoveTo;
|
|
use crossterm::cursor::RestorePosition;
|
|
use crossterm::cursor::SavePosition;
|
|
use crossterm::queue;
|
|
use image::DynamicImage;
|
|
use image::ImageFormat;
|
|
use image::RgbaImage;
|
|
use ratatui::layout::Rect;
|
|
|
|
use crate::pets::ImageProtocol;
|
|
|
|
// Separate from the two image ids reserved for pets (0xC0DE and 0xC0DF).
|
|
pub(crate) const FIRST_IMAGE_ID: u32 = 0xC100;
|
|
|
|
pub(crate) struct ImagePlacement {
|
|
pub(crate) image: Arc<TranscriptImage>,
|
|
pub(crate) area: Rect,
|
|
pub(crate) first_row: usize,
|
|
pub(crate) file: Option<tempfile::NamedTempFile>,
|
|
}
|
|
|
|
impl PartialEq for ImagePlacement {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
Arc::ptr_eq(&self.image, &other.image)
|
|
&& self.area == other.area
|
|
&& self.first_row == other.first_row
|
|
}
|
|
}
|
|
|
|
pub(crate) struct TranscriptImage {
|
|
pub(crate) protocol: ImageProtocol,
|
|
pub(crate) columns: u16,
|
|
pub(crate) rows: u16,
|
|
pub(crate) pixels: RgbaImage,
|
|
pub(crate) cell_width: u32,
|
|
pub(crate) cell_height: u32,
|
|
}
|
|
|
|
pub(crate) fn terminal_cell_size() -> Option<(u32, u32)> {
|
|
crossterm::terminal::window_size()
|
|
.ok()
|
|
.filter(|size| size.columns > 0 && size.rows > 0 && size.width > 0 && size.height > 0)
|
|
.map(|size| {
|
|
(
|
|
u32::from(size.width / size.columns).max(1),
|
|
u32::from(size.height / size.rows).max(1),
|
|
)
|
|
})
|
|
}
|
|
|
|
impl TranscriptImage {
|
|
pub(crate) fn load(path: &Path, width: u16, protocol: ImageProtocol) -> Option<Self> {
|
|
let columns = width.saturating_sub(2).min(48);
|
|
let metadata = path.metadata().ok()?;
|
|
// Bound encoded input as well as the raster: metadata alone can dwarf a tiny image.
|
|
if columns == 0 || !metadata.is_file() || metadata.len() > 32 * 1024 * 1024 {
|
|
return None;
|
|
}
|
|
// Sixel paints physical pixels rather than sizing to a Kitty cell rectangle. Guessing
|
|
// the cell size could paint beyond the reserved rows and cover the composer.
|
|
let (cell_width, cell_height) = match protocol {
|
|
ImageProtocol::Sixel => terminal_cell_size()?,
|
|
// Kitty scales to cell coordinates itself. Use a stable, typical cell aspect ratio.
|
|
ImageProtocol::Kitty | ImageProtocol::KittyLocalFile => (8, 16),
|
|
};
|
|
let mut reader = image::ImageReader::open(path)
|
|
.ok()?
|
|
.with_guessed_format()
|
|
.ok()?;
|
|
// The reader checks the decoded size before allocating the raster. Keep normal
|
|
// screenshots usable, but never allocate hundreds of MB for an optional thumbnail.
|
|
let mut limits = image::Limits::default();
|
|
limits.max_alloc = Some(32 * 1024 * 1024);
|
|
reader.limits(limits);
|
|
let image = reader.decode().ok()?;
|
|
// The sprite encoder scans each active color per Sixel band. Bound both its CPU
|
|
// work and worst-case output even with large terminal cells or high-entropy images.
|
|
let (max_width, max_height) = match protocol {
|
|
ImageProtocol::Sixel => (160, 80),
|
|
ImageProtocol::Kitty | ImageProtocol::KittyLocalFile => (384, 160),
|
|
};
|
|
let pixels = image
|
|
.thumbnail(
|
|
(u32::from(columns) * cell_width)
|
|
.min(image.width())
|
|
.min(max_width),
|
|
(6 * cell_height).min(image.height()).min(max_height),
|
|
)
|
|
.to_rgba8();
|
|
Some(Self {
|
|
protocol,
|
|
columns: pixels.width().div_ceil(cell_width) as u16,
|
|
rows: pixels.height().div_ceil(cell_height) as u16,
|
|
pixels,
|
|
cell_width,
|
|
cell_height,
|
|
})
|
|
}
|
|
|
|
/// Draw only the visible vertical slice, never covering the prompt header or composer.
|
|
pub(crate) fn draw(
|
|
&self,
|
|
writer: &mut impl Write,
|
|
area: Rect,
|
|
first_row: usize,
|
|
id: u32,
|
|
) -> Result<Option<tempfile::NamedTempFile>> {
|
|
let (top, bottom) = match self.protocol {
|
|
ImageProtocol::Sixel => (
|
|
first_row as u32 * self.cell_height,
|
|
(first_row as u32 + u32::from(area.height)) * self.cell_height,
|
|
),
|
|
// Kitty scales the entire raster to r=rows; clip at those scaled boundaries.
|
|
ImageProtocol::Kitty | ImageProtocol::KittyLocalFile => (
|
|
first_row as u32 * self.pixels.height() / u32::from(self.rows),
|
|
(first_row as u32 + u32::from(area.height)) * self.pixels.height()
|
|
/ u32::from(self.rows),
|
|
),
|
|
};
|
|
let top = top.min(self.pixels.height());
|
|
let height = bottom.min(self.pixels.height()) - top;
|
|
if height == 0 {
|
|
return Ok(None);
|
|
}
|
|
let cropped =
|
|
image::imageops::crop_imm(&self.pixels, 0, top, self.pixels.width(), height).to_image();
|
|
let mut file = None;
|
|
// Encoding and temp-file failures must not abort the TUI. Only writes to the actual
|
|
// terminal below are fatal; the existing text entry remains usable without its preview.
|
|
let payload = (|| -> Result<_> {
|
|
Ok(match self.protocol {
|
|
ImageProtocol::Kitty | ImageProtocol::KittyLocalFile => {
|
|
let mut png = tempfile::Builder::new().suffix(".png").tempfile()?;
|
|
DynamicImage::ImageRgba8(cropped).write_to(&mut png, ImageFormat::Png)?;
|
|
png.flush()?;
|
|
let transmit = if self.protocol == ImageProtocol::KittyLocalFile {
|
|
crate::pets::kitty_transmit_png_file_with_id
|
|
} else {
|
|
crate::pets::kitty_transmit_png_with_id
|
|
};
|
|
let command = transmit(png.path(), self.columns, area.height, Some(id))?;
|
|
file = (self.protocol == ImageProtocol::KittyLocalFile).then_some(png);
|
|
command.into_bytes()
|
|
}
|
|
ImageProtocol::Sixel => {
|
|
crate::pets::encode_rgba(cropped.as_raw(), cropped.width(), cropped.height())?
|
|
}
|
|
})
|
|
})();
|
|
let Ok(payload) = payload else {
|
|
return Ok(None);
|
|
};
|
|
queue!(writer, SavePosition, MoveTo(area.x, area.y))?;
|
|
writer.write_all(&payload)?;
|
|
queue!(writer, RestorePosition)?;
|
|
Ok(file)
|
|
}
|
|
}
|