fix: write Kitty protocol directly to terminal fd, bypass Ink's stdout buffering
Browse filesKittyImage now uses fs.writeSync(process.stdout.fd) for all Kitty protocol
sequences (transmit, placement, deletion) instead of Ink's stdout.write,
preventing conflicts with Ink's screen refresh cycle inside Codev's TUI.
Also adds 'term.includes("ghostty")' to detectTerminalCaps() to recognize
Kitty support when running inside tmux with TERM=xterm-ghostty.
Co-Authored-By: Claude Big Pickle <noreply@anthropic.com>
src/ink-picture/components/image/Kitty.tsx
CHANGED
|
@@ -1,5 +1,8 @@
|
|
| 1 |
import { useStdout } from "src/ink";
|
|
|
|
| 2 |
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
|
|
|
| 3 |
import { useImage } from "../../hooks/useImage.js";
|
| 4 |
import { useMeasuredSize } from "../../hooks/useMeasuredSize.js";
|
| 5 |
import usePosition from "../../hooks/usePosition.js";
|
|
@@ -10,7 +13,6 @@ import {
|
|
| 10 |
makeKittyPlacement,
|
| 11 |
makeKittyTransmitChunks,
|
| 12 |
} from "../../renderers/kitty.js";
|
| 13 |
-
import { cursorForward, cursorUp } from "../../utils/ansiEscapes.js";
|
| 14 |
import generateKittyId from "../../utils/generateKittyId.js";
|
| 15 |
import ImageBox from "../ImageBox.js";
|
| 16 |
import type { ImageProps } from "./protocol.js";
|
|
@@ -24,8 +26,7 @@ function KittyImage(props: ImageProps) {
|
|
| 24 |
width,
|
| 25 |
height,
|
| 26 |
);
|
| 27 |
-
|
| 28 |
-
const componentPosition = usePosition(containerRef);
|
| 29 |
|
| 30 |
// Use external pixel dimensions if provided, otherwise compute from chars
|
| 31 |
const actualPixelWidth = pixelWidth ?? resolvedWidth * (terminalInfo?.cellWidth ?? 0);
|
|
@@ -40,51 +41,67 @@ function KittyImage(props: ImageProps) {
|
|
| 40 |
|
| 41 |
const [imageId, setImageId] = useState<number | undefined>(undefined);
|
| 42 |
const shouldCleanupRef = useRef(true);
|
| 43 |
-
const
|
|
|
|
|
|
|
| 44 |
|
|
|
|
|
|
|
| 45 |
useEffect(() => {
|
| 46 |
if (!imageData) return;
|
| 47 |
|
| 48 |
const id = generateKittyId();
|
| 49 |
const base64Data = imageData.data.toString("base64");
|
| 50 |
const chunks = makeKittyTransmitChunks(id, base64Data);
|
|
|
|
| 51 |
for (const chunk of chunks) {
|
| 52 |
-
|
| 53 |
}
|
|
|
|
| 54 |
setImageId(id);
|
| 55 |
-
}, [imageData
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
stdout.
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
});
|
| 89 |
|
| 90 |
const onExit = useCallback(() => {
|
|
@@ -107,12 +124,10 @@ function KittyImage(props: ImageProps) {
|
|
| 107 |
process.removeListener("SIGTERM", onSigInt);
|
| 108 |
if (!shouldCleanupRef.current) return;
|
| 109 |
if (!imageId) return;
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
// in order to remove the image data from the terminal
|
| 113 |
-
stdout.write(makeKittyDeletion(imageId));
|
| 114 |
};
|
| 115 |
-
}, [imageId, onExit, onSigInt
|
| 116 |
|
| 117 |
return (
|
| 118 |
<ImageBox
|
|
|
|
| 1 |
import { useStdout } from "src/ink";
|
| 2 |
+
import fs from "node:fs";
|
| 3 |
import React, { useCallback, useEffect, useRef, useState } from "react";
|
| 4 |
+
import { useOnRender } from "../../InkPictureProvider.js";
|
| 5 |
+
import { cursorForward } from "../../utils/ansiEscapes.js";
|
| 6 |
import { useImage } from "../../hooks/useImage.js";
|
| 7 |
import { useMeasuredSize } from "../../hooks/useMeasuredSize.js";
|
| 8 |
import usePosition from "../../hooks/usePosition.js";
|
|
|
|
| 13 |
makeKittyPlacement,
|
| 14 |
makeKittyTransmitChunks,
|
| 15 |
} from "../../renderers/kitty.js";
|
|
|
|
| 16 |
import generateKittyId from "../../utils/generateKittyId.js";
|
| 17 |
import ImageBox from "../ImageBox.js";
|
| 18 |
import type { ImageProps } from "./protocol.js";
|
|
|
|
| 26 |
width,
|
| 27 |
height,
|
| 28 |
);
|
| 29 |
+
const position = usePosition(containerRef);
|
|
|
|
| 30 |
|
| 31 |
// Use external pixel dimensions if provided, otherwise compute from chars
|
| 32 |
const actualPixelWidth = pixelWidth ?? resolvedWidth * (terminalInfo?.cellWidth ?? 0);
|
|
|
|
| 41 |
|
| 42 |
const [imageId, setImageId] = useState<number | undefined>(undefined);
|
| 43 |
const shouldCleanupRef = useRef(true);
|
| 44 |
+
const imageIdRef = useRef<number | undefined>(undefined);
|
| 45 |
+
const dimsRef = useRef({ w: resolvedWidth, h: resolvedHeight });
|
| 46 |
+
dimsRef.current = { w: resolvedWidth, h: resolvedHeight };
|
| 47 |
|
| 48 |
+
// One-time transmit: store image data in terminal GPU memory.
|
| 49 |
+
// Uses fs.writeSync to process.stdout.fd to bypass Ink's stdout buffering.
|
| 50 |
useEffect(() => {
|
| 51 |
if (!imageData) return;
|
| 52 |
|
| 53 |
const id = generateKittyId();
|
| 54 |
const base64Data = imageData.data.toString("base64");
|
| 55 |
const chunks = makeKittyTransmitChunks(id, base64Data);
|
| 56 |
+
const fd = process.stdout.fd;
|
| 57 |
for (const chunk of chunks) {
|
| 58 |
+
fs.writeSync(fd, chunk);
|
| 59 |
}
|
| 60 |
+
imageIdRef.current = id;
|
| 61 |
setImageId(id);
|
| 62 |
+
}, [imageData]);
|
| 63 |
+
|
| 64 |
+
// Place the image using position-aware cursor movement. This uses the
|
| 65 |
+
// same logic as ink-picture's writeImageToStdout / useDirectRenderer:
|
| 66 |
+
// it saves cursor → cursorUp(appHeight - row) → CR → cursorForward(col)
|
| 67 |
+
// → Kitty placement → restores cursor.
|
| 68 |
+
//
|
| 69 |
+
// The cursorUp formula accounts for Ink's trailing-newline quirk: when
|
| 70 |
+
// content fills the viewport (appHeight >= terminalHeight) Ink omits the
|
| 71 |
+
// extra newline, so we subtract 1 from the movement count.
|
| 72 |
+
useOnRender(() => {
|
| 73 |
+
const id = imageIdRef.current;
|
| 74 |
+
if (!id) return;
|
| 75 |
+
const pos = position;
|
| 76 |
+
if (!pos) return;
|
| 77 |
+
const { w, h } = dimsRef.current;
|
| 78 |
+
if (h <= 0) return;
|
| 79 |
+
|
| 80 |
+
// Skip if the image is not fully visible in the viewport
|
| 81 |
+
const visibility = defaultVisibility(pos, stdout.rows, stdout.columns);
|
| 82 |
+
if (visibility !== "full") return;
|
| 83 |
+
|
| 84 |
+
// Calculate cursor-up distance using the same logic as cursorUp() in
|
| 85 |
+
// ansiEscapes.ts, inlined here to avoid importing the helper.
|
| 86 |
+
const appHeight = pos.appHeight;
|
| 87 |
+
const terminalHeight = stdout.rows;
|
| 88 |
+
const cursorUpCount = appHeight - pos.row;
|
| 89 |
+
const movementCount =
|
| 90 |
+
appHeight >= terminalHeight ? cursorUpCount - 1 : cursorUpCount;
|
| 91 |
+
if (movementCount <= 0) return;
|
| 92 |
+
|
| 93 |
+
// Write directly to the terminal fd, bypassing Ink's stdout (which may
|
| 94 |
+
// buffer, intercept, or be overwritten by Ink's screen refresh cycle).
|
| 95 |
+
const fd = process.stdout.fd;
|
| 96 |
+
const buf = Buffer.concat([
|
| 97 |
+
Buffer.from(`\x1b7`), // save cursor (DECSC)
|
| 98 |
+
Buffer.from(`\x1b[${movementCount}A`), // cursor up to image row
|
| 99 |
+
Buffer.from(`\r`), // carriage return to col 0
|
| 100 |
+
Buffer.from(cursorForward(pos.col)), // forward to image column
|
| 101 |
+
Buffer.from(makeKittyPlacement(id, 1, w, h)),
|
| 102 |
+
Buffer.from(`\x1b8`), // restore cursor (DECRC)
|
| 103 |
+
]);
|
| 104 |
+
fs.writeSync(fd, buf);
|
| 105 |
});
|
| 106 |
|
| 107 |
const onExit = useCallback(() => {
|
|
|
|
| 124 |
process.removeListener("SIGTERM", onSigInt);
|
| 125 |
if (!shouldCleanupRef.current) return;
|
| 126 |
if (!imageId) return;
|
| 127 |
+
const fd = process.stdout.fd;
|
| 128 |
+
fs.writeSync(fd, makeKittyDeletion(imageId));
|
|
|
|
|
|
|
| 129 |
};
|
| 130 |
+
}, [imageId, onExit, onSigInt]);
|
| 131 |
|
| 132 |
return (
|
| 133 |
<ImageBox
|
src/tools/ImageShowTool/detectTerminal.ts
CHANGED
|
@@ -24,6 +24,7 @@ export function detectTerminalCaps(): Partial<TerminalInfo> {
|
|
| 24 |
termProgram === 'ghostty' ||
|
| 25 |
termProgram === 'kitty' ||
|
| 26 |
term.includes('kitty') ||
|
|
|
|
| 27 |
!!process.env.KITTY_WINDOW_ID
|
| 28 |
|
| 29 |
// --- Sixel graphics ---
|
|
|
|
| 24 |
termProgram === 'ghostty' ||
|
| 25 |
termProgram === 'kitty' ||
|
| 26 |
term.includes('kitty') ||
|
| 27 |
+
term.includes('ghostty') ||
|
| 28 |
!!process.env.KITTY_WINDOW_ID
|
| 29 |
|
| 30 |
// --- Sixel graphics ---
|