fix(tui): keep kitty image redraws inside TUI

Fixes #4208.
This commit is contained in:
Armin Ronacher
2026-05-07 10:34:37 +02:00
parent 50993d743d
commit b8712457d2
5 changed files with 273 additions and 19 deletions

View File

@@ -8,9 +8,26 @@ import * as path from "node:path";
import { performance } from "node:perf_hooks";
import { isKeyRelease, matchesKey } from "./keys.js";
import type { Terminal } from "./terminal.js";
import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
const KITTY_SEQUENCE_PATTERN = /\x1b_G([^;\x1b]*);/g;
function extractKittyImageIds(line: string): number[] {
const ids: number[] = [];
for (const match of line.matchAll(KITTY_SEQUENCE_PATTERN)) {
for (const param of match[1].split(",")) {
const [key, value] = param.split("=", 2);
if (key !== "i" || value === undefined) continue;
const id = Number(value);
if (Number.isInteger(id) && id > 0 && id <= 0xffffffff && !ids.includes(id)) {
ids.push(id);
}
}
}
return ids;
}
/**
* Component interface - all components must implement this
*/
@@ -217,6 +234,7 @@ export class Container implements Component {
export class TUI extends Container {
public terminal: Terminal;
private previousLines: string[] = [];
private previousKittyImageIds = new Set<number>();
private previousWidth = 0;
private previousHeight = 0;
private focusedComponent: Component | null = null;
@@ -806,6 +824,48 @@ export class TUI extends Container {
return lines;
}
private collectKittyImageIds(lines: string[]): Set<number> {
const ids = new Set<number>();
for (const line of lines) {
for (const id of extractKittyImageIds(line)) {
ids.add(id);
}
}
return ids;
}
private deleteKittyImages(ids: Iterable<number>): string {
let buffer = "";
for (const id of ids) {
buffer += deleteKittyImage(id);
}
return buffer;
}
private expandLastChangedForKittyImages(firstChanged: number, lastChanged: number): number {
let expandedLastChanged = lastChanged;
for (let i = firstChanged; i < this.previousLines.length; i++) {
if (extractKittyImageIds(this.previousLines[i]).length > 0) {
expandedLastChanged = Math.max(expandedLastChanged, i);
}
}
return expandedLastChanged;
}
private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string {
if (firstChanged < 0 || lastChanged < firstChanged) return "";
const ids = new Set<number>();
const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
for (let i = firstChanged; i <= maxLine; i++) {
for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {
ids.add(id);
}
}
return this.deleteKittyImages(ids);
}
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
private compositeLineAt(
baseLine: string,
@@ -918,7 +978,10 @@ export class TUI extends Container {
const fullRender = (clear: boolean): void => {
this.fullRedrawCount += 1;
let buffer = "\x1b[?2026h"; // Begin synchronized output
if (clear) buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
if (clear) {
buffer += this.deleteKittyImages(this.previousKittyImageIds);
buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
}
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer += "\r\n";
buffer += newLines[i];
@@ -937,6 +1000,7 @@ export class TUI extends Container {
this.previousViewportTop = Math.max(0, bufferLength - height);
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousWidth = width;
this.previousHeight = height;
};
@@ -1003,6 +1067,9 @@ export class TUI extends Container {
}
lastChanged = newLines.length - 1;
}
if (firstChanged !== -1) {
lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
}
const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
// No changes - but still need to update hardware cursor position if it moved
@@ -1017,6 +1084,7 @@ export class TUI extends Container {
if (firstChanged >= newLines.length) {
if (this.previousLines.length > newLines.length) {
let buffer = "\x1b[?2026h";
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
// Move to end of new content (clamp to 0 for empty content)
const targetRow = Math.max(0, newLines.length - 1);
if (targetRow < prevViewportTop) {
@@ -1052,6 +1120,7 @@ export class TUI extends Container {
}
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousWidth = width;
this.previousHeight = height;
this.previousViewportTop = prevViewportTop;
@@ -1069,6 +1138,7 @@ export class TUI extends Container {
// Render from first changed line to end
// Build buffer with all updates wrapped in synchronized output
let buffer = "\x1b[?2026h"; // Begin synchronized output
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
const prevViewportBottom = prevViewportTop + height - 1;
const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
if (moveTargetRow > prevViewportBottom) {
@@ -1199,6 +1269,7 @@ export class TUI extends Container {
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousWidth = width;
this.previousHeight = height;
}