@@ -14,7 +14,8 @@ import { iconFor } from "../lib/icons";
1414import {
1515 type DecodedImage ,
1616 kittyDelete ,
17- kittyImage ,
17+ kittyPut ,
18+ kittyTransmit ,
1819 loadImage ,
1920 pickProtocol ,
2021} from "../lib/image" ;
@@ -110,6 +111,19 @@ function cellPixels(renderer: ReturnType<typeof useRenderer>): {
110111
111112type Cells = { cols : number ; rows : number } ;
112113
114+ // OpenTUI writes every frame through its own serialized output path
115+ // (renderer.writeOut → native lib.writeOut). Writing kitty escapes via
116+ // process.stdout instead races that path at the byte level and the sequence
117+ // can arrive interleaved/corrupt — the image then intermittently fails to
118+ // show. Route our escapes through the same path so they can't interleave.
119+ // `writeOut` is internal (not in the public typings) but is the method the
120+ // renderer uses for all of its own output.
121+ type RawWriter = { writeOut ( chunk : string ) : void } ;
122+
123+ function writeRaw ( renderer : ReturnType < typeof useRenderer > , seq : string ) : void {
124+ ( renderer as unknown as RawWriter ) . writeOut ( seq ) ;
125+ }
126+
113127export function ImagePreview ( { node } : { node : FileNode } ) {
114128 const renderer = useRenderer ( ) ;
115129 const containerRef = useRef < BoxRenderable > ( null ) ;
@@ -164,34 +178,36 @@ export function ImagePreview({ node }: { node: FileNode }) {
164178 } ;
165179 } , [ node . path , cells , protocol , renderer ] ) ;
166180
167- // Kitty path: transmit + display the image over the pane, re-placing it only
168- // when its on-screen geometry changes . Cleanup deletes the image so it
169- // doesn't linger when switching files or unmounting.
181+ // Kitty path: transmit the pixels once, display the placement immediately, and
182+ // keep the placement in sync as the pane moves . Cleanup deletes the image so
183+ // it doesn't linger when switching files or unmounting.
170184 useEffect ( ( ) => {
171185 if ( protocol !== "kitty" || ! image ) return ;
172- let currentId : number | null = null ;
173- let lastKey = "" ;
186+ const { id , sequence } = kittyTransmit ( image ) ;
187+ writeRaw ( renderer , sequence ) ;
174188
175189 const place = ( ) => {
176190 const box = containerRef . current ;
177191 if ( ! box || box . width <= 0 || box . height <= 0 ) return ;
178- const key = `${ box . x } ,${ box . y } ,${ box . width } ,${ box . height } ` ;
179- if ( key === lastKey ) return ;
180- lastKey = key ;
181-
182- let out = "" ;
183- if ( currentId !== null ) out += kittyDelete ( currentId ) ;
184- const placed = kittyImage ( image ) ;
185- currentId = placed . id ;
186- // Save cursor, jump to the pane's top-left (1-based), emit, restore.
187- out += `\x1b7\x1b[${ box . y + 1 } ;${ box . x + 1 } H${ placed . sequence } \x1b8` ;
188- process . stdout . write ( out ) ;
192+ // Save cursor, jump to the pane's top-left (1-based), place, restore.
193+ writeRaw (
194+ renderer ,
195+ `\x1b7\x1b[${ box . y + 1 } ;${ box . x + 1 } H${ kittyPut ( id ) } \x1b8` ,
196+ ) ;
189197 } ;
190198
199+ // Display it now — layout has already settled (the pane was measured before
200+ // we got here), so the geometry is valid. We must NOT rely on a FRAME event
201+ // to show it: OpenTUI renders on demand and stays idle once the pane
202+ // settles, so a frame often never arrives and the image would never appear.
203+ // Redrawn cells don't erase kitty images, so one placement keeps it visible;
204+ // the FRAME listener only re-places when the pane moves (scroll/resize),
205+ // which do trigger renders.
206+ place ( ) ;
191207 renderer . on ( CliRenderEvents . FRAME , place ) ;
192208 return ( ) => {
193209 renderer . off ( CliRenderEvents . FRAME , place ) ;
194- if ( currentId !== null ) process . stdout . write ( kittyDelete ( currentId ) ) ;
210+ writeRaw ( renderer , kittyDelete ( id ) ) ;
195211 } ;
196212 } , [ protocol , image , renderer ] ) ;
197213
0 commit comments