chenbhao Claude Big Pickle commited on
Commit
f0588e7
·
1 Parent(s): fbafa72

fix: write Kitty protocol directly to terminal fd, bypass Ink's stdout buffering

Browse files

KittyImage 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 wasPlacedRef = useRef(false);
 
 
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
- stdout.write(chunk);
53
  }
 
54
  setImageId(id);
55
- }, [imageData, stdout.write]);
56
-
57
- useEffect(() => {
58
- if (!imageId) return;
59
- if (!componentPosition) return;
60
-
61
- if (
62
- defaultVisibility(componentPosition, stdout.rows, stdout.columns) !==
63
- "full"
64
- ) {
65
- if (wasPlacedRef.current) {
66
- stdout.write(makeKittyDeletion(imageId, 1));
67
- wasPlacedRef.current = false;
68
- }
69
- return;
70
- }
71
-
72
- stdout.write("\x1b7");
73
- stdout.write(
74
- cursorUp(componentPosition.appHeight - componentPosition.row, {
75
- appHeight: componentPosition.appHeight,
76
- terminalHeight: stdout.rows,
77
- }),
78
- );
79
- stdout.write("\r");
80
- stdout.write(cursorForward(componentPosition.col));
81
-
82
- // 关键修改:传入 resolvedWidth 和 resolvedHeight(字符尺寸)
83
- stdout.write(makeKittyPlacement(imageId, 1, resolvedWidth, resolvedHeight));
84
-
85
- stdout.write("\x1b8");
86
-
87
- wasPlacedRef.current = true;
 
 
 
 
 
 
 
 
 
 
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
- // We always delete during unmount regardless of whether the image is placed
112
- // in order to remove the image data from the terminal
113
- stdout.write(makeKittyDeletion(imageId));
114
  };
115
- }, [imageId, onExit, onSigInt, stdout.write]);
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 ---