From 4e13f82f613884826910e3a5c852d740a0d4f40f Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 23:15:44 +0200 Subject: [PATCH 1/3] Fix screen damage when the terminal is resized with an inline-image chart Found reviewing glassnode-terminal (VS Code / iTerm2, Ink incrementalRendering): after a resize, rows outside the chart were wiped and stayed wiped. - When a resize settled, the render effect ran once more with the old committed size, stamping an old-size image recorded against the new terminal size; the next commit then blanked that stale area. Skip drawing while the committed size lags the requested one (the committed update re-runs it at the right size). - Blanking now skips when the terminal size changed since the stamp (the terminal reflowed and the host repainted), never writes past the right edge (a wrapped blank wipes the next row), and never targets rows below Ink's cursor line ("moving up" a negative amount is a no-op, so those rows all landed on the cursor's own line, e.g. the host's status bar). - The iTerm2 redraw interval skips while resizing or at stale coordinates. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PzJHgXssByMwBXH1PGbXCL --- src/InkUPlot.tsx | 50 +++++++++++++++++++++------- test/inline-cleanup.test.tsx | 63 ++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/src/InkUPlot.tsx b/src/InkUPlot.tsx index 2da89aa..fdb26a2 100644 --- a/src/InkUPlot.tsx +++ b/src/InkUPlot.tsx @@ -43,12 +43,17 @@ function boxScreenGeom( * at an absolute terminal row — keeps the image in place when the frame doesn't start at * the terminal's first row (non-fullscreen hosts, output below a shell prompt). */ -function linesUpFromCursor( +function cursorAnchor( geom: { row: number; frameHeight: number }, stdout: { isTTY?: boolean; rows?: number }, -): number { +): { up: number; minUp: number } { const fullscreen = Boolean(stdout.isTTY) && stdout.rows !== undefined && geom.frameHeight >= stdout.rows; - return geom.frameHeight - geom.row - (fullscreen ? 1 : 0); + return { + up: geom.frameHeight - geom.row - (fullscreen ? 1 : 0), + // Lowest on-screen frame line, counted up from the cursor (fullscreen: the cursor's own + // line; otherwise the line above it). Anything below is outside the frame. + minUp: fullscreen ? 0 : 1, + }; } // Kitty image ids are global to the terminal window. Give each chart its own pair (for the @@ -156,8 +161,13 @@ export function InkUPlot({ 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. - // `up`/`col` are relative to the cursor Ink leaves after a frame (see linesUpFromCursor). - const inlineStampRef = useRef<{ ansi: string; up: number; col: number; rows: number; cols: number } | null>(null); + // `up`/`col` are relative to the cursor Ink leaves after a frame (see cursorAnchor). + // termCols/termRows: the terminal size when stamped — after a resize the stamp's + // coordinates are stale (the terminal reflowed and the host repainted). + const inlineStampRef = useRef<{ + ansi: string; up: number; minUp: number; col: number; rows: number; cols: number; + termCols?: number; termRows?: number; + } | null>(null); // Inline images live in the terminal's text cells: printing anything into a cell (an Ink // repaint of that line) drops that part of the image. Real iTerm2/sixels get a redraw @@ -182,14 +192,23 @@ export function InkUPlot({ // and would wipe the replacement view; Ink's incremental rendering never repaints the // unchanged lines.) Cells Ink leaves unchanged were the chart's blank placeholder, so // default-attribute spaces are exactly right there. + // After a terminal resize the stamp's coordinates are stale: the terminal has reflowed and + // the host has repainted (Ink clears on width shrink; changed lines are rewritten), so + // blanking there would wipe unrelated content and wrap past the new right edge. Skip it. useLayoutEffect(() => { return () => { const s = inlineStampRef.current; if (!s || isKitty(format)) return; inlineStampRef.current = null; - const blank = `\x1b[0m${' '.repeat(s.cols)}`; + if (s.termCols !== inkStdout.columns || s.termRows !== inkStdout.rows) return; + // Never write past the right edge: a wrapped blank wipes the start of the next row. + const cols = inkStdout.columns ? Math.min(s.cols, inkStdout.columns - s.col + 1) : s.cols; + if (cols <= 0) return; + const blank = `\x1b[0m${' '.repeat(cols)}`; let out = ''; - for (let r = 0; r < s.rows; r++) out += cursorTo(s.up - r, s.col) + blank + '\x1b8\x1b7'; + // Only rows inside the frame: "moving up" a negative amount is a no-op, so rows below + // the cursor would all land on the cursor's own line (e.g. the host's status bar). + for (let r = 0; r < s.rows && s.up - r >= s.minUp; r++) out += cursorTo(s.up - r, s.col) + blank + '\x1b8\x1b7'; process.stdout.write(withSavedCursor(out)); }; }, [chartCols, chartRows, format]); @@ -202,7 +221,9 @@ export function InkUPlot({ if (!inlineErasable) return; const id = setInterval(() => { const s = inlineStampRef.current; - if (s) process.stdout.write(withSavedCursor(cursorTo(s.up, s.col) + s.ansi)); + // Not mid-resize, and not at coordinates from a different terminal size. + if (!s || resizingRef.current || s.termCols !== inkStdout.columns || s.termRows !== inkStdout.rows) return; + process.stdout.write(withSavedCursor(cursorTo(s.up, s.col) + s.ansi)); }, 120); return () => clearInterval(id); }, [inlineErasable]); @@ -214,6 +235,10 @@ export function InkUPlot({ useEffect(() => { if (canvasWidth < 8 || canvasHeight < 16) return; + // The committed size lags the requested one (a resize just settled, or width/height + // props changed): drawing now would place an image of the old size, recorded against + // the new terminal size. The committed update re-runs this effect at the right size. + if (committed.cols !== liveCols || committed.rows !== liveRows) return; let cancelled = false; // Serialize through renderLock — renderToImageData is not reentrant @@ -247,7 +272,7 @@ export function InkUPlot({ // terminal's top-left, which overflows any layout where the chart isn't the only pane. const geom = boxScreenGeom(boxRef.current) ?? { col: 0, row: 0, frameHeight: 0 }; const col = geom.col + 1; // 1-based terminal column of the box's left edge - const up = linesUpFromCursor(geom, inkStdout); + const { up, minUp } = cursorAnchor(geom, inkStdout); if (isKitty(format)) { // Kitty images live in a graphics plane, so text repaints don't erase them. @@ -261,7 +286,10 @@ export function InkUPlot({ // Inline images (iterm2/sixels) occupy character cells. Place at the box's top-left // and cache the stamp for the redraw interval and the blank-on-change cleanup. process.stdout.write(withSavedCursor(cursorTo(up, col) + ansi)); - inlineStampRef.current = { ansi, up, col, rows: chartRows, cols: chartCols }; + inlineStampRef.current = { + ansi, up, minUp, col, rows: chartRows, cols: chartCols, + termCols: inkStdout.columns, termRows: inkStdout.rows, + }; } else { setOutput(ansi); } @@ -276,7 +304,7 @@ export function InkUPlot({ }); return () => { cancelled = true; }; - }, [opts, data, canvasWidth, canvasHeight, chartCols, chartRows, format, color, showAxes, resizeTick]); + }, [opts, data, canvasWidth, canvasHeight, chartCols, chartRows, format, color, showAxes, resizeTick, committed.cols, committed.rows, liveCols, liveRows]); if (error) { return Error rendering chart: {error}; diff --git a/test/inline-cleanup.test.tsx b/test/inline-cleanup.test.tsx index 9029f90..1c16711 100644 --- a/test/inline-cleanup.test.tsx +++ b/test/inline-cleanup.test.tsx @@ -81,3 +81,66 @@ describe('inline image cleanup (iterm2)', () => { instance.unmount(); }); }); + +describe('inline image cleanup after a terminal resize (iterm2)', () => { + it('does not blank at stale coordinates once the terminal size has changed', async () => { + let instance!: ReturnType; + const writes = spyStdout(() => instance?.frames.length ?? 0); + instance = render(); + await until(() => writes.some(isStamp)); + const before = writes.length; + + // The terminal shrinks: it reflows, and Ink repaints. The old image position no longer + // means anything, so blanking it would wipe whatever now sits there (and wrap past the + // new right edge). + Object.defineProperty(instance.stdout, 'columns', { get: () => 60, configurable: true }); + instance.rerender(); + await until(() => writes.slice(before).some(isStamp)); + + expect(writes.slice(before).filter(isBlank)).toHaveLength(0); + instance.unmount(); + }); + + it('never blanks past the right edge of the terminal', async () => { + let instance!: ReturnType; + const writes = spyStdout(() => instance?.frames.length ?? 0); + // Chart starts at column 71 of a 100-column terminal and claims 50 columns. + instance = render( + + + , + ); + await until(() => writes.some(isStamp)); + + instance.rerender(gone); + await until(() => writes.some(isBlank)); + const blank = writes.find(isBlank)!; + const widest = Math.max(...(blank.text.match(/ +/g) ?? ['']).map((s) => s.length)); + expect(widest).toBeLessThanOrEqual(100 - 71 + 1); + instance.unmount(); + }); +}); + +describe('inline image cleanup stays inside the frame (iterm2)', () => { + it('never blanks rows below the cursor Ink leaves after the frame', async () => { + let instance!: ReturnType; + const writes = spyStdout(() => instance?.frames.length ?? 0); + // A 10-row chart clipped into a 4-row frame: the stamp's rows run past the frame. Rows + // below the cursor don't exist on screen; "moving up" a negative amount is a no-op, so + // blanking them would repeatedly overwrite the cursor's own line (e.g. a status bar). + instance = render( + + + , + ); + await until(() => writes.some(isStamp)); + instance.rerender(gone); + await until(() => writes.some(isBlank)); + const blank = writes.find(isBlank)!; + const moves = [...blank.text.matchAll(/\x1b7(?:\x1b\[(\d+)A)?\x1b\[\d+G/g)].map((m) => Number(m[1] ?? 0)); + // Non-fullscreen: cursor is on the line after the 4-line frame, so at most 4 rows (up 4..1). + expect(moves.length).toBeLessThanOrEqual(4); + expect(Math.min(...moves)).toBeGreaterThanOrEqual(1); + instance.unmount(); + }); +}); From 8395f815fb240ccf706b5db6a1ec95692ab7c699 Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 23:47:13 +0200 Subject: [PATCH 2/3] Place images after Ink flushes their frame; narrow the resize blank skip From review of this PR: - Ink throttles frame writes and the PNG is often ready first, so the image was placed relative to the cursor of the *previous* frame (misplaced after height changes in non-fullscreen layouts; the removed stale draw used to hide this race). Out-of-band draws now await useApp().waitUntilRenderFlush() (Ink >= 7; one throttle interval on older Ink) before reading the anchor. - Skipping the blank on any size change left old image cells behind on width grow / non-fullscreen height change (Ink doesn't clear, unchanged lines are not rewritten). Skip only when the width shrank (Ink cleared and the terminal reflowed) or a fullscreen frame's row count changed (its anchor moved). - Blank rows above the screen's top line are skipped (cursor-up clamps, so they all rewrote row 1). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PzJHgXssByMwBXH1PGbXCL --- src/InkUPlot.tsx | 30 ++++++++++++++++---- test/inline-cleanup.test.tsx | 54 ++++++++++++++++++++++++++++++++++++ test/render-flush.test.tsx | 54 ++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 test/render-flush.test.tsx diff --git a/src/InkUPlot.tsx b/src/InkUPlot.tsx index fdb26a2..03dea0f 100644 --- a/src/InkUPlot.tsx +++ b/src/InkUPlot.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; -import { Box, Text, useStdout, type DOMElement } from 'ink'; +import { Box, Text, useApp, useStdout, type DOMElement } from 'ink'; import { renderToImageData, renderToPNG } from './renderer.js'; import { pixelsToTerminal } from './chafa.js'; import { computeScales, buildYLabels, buildXLabelLine } from './axes.js'; @@ -155,6 +155,8 @@ export function InkUPlot({ const [output, setOutput] = useState(null); const [error, setError] = useState(null); const { stdout: inkStdout } = useStdout(); + // Ink >= 7 resolves this once the frame being rendered has been written to the terminal. + const app = useApp() as { waitUntilRenderFlush?: () => Promise }; const [kittyIds] = useState(allocateKittyIds); const kittyIdRef = useRef(0); // index into kittyIds of the id the next image uses // Reserved box for out-of-band graphics — we read its on-screen position to place the image. @@ -192,15 +194,18 @@ export function InkUPlot({ // and would wipe the replacement view; Ink's incremental rendering never repaints the // unchanged lines.) Cells Ink leaves unchanged were the chart's blank placeholder, so // default-attribute spaces are exactly right there. - // After a terminal resize the stamp's coordinates are stale: the terminal has reflowed and - // the host has repainted (Ink clears on width shrink; changed lines are rewritten), so - // blanking there would wipe unrelated content and wrap past the new right edge. Skip it. + // The blank runs before Ink writes its next frame, so the stamp's cursor-relative + // coordinates stay valid across most resizes — except when Ink cleared the screen (width + // shrink: it also reflowed) or the anchor moved (a fullscreen frame's row count changed). + // Blanking then would wipe unrelated content, so skip it; Ink repaints those lines anyway. useLayoutEffect(() => { return () => { const s = inlineStampRef.current; if (!s || isKitty(format)) return; inlineStampRef.current = null; - if (s.termCols !== inkStdout.columns || s.termRows !== inkStdout.rows) return; + const widthShrank = (inkStdout.columns ?? 0) < (s.termCols ?? 0); + const fullscreenAnchorMoved = s.minUp === 0 && s.termRows !== inkStdout.rows; + if (widthShrank || fullscreenAnchorMoved) return; // Never write past the right edge: a wrapped blank wipes the start of the next row. const cols = inkStdout.columns ? Math.min(s.cols, inkStdout.columns - s.col + 1) : s.cols; if (cols <= 0) return; @@ -208,7 +213,12 @@ export function InkUPlot({ let out = ''; // Only rows inside the frame: "moving up" a negative amount is a no-op, so rows below // the cursor would all land on the cursor's own line (e.g. the host's status bar). - for (let r = 0; r < s.rows && s.up - r >= s.minUp; r++) out += cursorTo(s.up - r, s.col) + blank + '\x1b8\x1b7'; + // …and none above the screen's top line (a frame taller than the screen): cursor-up + // stops there, so they would all rewrite that line. + const maxUp = (inkStdout.rows ?? Infinity) - 1; + for (let r = 0; r < s.rows && s.up - r >= s.minUp; r++) { + if (s.up - r <= maxUp) out += cursorTo(s.up - r, s.col) + blank + '\x1b8\x1b7'; + } process.stdout.write(withSavedCursor(out)); }; }, [chartCols, chartRows, format]); @@ -265,6 +275,14 @@ export function InkUPlot({ }); } if (cancelled) return; + // Out-of-band graphics are placed relative to the cursor Ink leaves after the frame + // this layout belongs to. Ink throttles frame writes and the image is often ready + // first, so wait until that frame is flushed (Ink >= 7); on older Ink, wait out one + // throttle interval (maxFps 30). + if (isRawFormat(format)) { + await (app.waitUntilRenderFlush?.() ?? new Promise((r) => setTimeout(r, 50))); + if (cancelled) return; + } // Re-check: a resize may have started while this frame was rendering. if (resizingRef.current && isRawFormat(format)) return; diff --git a/test/inline-cleanup.test.tsx b/test/inline-cleanup.test.tsx index 1c16711..698ddb4 100644 --- a/test/inline-cleanup.test.tsx +++ b/test/inline-cleanup.test.tsx @@ -144,3 +144,57 @@ describe('inline image cleanup stays inside the frame (iterm2)', () => { instance.unmount(); }); }); + +describe('inline image cleanup: when a resize still needs the blank (iterm2)', () => { + // The blank runs before Ink writes its next frame, so the stamp's cursor-relative + // coordinates stay valid unless Ink cleared (width shrink) or a fullscreen frame's anchor + // moved (row count changed). Otherwise, skipping the blank leaves old image cells behind. + const setSize = (instance: ReturnType, size: { columns?: number; rows?: number; isTTY?: boolean }) => { + for (const [k, v] of Object.entries(size)) { + Object.defineProperty(instance.stdout, k, { get: () => v, configurable: true }); + } + }; + + it('still blanks after the terminal grows wider', async () => { + let instance!: ReturnType; + const writes = spyStdout(() => instance?.frames.length ?? 0); + instance = render(); + await until(() => writes.some(isStamp)); + const before = writes.length; + setSize(instance, { columns: 140 }); + instance.rerender(); + await until(() => writes.slice(before).some(isStamp)); + expect(writes.slice(before).filter(isBlank).length).toBeGreaterThan(0); + instance.unmount(); + }); + + it('still blanks after a height change when the frame is not fullscreen', async () => { + let instance!: ReturnType; + const writes = spyStdout(() => instance?.frames.length ?? 0); + instance = render(); + await until(() => writes.some(isStamp)); + const before = writes.length; + setSize(instance, { rows: 50 }); + instance.rerender(); + await until(() => writes.slice(before).some(isStamp)); + expect(writes.slice(before).filter(isBlank).length).toBeGreaterThan(0); + instance.unmount(); + }); + + it('skips the blank when a fullscreen frame\'s row count changed (its cursor anchor moved)', async () => { + let instance!: ReturnType; + const writes = spyStdout(() => instance?.frames.length ?? 0); + instance = render(); + // The 8-row frame fills an 8-row TTY: fullscreen. + setSize(instance, { isTTY: true, rows: 8 }); + instance.rerender(); + await until(() => writes.some(isStamp)); + const before = writes.length; + setSize(instance, { rows: 6 }); + instance.rerender(); + await until(() => writes.slice(before).some(isStamp)); + expect(writes.slice(before).filter(isBlank)).toHaveLength(0); + instance.unmount(); + }); +}); + diff --git a/test/render-flush.test.tsx b/test/render-flush.test.tsx new file mode 100644 index 0000000..50fc01d --- /dev/null +++ b/test/render-flush.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render } from 'ink-testing-library'; + +// Ink throttles frame writes, and the chart image is often ready first. The image is placed +// relative to the cursor Ink leaves after the frame it was laid out for, so it must not be +// written until Ink has flushed that frame (useApp().waitUntilRenderFlush, Ink >= 7). +let releaseFlush: () => void = () => {}; +const flushCalls = vi.hoisted(() => ({ count: 0 })); +vi.mock('ink', async (importOriginal) => { + const ink = await importOriginal(); + return { + ...ink, + useApp: () => ({ + ...ink.useApp(), + waitUntilRenderFlush: () => { + flushCalls.count++; + return new Promise((resolve) => { releaseFlush = resolve; }); + }, + }), + }; +}); + +const { InkUPlot } = await import('../src/index.js'); + +const opts = { series: [{}, { stroke: '#ffffff', width: 2 }] }; +const data: [number[], number[]] = [[1, 2, 3], [10, 20, 15]]; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('inline image placement waits for Ink to flush the frame', () => { + it('does not write the image until waitUntilRenderFlush resolves', { timeout: 20000 }, async () => { + const writes: string[] = []; + vi.spyOn(process.stdout, 'write').mockImplementation(((c: string | Uint8Array) => { + writes.push(String(c)); + return true; + }) as typeof process.stdout.write); + const instance = render(); + + const start = Date.now(); + while (flushCalls.count === 0 && Date.now() - start < 10000) await new Promise((r) => setTimeout(r, 20)); + expect(flushCalls.count).toBeGreaterThan(0); + await new Promise((r) => setTimeout(r, 100)); + expect(writes.some((w) => w.includes('\x1b]1337;File='))).toBe(false); + + releaseFlush(); + const t = Date.now(); + while (!writes.some((w) => w.includes('\x1b]1337;File=')) && Date.now() - t < 5000) await new Promise((r) => setTimeout(r, 20)); + expect(writes.some((w) => w.includes('\x1b]1337;File='))).toBe(true); + instance.unmount(); + }); +}); From 025597b9c363adc6c5a475596305b9831a773b6b Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 23:52:57 +0200 Subject: [PATCH 3/3] Test: no blanking above the screen's top line; README: Ink >= 7 and incremental rendering Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PzJHgXssByMwBXH1PGbXCL --- README.md | 2 ++ test/inline-cleanup.test.tsx | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/README.md b/README.md index c5f6896..5e3e7bd 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,8 @@ Using ink-uplot in your project? Open a PR to add it here. - Node.js >= 18 - System libraries for [node-canvas](https://github.com/Automattic/node-canvas#compiling) (Cairo, Pango) +- Ink >= 7 for exact placement of native graphics (kitty, iTerm2/VS Code, sixel): images are written after Ink flushes the frame they belong to (`waitUntilRenderFlush`). Older Ink falls back to waiting one frame interval. +- For iTerm2-protocol images (including VS Code), render with Ink's `incrementalRendering: true` so unrelated updates don't repaint — and erase — the chart's lines. ## License diff --git a/test/inline-cleanup.test.tsx b/test/inline-cleanup.test.tsx index 698ddb4..ae32def 100644 --- a/test/inline-cleanup.test.tsx +++ b/test/inline-cleanup.test.tsx @@ -198,3 +198,21 @@ describe('inline image cleanup: when a resize still needs the blank (iterm2)', ( }); }); + +describe('inline image cleanup stays on screen (iterm2)', () => { + it('never blanks rows above the screen\'s top line (frame taller than the screen)', async () => { + let instance!: ReturnType; + const writes = spyStdout(() => instance?.frames.length ?? 0); + // A 10-row chart in a 5-row terminal: its top rows are above the screen. Cursor-up stops + // at the top line, so blanking them would rewrite that line over and over. + instance = render(); + Object.defineProperty(instance.stdout, 'rows', { get: () => 5, configurable: true }); + await until(() => writes.some(isStamp)); + instance.rerender(gone); + await until(() => writes.some(isBlank)); + const blank = writes.find(isBlank)!; + const ups = [...blank.text.matchAll(/\x1b7(?:\x1b\[(\d+)A)?\x1b\[\d+G/g)].map((m) => Number(m[1] ?? 0)); + expect(Math.max(...ups)).toBeLessThanOrEqual(5 - 1); + instance.unmount(); + }); +});