From 15302238f52fc756cf2720a0b68eb9bad72256b4 Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 14 Jul 2026 18:15:12 +0200 Subject: [PATCH] Redraw inline images (iTerm2) and clear them on unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iterm2 inline-image format (real iTerm2 and VS Code) doesn't integrate with Ink's frame lifecycle the way kitty/ghostty do (no graphics plane, no image IDs to double-buffer). - Real iTerm2/sixels: the image lives in the text grid, so the host app's normal Ink repaints (e.g. a live price ticker) erase it, and this component doesn't re-render on those. Re-stamp the cached image on a 120ms interval so it survives. Some flicker is inherent — without image IDs there's no atomic swap like kitty. Skipped for VS Code (its images persist and would stack). - On unmount (switching to a table/log view), paint the image's exact cells black so the replacement view draws over a clean region instead of the lingering chart. Kitty still deletes its buffered image IDs. Inline draws now use absolute positioning (like kitty) so the same coords drive the draw, the redraw interval, and the unmount clear. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SCHVP8VywU8i8JAN4xgLVs --- src/InkUPlot.tsx | 47 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/src/InkUPlot.tsx b/src/InkUPlot.tsx index ef2abe2..bd8f29b 100644 --- a/src/InkUPlot.tsx +++ b/src/InkUPlot.tsx @@ -126,20 +126,49 @@ export function InkUPlot({ const kittyIdRef = useRef(1); // Reserved box for out-of-band graphics — we read its on-screen position to place the image. const boxRef = useRef(null); + // Last inline image (iterm2/sixels) + where it was stamped, in 1-based cells. + // Drives the redraw interval (survive Ink repaints) and the unmount clear. + const inlineStampRef = useRef<{ ansi: string; row: number; col: number; rows: number; cols: number } | null>(null); + + // Real iTerm2/sixels erase inline images on Ink repaints; VS Code instead persists them + // (its images would stack if re-stamped). So only the former needs the redraw interval. + const inlineErasable = isRawFormat(format) && !isKitty(format) && process.env['TERM_PROGRAM'] !== 'vscode'; // On unmount (e.g. the host app switches to a table or log view), erase the image. - // Kitty/ghostty images live in a separate graphics plane that text repaints never - // clear — and the double-buffer below only deletes the previous image on the *next* - // render, which never comes once the chart is gone. Without this the last chart - // lingers on screen over whatever replaces it. Both buffer IDs are deleted to be safe. + // Kitty/ghostty images live in a separate graphics plane that text repaints never clear; + // the double-buffer only deletes the previous image on the *next* render, which never + // comes once the chart is gone — so delete both buffer IDs. Inline images (iterm2/vscode) + // have no delete command and VS Code persists them over later text, so paint the exact + // cells black; the host's next repaint then draws the replacement view over the black. useEffect(() => { return () => { if (isKitty(format)) { process.stdout.write(kittyDelete(1) + kittyDelete(2)); + } else if (isRawFormat(format)) { + const s = inlineStampRef.current; + if (s) { + const blank = `\x1b[40m${' '.repeat(s.cols)}\x1b[0m`; + let out = ''; + for (let r = 0; r < s.rows; r++) out += `\x1b[${s.row + r};${s.col}H${blank}`; + process.stdout.write(out); + } } }; }, [format]); + // Inline images (real iTerm2/sixels) sit in the text grid, so the host app's normal Ink + // repaints (e.g. a live price ticker) erase them — and this component doesn't re-render + // on those, so nothing redraws it. Re-stamp the cached image on a short interval so it + // survives. Some flicker is expected: unlike kitty there's no double-buffer to swap. + useEffect(() => { + if (!inlineErasable) return; + const id = setInterval(() => { + const s = inlineStampRef.current; + if (s) process.stdout.write(`\x1b[${s.row};${s.col}H${s.ansi}`); + }, 120); + return () => clearInterval(id); + }, [inlineErasable]); + // Clear stale output when dimensions change (not needed for kitty — bypasses Ink) useEffect(() => { if (!isRawFormat(format)) { setOutput(null); setError(null); } @@ -191,10 +220,12 @@ export function InkUPlot({ const row = (geom?.row ?? 0) + 1; // 1-based row within the (top-anchored) frame process.stdout.write(`\x1b[${row};${col}H${tagged}${kittyDelete(oldId)}`); } else if (isRawFormat(format)) { - // Inline images (iterm2/sixels) occupy character cells. Ink leaves the cursor at - // the frame bottom, so move up to the box's top row, then across to its column. - const up = geom ? Math.max(0, geom.frameHeight - geom.row) : chartRows; - process.stdout.write(`\x1b[${up}A\x1b[${col}G${ansi}`); + // Inline images (iterm2/sixels) occupy character cells. Position absolutely at the + // box's top-left (like kitty) so the same coords drive the redraw interval and the + // unmount clear. Cache the stamp for both. + const row = (geom?.row ?? 0) + 1; + process.stdout.write(`\x1b[${row};${col}H${ansi}`); + inlineStampRef.current = { ansi, row, col, rows: chartRows, cols: chartCols }; } else { setOutput(ansi); }