Kitty Graphics Protocol in OCaml
terminal graphics ocaml

sync

+441 -324
+1
dune-project
··· 8 8 "A standalone library for rendering images in terminals that support the Kitty graphics protocol. Supports image transmission, display, animation, Unicode placeholders, and terminal capability detection.") 9 9 (depends 10 10 (ocaml (>= 4.14.0)) 11 + cmdliner 11 12 base64))
-97
example/anim_test.ml
··· 1 - (* Minimal animation test - shows exact bytes sent *) 2 - 3 - module K = Kgp 4 - 5 - let solid_color_rgba ~width ~height ~r ~g ~b ~a = 6 - let pixels = Bytes.create (width * height * 4) in 7 - for i = 0 to (width * height) - 1 do 8 - let idx = i * 4 in 9 - Bytes.set pixels idx (Char.chr r); 10 - Bytes.set pixels (idx + 1) (Char.chr g); 11 - Bytes.set pixels (idx + 2) (Char.chr b); 12 - Bytes.set pixels (idx + 3) (Char.chr a) 13 - done; 14 - Bytes.to_string pixels 15 - 16 - let send cmd ~data = 17 - print_string (K.Command.to_string cmd ~data); 18 - flush stdout 19 - 20 - let () = 21 - let width, height = 40, 40 in (* Smaller for faster testing *) 22 - let image_id = 500 in 23 - 24 - (* Clear any existing image *) 25 - send (K.Command.delete ~free:true ~quiet:`Errors_only `All_visible) ~data:""; 26 - 27 - (* Step 1: Transmit base frame (red) *) 28 - let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in 29 - send 30 - (K.Command.transmit 31 - ~image_id 32 - ~format:`Rgba32 33 - ~width ~height 34 - ~quiet:`Errors_only 35 - ()) 36 - ~data:red_frame; 37 - 38 - (* Step 2: Add frame (blue) *) 39 - let blue_frame = solid_color_rgba ~width ~height ~r:0 ~g:0 ~b:255 ~a:255 in 40 - send 41 - (K.Command.frame 42 - ~image_id 43 - ~format:`Rgba32 44 - ~width ~height 45 - ~frame:(K.Frame.make ~gap_ms:500 ~composition:`Overwrite ()) 46 - ~quiet:`Errors_only 47 - ()) 48 - ~data:blue_frame; 49 - 50 - (* Step 3: Add frame (green) *) 51 - let green_frame = solid_color_rgba ~width ~height ~r:0 ~g:255 ~b:0 ~a:255 in 52 - send 53 - (K.Command.frame 54 - ~image_id 55 - ~format:`Rgba32 56 - ~width ~height 57 - ~frame:(K.Frame.make ~gap_ms:500 ~composition:`Overwrite ()) 58 - ~quiet:`Errors_only 59 - ()) 60 - ~data:green_frame; 61 - 62 - (* Step 4: Create placement *) 63 - send 64 - (K.Command.display 65 - ~image_id 66 - ~placement:(K.Placement.make 67 - ~placement_id:1 68 - ~cursor:`Static 69 - ()) 70 - ~quiet:`Errors_only 71 - ()) 72 - ~data:""; 73 - 74 - (* Step 5: Set root frame gap - IMPORTANT: root frame has no gap by default *) 75 - send 76 - (K.Command.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:500)) 77 - ~data:""; 78 - 79 - (* Step 6: Start animation *) 80 - send 81 - (K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run)) 82 - ~data:""; 83 - 84 - print_endline ""; 85 - print_endline "Animation should be playing (red -> blue -> green)."; 86 - print_endline "Press Enter to stop..."; 87 - flush stdout; 88 - let _ = read_line () in 89 - 90 - (* Stop animation *) 91 - send 92 - (K.Command.animate ~image_id (K.Animation.set_state `Stop)) 93 - ~data:""; 94 - 95 - (* Clean up *) 96 - send (K.Command.delete ~free:true ~quiet:`Errors_only `All_visible) ~data:""; 97 - print_endline "Done."
example/anim_test.mli

This is a binary file and will not be displayed.

example/camel.png

This is a binary file and will not be displayed.

-94
example/debug_anim.ml
··· 1 - (* Debug: Output animation escape sequences for comparison with Go *) 2 - 3 - module K = Kgp 4 - 5 - let solid_color_rgba ~width ~height ~r ~g ~b ~a = 6 - let pixels = Bytes.create (width * height * 4) in 7 - for i = 0 to (width * height) - 1 do 8 - let idx = i * 4 in 9 - Bytes.set pixels idx (Char.chr r); 10 - Bytes.set pixels (idx + 1) (Char.chr g); 11 - Bytes.set pixels (idx + 2) (Char.chr b); 12 - Bytes.set pixels (idx + 3) (Char.chr a) 13 - done; 14 - Bytes.to_string pixels 15 - 16 - let send cmd ~data = 17 - let s = K.Command.to_string cmd ~data in 18 - (* Print escaped version for debugging *) 19 - String.iter (fun c -> 20 - let code = Char.code c in 21 - if code = 27 then print_string "\\x1b" 22 - else if code < 32 || code > 126 then Printf.printf "\\x%02x" code 23 - else print_char c 24 - ) s; 25 - print_newline () 26 - 27 - let () = 28 - let width, height = 80, 80 in 29 - let image_id = 300 in 30 - 31 - print_endline "=== OCaml Animation Debug ===\n"; 32 - 33 - (* Step 1: Transmit base frame *) 34 - print_endline "1. Transmit base frame (a=t):"; 35 - let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in 36 - send 37 - (K.Command.transmit 38 - ~image_id 39 - ~format:`Rgba32 40 - ~width ~height 41 - ~quiet:`Errors_only 42 - ()) 43 - ~data:red_frame; 44 - print_newline (); 45 - 46 - (* Step 2: Add frame *) 47 - print_endline "2. Add frame (a=f):"; 48 - let orange_frame = solid_color_rgba ~width ~height ~r:255 ~g:165 ~b:0 ~a:255 in 49 - send 50 - (K.Command.frame 51 - ~image_id 52 - ~format:`Rgba32 53 - ~width ~height 54 - ~frame:(K.Frame.make ~gap_ms:100 ~composition:`Overwrite ()) 55 - ~quiet:`Errors_only 56 - ()) 57 - ~data:orange_frame; 58 - print_newline (); 59 - 60 - (* Step 3: Put/display placement *) 61 - print_endline "3. Create placement (a=p):"; 62 - send 63 - (K.Command.display 64 - ~image_id 65 - ~placement:(K.Placement.make 66 - ~placement_id:1 67 - ~cell_x_offset:0 68 - ~cell_y_offset:0 69 - ~cursor:`Static 70 - ()) 71 - ~quiet:`Errors_only 72 - ()) 73 - ~data:""; 74 - print_newline (); 75 - 76 - (* Step 4: Set root frame gap *) 77 - print_endline "4. Set root frame gap (a=a,r=1,z=100):"; 78 - send 79 - (K.Command.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:100)) 80 - ~data:""; 81 - print_newline (); 82 - 83 - (* Step 5: Animate *) 84 - print_endline "5. Start animation (a=a,s=3,v=1):"; 85 - send 86 - (K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run)) 87 - ~data:""; 88 - print_newline (); 89 - 90 - (* Step 6: Stop animation *) 91 - print_endline "6. Stop animation:"; 92 - send 93 - (K.Command.animate ~image_id (K.Animation.set_state `Stop)) 94 - ~data:""
example/debug_anim.mli

This is a binary file and will not be displayed.

+3 -11
example/dune
··· 2 2 (name example) 3 3 (libraries kgp unix)) 4 4 5 - (executable 6 - (name debug_anim) 7 - (libraries kgp)) 8 - 9 - (executable 10 - (name test_output) 11 - (libraries kgp)) 12 - 13 - (executable 14 - (name anim_test) 15 - (libraries kgp)) 5 + (alias 6 + (name example) 7 + (deps example.exe camel.png)) 16 8 17 9 (executable 18 10 (name tiny_anim)
+34 -34
example/example.ml
··· 50 50 s 51 51 52 52 let send cmd ~data = 53 - print_string (K.Command.to_string cmd ~data); 53 + print_string (K.to_string cmd ~data); 54 54 flush stdout 55 55 56 56 let wait_for_enter () = ··· 78 78 (* Demo 1: Basic formats - PNG *) 79 79 clear_screen (); 80 80 print_endline "Demo 1: Image Formats - PNG format"; 81 - (* Read sf.png and display a small portion as demo *) 81 + (* Read camel.png and display a small portion as demo *) 82 82 (try 83 - let png_data = read_file "sf.png" in 83 + let png_data = read_file "camel.png" in 84 84 send 85 - (K.Command.transmit_and_display 85 + (K.transmit_and_display 86 86 ~image_id:1 87 87 ~format:`Png 88 88 ~quiet:`Errors_only 89 89 ~placement:(K.Placement.make ~columns:15 ~rows:8 ()) 90 90 ()) 91 91 ~data:png_data; 92 - print_endline "sf.png displayed using PNG format" 92 + print_endline "camel.png displayed using PNG format" 93 93 with _ -> 94 94 (* Fallback: red square as RGBA *) 95 95 let red_data = solid_color_rgba ~width:100 ~height:100 ~r:255 ~g:0 ~b:0 ~a:255 in 96 96 send 97 - (K.Command.transmit_and_display 97 + (K.transmit_and_display 98 98 ~image_id:1 99 99 ~format:`Rgba32 100 100 ~width:100 ~height:100 101 101 ~quiet:`Errors_only 102 102 ()) 103 103 ~data:red_data; 104 - print_endline "Red square displayed (sf.png not found)"); 104 + print_endline "Red square displayed (camel.png not found)"); 105 105 print_newline (); 106 106 wait_for_enter (); 107 107 ··· 110 110 print_endline "Demo 2: Image Formats - RGBA format (32-bit)"; 111 111 let blue_data = solid_color_rgba ~width:100 ~height:100 ~r:0 ~g:0 ~b:255 ~a:255 in 112 112 send 113 - (K.Command.transmit_and_display 113 + (K.transmit_and_display 114 114 ~image_id:2 115 115 ~format:`Rgba32 116 116 ~width:100 ~height:100 ··· 126 126 print_endline "Demo 3: Image Formats - RGB format (24-bit)"; 127 127 let green_data = solid_color_rgb ~width:100 ~height:100 ~r:0 ~g:255 ~b:0 in 128 128 send 129 - (K.Command.transmit_and_display 129 + (K.transmit_and_display 130 130 ~image_id:3 131 131 ~format:`Rgb24 132 132 ~width:100 ~height:100 ··· 142 142 print_endline "Demo 4: Large Image (compression requires zlib library)"; 143 143 let orange_data = solid_color_rgba ~width:200 ~height:200 ~r:255 ~g:165 ~b:0 ~a:255 in 144 144 send 145 - (K.Command.transmit_and_display 145 + (K.transmit_and_display 146 146 ~image_id:4 147 147 ~format:`Rgba32 148 148 ~width:200 ~height:200 ··· 155 155 156 156 (* Demo 5: Load and display external PNG file *) 157 157 clear_screen (); 158 - print_endline "Demo 5: Loading external PNG file (sf.png)"; 158 + print_endline "Demo 5: Loading external PNG file (camel.png)"; 159 159 (try 160 - let png_data = read_file "sf.png" in 160 + let png_data = read_file "camel.png" in 161 161 send 162 - (K.Command.transmit_and_display 162 + (K.transmit_and_display 163 163 ~image_id:10 164 164 ~format:`Png 165 165 ~quiet:`Errors_only 166 166 ()) 167 167 ~data:png_data; 168 - print_endline "sf.png loaded and displayed" 168 + print_endline "camel.png loaded and displayed" 169 169 with Sys_error msg -> 170 - Printf.printf "sf.png not found: %s\n" msg); 170 + Printf.printf "camel.png not found: %s\n" msg); 171 171 print_newline (); 172 172 wait_for_enter (); 173 173 ··· 176 176 print_endline "Demo 6: Cropping and Scaling - Display part of an image"; 177 177 let gradient = gradient_rgba ~width:200 ~height:200 in 178 178 send 179 - (K.Command.transmit_and_display 179 + (K.transmit_and_display 180 180 ~image_id:20 181 181 ~format:`Rgba32 182 182 ~width:200 ~height:200 ··· 198 198 let cyan_data = solid_color_rgba ~width:80 ~height:80 ~r:0 ~g:255 ~b:255 ~a:255 in 199 199 (* Transmit once with an ID *) 200 200 send 201 - (K.Command.transmit 201 + (K.transmit 202 202 ~image_id:100 203 203 ~format:`Rgba32 204 204 ~width:80 ~height:80 ··· 207 207 ~data:cyan_data; 208 208 (* Create first placement *) 209 209 send 210 - (K.Command.display 210 + (K.display 211 211 ~image_id:100 212 212 ~placement:(K.Placement.make ~columns:10 ~rows:5 ()) 213 213 ~quiet:`Errors_only ··· 215 215 ~data:""; 216 216 (* Create second placement *) 217 217 send 218 - (K.Command.display 218 + (K.display 219 219 ~image_id:100 220 220 ~placement:(K.Placement.make ~columns:5 ~rows:3 ()) 221 221 ~quiet:`Errors_only ··· 234 234 let grad_small = gradient_rgba ~width:100 ~height:100 in 235 235 (* Transmit once *) 236 236 send 237 - (K.Command.transmit 237 + (K.transmit 238 238 ~image_id:160 239 239 ~format:`Rgba32 240 240 ~width:100 ~height:100 ··· 243 243 ~data:grad_small; 244 244 (* Place same image three times at different sizes *) 245 245 send 246 - (K.Command.display 246 + (K.display 247 247 ~image_id:160 248 248 ~placement:(K.Placement.make ~columns:5 ~rows:5 ()) 249 249 ~quiet:`Errors_only ··· 251 251 ~data:""; 252 252 print_string " "; 253 253 send 254 - (K.Command.display 254 + (K.display 255 255 ~image_id:160 256 256 ~placement:(K.Placement.make ~columns:8 ~rows:8 ()) 257 257 ~quiet:`Errors_only ··· 259 259 ~data:""; 260 260 print_string " "; 261 261 send 262 - (K.Command.display 262 + (K.display 263 263 ~image_id:160 264 264 ~placement:(K.Placement.make ~columns:12 ~rows:12 ()) 265 265 ~quiet:`Errors_only ··· 276 276 print_endline "Demo 9: Z-Index Layering - Images above/below text"; 277 277 let bg_data = solid_color_rgba ~width:200 ~height:100 ~r:255 ~g:165 ~b:0 ~a:128 in 278 278 send 279 - (K.Command.transmit_and_display 279 + (K.transmit_and_display 280 280 ~image_id:200 281 281 ~format:`Rgba32 282 282 ~width:200 ~height:100 ··· 309 309 (* Create base frame (red) - transmit without displaying *) 310 310 let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in 311 311 send 312 - (K.Command.transmit 312 + (K.transmit 313 313 ~image_id 314 314 ~format:`Rgba32 315 315 ~width ~height ··· 320 320 (* Add frames with composition replace *) 321 321 let orange_frame = solid_color_rgba ~width ~height ~r:255 ~g:165 ~b:0 ~a:255 in 322 322 send 323 - (K.Command.frame 323 + (K.frame 324 324 ~image_id 325 325 ~format:`Rgba32 326 326 ~width ~height ··· 331 331 332 332 let yellow_frame = solid_color_rgba ~width ~height ~r:255 ~g:255 ~b:0 ~a:255 in 333 333 send 334 - (K.Command.frame 334 + (K.frame 335 335 ~image_id 336 336 ~format:`Rgba32 337 337 ~width ~height ··· 342 342 343 343 let green_frame = solid_color_rgba ~width ~height ~r:0 ~g:255 ~b:0 ~a:255 in 344 344 send 345 - (K.Command.frame 345 + (K.frame 346 346 ~image_id 347 347 ~format:`Rgba32 348 348 ~width ~height ··· 353 353 354 354 (* Create placement and start animation *) 355 355 send 356 - (K.Command.display 356 + (K.display 357 357 ~image_id 358 358 ~placement:(K.Placement.make 359 359 ~placement_id:1 ··· 367 367 368 368 (* Set root frame gap - root frame has no gap by default per Kitty protocol *) 369 369 send 370 - (K.Command.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:100)) 370 + (K.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:100)) 371 371 ~data:""; 372 372 373 373 (* Start animation with infinite looping *) 374 374 send 375 - (K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run)) 375 + (K.animate ~image_id (K.Animation.set_state ~loops:1 `Run)) 376 376 ~data:""; 377 377 378 378 print_newline (); ··· 385 385 386 386 (* Delete the current placement *) 387 387 send 388 - (K.Command.delete ~quiet:`Errors_only (`By_id (image_id, Some 1))) 388 + (K.delete ~quiet:`Errors_only (`By_id (image_id, Some 1))) 389 389 ~data:""; 390 390 391 391 (* Create new placement at next position *) 392 392 send 393 - (K.Command.display 393 + (K.display 394 394 ~image_id 395 395 ~placement:(K.Placement.make 396 396 ~placement_id:1 ··· 405 405 406 406 (* Stop the animation *) 407 407 send 408 - (K.Command.animate ~image_id (K.Animation.set_state `Stop)) 408 + (K.animate ~image_id (K.Animation.set_state `Stop)) 409 409 ~data:""; 410 410 411 411 print_endline "Animation stopped.";
-59
example/test_output.ml
··· 1 - (* Simple test to show exact escape sequences without data *) 2 - 3 - module K = Kgp 4 - 5 - let print_escaped s = 6 - String.iter (fun c -> 7 - let code = Char.code c in 8 - if code = 27 then print_string "\\x1b" 9 - else if code < 32 || code > 126 then Printf.printf "\\x%02x" code 10 - else print_char c 11 - ) s; 12 - print_newline () 13 - 14 - let () = 15 - let image_id = 300 in 16 - let width, height = 80, 80 in 17 - 18 - print_endline "=== Animation Escape Sequences (no data) ===\n"; 19 - 20 - (* 1. Transmit base frame (no data for testing) *) 21 - print_endline "1. Transmit (a=t):"; 22 - let cmd1 = K.Command.transmit 23 - ~image_id ~format:`Rgba32 ~width ~height ~quiet:`Errors_only () in 24 - print_escaped (K.Command.to_string cmd1 ~data:""); 25 - 26 - (* 2. Frame command *) 27 - print_endline "\n2. Frame (a=f):"; 28 - let cmd2 = K.Command.frame 29 - ~image_id ~format:`Rgba32 ~width ~height 30 - ~frame:(K.Frame.make ~gap_ms:100 ~composition:`Overwrite ()) 31 - ~quiet:`Errors_only () in 32 - print_escaped (K.Command.to_string cmd2 ~data:""); 33 - 34 - (* 3. Put/display command *) 35 - print_endline "\n3. Display/Put (a=p):"; 36 - let cmd3 = K.Command.display 37 - ~image_id 38 - ~placement:(K.Placement.make 39 - ~placement_id:1 40 - ~cell_x_offset:0 41 - ~cell_y_offset:0 42 - ~cursor:`Static ()) 43 - ~quiet:`Errors_only () in 44 - print_escaped (K.Command.to_string cmd3 ~data:""); 45 - 46 - (* 4. Set root frame gap - IMPORTANT for animation! *) 47 - print_endline "\n4. Set root frame gap (a=a, r=1, z=100):"; 48 - let cmd4 = K.Command.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:100) in 49 - print_escaped (K.Command.to_string cmd4 ~data:""); 50 - 51 - (* 5. Animate - start *) 52 - print_endline "\n5. Animate start (a=a, s=3, v=1):"; 53 - let cmd5 = K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run) in 54 - print_escaped (K.Command.to_string cmd5 ~data:""); 55 - 56 - (* 6. Animate - stop *) 57 - print_endline "\n6. Animate stop (a=a, s=1):"; 58 - let cmd6 = K.Command.animate ~image_id (K.Animation.set_state `Stop) in 59 - print_escaped (K.Command.to_string cmd6 ~data:"")
example/test_output.mli

This is a binary file and will not be displayed.

+10 -10
example/tiny_anim.ml
··· 15 15 Bytes.to_string pixels 16 16 17 17 let send cmd ~data = 18 - print_string (K.Command.to_string cmd ~data); 18 + print_string (K.to_string cmd ~data); 19 19 flush stdout 20 20 21 21 let () = ··· 24 24 let image_id = 999 in 25 25 26 26 (* Clear any existing images *) 27 - send (K.Command.delete ~free:true ~quiet:`Errors_only `All_visible) ~data:""; 27 + send (K.delete ~free:true ~quiet:`Errors_only `All_visible) ~data:""; 28 28 29 29 (* Step 1: Transmit base frame (red) - matching Go's sequence *) 30 30 let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in 31 31 send 32 - (K.Command.transmit 32 + (K.transmit 33 33 ~image_id 34 34 ~format:`Rgba32 35 35 ~width ~height ··· 40 40 (* Step 2: Add frame (orange) with 100ms gap - like Go *) 41 41 let orange_frame = solid_color_rgba ~width ~height ~r:255 ~g:165 ~b:0 ~a:255 in 42 42 send 43 - (K.Command.frame 43 + (K.frame 44 44 ~image_id 45 45 ~format:`Rgba32 46 46 ~width ~height ··· 52 52 (* Step 3: Add frame (yellow) *) 53 53 let yellow_frame = solid_color_rgba ~width ~height ~r:255 ~g:255 ~b:0 ~a:255 in 54 54 send 55 - (K.Command.frame 55 + (K.frame 56 56 ~image_id 57 57 ~format:`Rgba32 58 58 ~width ~height ··· 64 64 (* Step 4: Add frame (green) *) 65 65 let green_frame = solid_color_rgba ~width ~height ~r:0 ~g:255 ~b:0 ~a:255 in 66 66 send 67 - (K.Command.frame 67 + (K.frame 68 68 ~image_id 69 69 ~format:`Rgba32 70 70 ~width ~height ··· 75 75 76 76 (* Step 5: Create placement - exactly like Go *) 77 77 send 78 - (K.Command.display 78 + (K.display 79 79 ~image_id 80 80 ~placement:(K.Placement.make 81 81 ~placement_id:1 ··· 89 89 90 90 (* Step 6: Start animation - exactly like Go (NO root frame gap) *) 91 91 send 92 - (K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run)) 92 + (K.animate ~image_id (K.Animation.set_state ~loops:1 `Run)) 93 93 ~data:""; 94 94 95 95 print_endline ""; ··· 100 100 101 101 (* Stop animation *) 102 102 send 103 - (K.Command.animate ~image_id (K.Animation.set_state `Stop)) 103 + (K.animate ~image_id (K.Animation.set_state `Stop)) 104 104 ~data:""; 105 105 106 106 (* Clean up *) 107 - send (K.Command.delete ~free:true ~quiet:`Errors_only `All_visible) ~data:""; 107 + send (K.delete ~free:true ~quiet:`Errors_only `All_visible) ~data:""; 108 108 print_endline "Done."
example/tiny_anim.mli

This is a binary file and will not be displayed.

+4
lib-cli/dune
··· 1 + (library 2 + (name kgp_cli) 3 + (public_name kgp.cli) 4 + (libraries kgp cmdliner))
+23
lib-cli/kgp_cli.ml
··· 1 + (* Cmdliner Support for Kitty Graphics Protocol *) 2 + 3 + open Cmdliner 4 + 5 + let graphics_docs = "GRAPHICS OPTIONS" 6 + 7 + let graphics_term = 8 + let doc = "Force graphics output enabled, ignoring terminal detection." in 9 + let enable = Arg.info ["g"; "graphics"] ~doc ~docs:graphics_docs in 10 + let doc = "Disable graphics output, use text placeholders instead." in 11 + let disable = Arg.info ["no-graphics"] ~doc ~docs:graphics_docs in 12 + let doc = "Force tmux passthrough mode for graphics." in 13 + let tmux = Arg.info ["tmux"] ~doc ~docs:graphics_docs in 14 + let choose enable disable tmux : Kgp.Terminal.graphics_mode = 15 + if enable then `Enabled 16 + else if disable then `Disabled 17 + else if tmux then `Tmux 18 + else `Auto 19 + in 20 + Term.(const choose 21 + $ Arg.(value & flag enable) 22 + $ Arg.(value & flag disable) 23 + $ Arg.(value & flag tmux))
+46
lib-cli/kgp_cli.mli
··· 1 + (** Cmdliner Support for Kitty Graphics Protocol 2 + 3 + This module provides Cmdliner terms for configuring graphics output mode 4 + in CLI applications. It allows users to override auto-detection with 5 + command-line flags. 6 + 7 + {2 Usage} 8 + 9 + Add the graphics term to your command: 10 + 11 + {[ 12 + let cmd = 13 + let graphics = Kgp_cli.graphics_term in 14 + let my_args = ... in 15 + Cmd.v info Term.(const my_run $ graphics $ my_args) 16 + ]} 17 + 18 + Then use the resolved mode in your application: 19 + 20 + {[ 21 + let my_run graphics_mode args = 22 + if Kgp.Terminal.supports_graphics graphics_mode then 23 + (* render with graphics *) 24 + else 25 + (* use text fallback *) 26 + ]} *) 27 + 28 + (** {1 Terms} *) 29 + 30 + val graphics_term : Kgp.Terminal.graphics_mode Cmdliner.Term.t 31 + (** Cmdliner term for graphics mode selection. 32 + 33 + Provides the following command-line options: 34 + 35 + - [--graphics] / [-g]: Force graphics output enabled 36 + - [--no-graphics]: Force graphics output disabled (use placeholders) 37 + - [--tmux]: Force tmux passthrough mode 38 + - (default): Auto-detect based on terminal environment 39 + 40 + The term evaluates to a {!Kgp.Terminal.graphics_mode} value which can 41 + be passed to {!Kgp.Terminal.supports_graphics} or {!Kgp.Terminal.resolve_mode}. *) 42 + 43 + val graphics_docs : string 44 + (** Section name for graphics options in help output ("GRAPHICS OPTIONS"). 45 + 46 + Use this when grouping graphics options in a separate help section. *)
+1 -1
lib/dune
··· 1 1 (library 2 2 (name kgp) 3 3 (public_name kgp) 4 - (libraries base64)) 4 + (libraries base64 unix))
+4 -3
lib/kgp.ml
··· 1 - (* Kitty Terminal Graphics Protocol - Main Module *) 2 - 3 1 (* Type modules *) 4 2 module Format = Kgp_format 5 3 module Transmission = Kgp_transmission ··· 29 27 let compose = Kgp_command.compose 30 28 let write = Kgp_command.write 31 29 let to_string = Kgp_command.to_string 30 + let write_tmux = Kgp_command.write_tmux 31 + let to_string_tmux = Kgp_command.to_string_tmux 32 32 33 33 (* Core modules *) 34 - module Command = Kgp_command 35 34 module Response = Kgp_response 36 35 37 36 (* Utility modules *) 38 37 module Unicode_placeholder = Kgp_unicode 39 38 module Detect = Kgp_detect 39 + module Tmux = Kgp_tmux 40 + module Terminal = Kgp_terminal
+23 -5
lib/kgp.mli
··· 19 19 20 20 All graphics commands use the Application Programming Command (APC) format: 21 21 22 - {v <ESC>_G<control data>;<payload><ESC>\ v} 22 + {v <ESC>_G<control data>;<payload><ESC> v} 23 23 24 24 Where: 25 25 - [ESC _G] is the APC start sequence (bytes 0x1B 0x5F 0x47) ··· 336 336 Convenience wrapper around {!write} that returns the serialized 337 337 command as a string. *) 338 338 339 + val write_tmux : Buffer.t -> command -> data:string -> unit 340 + (** Write the command to a buffer with tmux passthrough support. 341 + 342 + If running inside tmux (detected via [TMUX] environment variable), 343 + wraps the graphics command in a DCS passthrough sequence so it 344 + reaches the underlying terminal. Otherwise, behaves like {!write}. 345 + 346 + Requires tmux 3.3+ with [allow-passthrough] enabled. *) 347 + 348 + val to_string_tmux : command -> data:string -> string 349 + (** Convert command to a string with tmux passthrough support. 350 + 351 + Convenience wrapper around {!write_tmux}. If running inside tmux, 352 + wraps the output for passthrough. Otherwise, behaves like {!to_string}. *) 353 + 339 354 (** {1 Response} *) 340 355 341 356 module Response = Kgp_response ··· 345 360 module Unicode_placeholder = Kgp_unicode 346 361 module Detect = Kgp_detect 347 362 348 - (** {1 Low-level Access} *) 363 + module Tmux = Kgp_tmux 364 + (** Tmux passthrough support. Provides functions to detect if running 365 + inside tmux and to wrap escape sequences for passthrough. *) 366 + 367 + module Terminal = Kgp_terminal 368 + (** Terminal environment detection. Provides functions to detect terminal 369 + capabilities, pager mode, and resolve graphics output mode. *) 349 370 350 - module Command = Kgp_command 351 - (** Low-level command module. The command functions are also available 352 - at the top level of this module for convenience. *)
+13
lib/kgp_command.ml
··· 305 305 let buf = Buffer.create 1024 in 306 306 write buf cmd ~data; 307 307 Buffer.contents buf 308 + 309 + let write_tmux buf cmd ~data = 310 + if Kgp_tmux.is_active () then begin 311 + let inner_buf = Buffer.create 1024 in 312 + write inner_buf cmd ~data; 313 + Kgp_tmux.write_wrapped buf (Buffer.contents inner_buf) 314 + end else 315 + write buf cmd ~data 316 + 317 + let to_string_tmux cmd ~data = 318 + let buf = Buffer.create 1024 in 319 + write_tmux buf cmd ~data; 320 + Buffer.contents buf
+13
lib/kgp_command.mli
··· 108 108 109 109 val to_string : t -> data:string -> string 110 110 (** Convert command to a string. *) 111 + 112 + val write_tmux : Buffer.t -> t -> data:string -> unit 113 + (** Write the command to a buffer with tmux passthrough wrapping. 114 + 115 + If running inside tmux (detected via [TMUX] environment variable), 116 + wraps the graphics command in a DCS passthrough sequence. Otherwise, 117 + behaves like {!write}. *) 118 + 119 + val to_string_tmux : t -> data:string -> string 120 + (** Convert command to a string with tmux passthrough wrapping. 121 + 122 + If running inside tmux, wraps the output for passthrough. 123 + Otherwise, behaves like {!to_string}. *)
+59
lib/kgp_terminal.ml
··· 1 + (* Terminal Environment Detection *) 2 + 3 + type graphics_mode = [ `Auto | `Enabled | `Disabled | `Tmux ] 4 + 5 + let is_kitty () = 6 + Option.is_some (Sys.getenv_opt "KITTY_WINDOW_ID") || 7 + (match Sys.getenv_opt "TERM" with 8 + | Some term -> String.lowercase_ascii term = "xterm-kitty" 9 + | None -> false) || 10 + (match Sys.getenv_opt "TERM_PROGRAM" with 11 + | Some prog -> String.lowercase_ascii prog = "kitty" 12 + | None -> false) 13 + 14 + let is_wezterm () = 15 + Option.is_some (Sys.getenv_opt "WEZTERM_PANE") || 16 + (match Sys.getenv_opt "TERM_PROGRAM" with 17 + | Some prog -> String.lowercase_ascii prog = "wezterm" 18 + | None -> false) 19 + 20 + let is_ghostty () = 21 + Option.is_some (Sys.getenv_opt "GHOSTTY_RESOURCES_DIR") || 22 + (match Sys.getenv_opt "TERM_PROGRAM" with 23 + | Some prog -> String.lowercase_ascii prog = "ghostty" 24 + | None -> false) 25 + 26 + let is_graphics_terminal () = 27 + is_kitty () || is_wezterm () || is_ghostty () 28 + 29 + let is_tmux () = Kgp_tmux.is_active () 30 + 31 + let is_interactive () = 32 + Unix.isatty Unix.stdout 33 + 34 + let is_pager () = 35 + (* Not interactive = likely piped to pager *) 36 + not (is_interactive ()) || 37 + (* PAGER set and not in a known graphics terminal *) 38 + (Option.is_some (Sys.getenv_opt "PAGER") && not (is_graphics_terminal ())) 39 + 40 + let resolve_mode = function 41 + | `Disabled -> `Placeholder 42 + | `Enabled -> `Graphics 43 + | `Tmux -> `Tmux 44 + | `Auto -> 45 + if is_pager () || not (is_interactive ()) then 46 + `Placeholder 47 + else if is_tmux () then 48 + (* Inside tmux - use passthrough if underlying terminal supports graphics *) 49 + if is_graphics_terminal () then `Tmux 50 + else `Placeholder 51 + else if is_graphics_terminal () then 52 + `Graphics 53 + else 54 + `Placeholder 55 + 56 + let supports_graphics mode = 57 + match resolve_mode mode with 58 + | `Graphics | `Tmux -> true 59 + | `Placeholder -> false
+80
lib/kgp_terminal.mli
··· 1 + (** Terminal Environment Detection 2 + 3 + Detect terminal capabilities and environment for graphics protocol support. 4 + 5 + {2 Supported Terminals} 6 + 7 + The following terminals support the Kitty Graphics Protocol: 8 + - Kitty (the original implementation) 9 + - WezTerm 10 + - Ghostty 11 + - Konsole (partial support) 12 + 13 + {2 Environment Detection} 14 + 15 + Detection is based on environment variables: 16 + - [KITTY_WINDOW_ID] - set by Kitty 17 + - [WEZTERM_PANE] - set by WezTerm 18 + - [GHOSTTY_RESOURCES_DIR] - set by Ghostty 19 + - [TERM_PROGRAM] - may contain terminal name 20 + - [TERM] - may contain "kitty" 21 + - [TMUX] - set when inside tmux 22 + - [PAGER] / output to non-tty - indicates pager mode *) 23 + 24 + (** {1 Graphics Mode} *) 25 + 26 + type graphics_mode = [ `Auto | `Enabled | `Disabled | `Tmux ] 27 + (** Graphics output mode. 28 + 29 + - [`Auto] - Auto-detect based on environment 30 + - [`Enabled] - Force graphics enabled 31 + - [`Disabled] - Force graphics disabled (use placeholders) 32 + - [`Tmux] - Force tmux passthrough mode *) 33 + 34 + (** {1 Detection} *) 35 + 36 + val is_kitty : unit -> bool 37 + (** Detect if running in Kitty terminal. *) 38 + 39 + val is_wezterm : unit -> bool 40 + (** Detect if running in WezTerm terminal. *) 41 + 42 + val is_ghostty : unit -> bool 43 + (** Detect if running in Ghostty terminal. *) 44 + 45 + val is_graphics_terminal : unit -> bool 46 + (** Detect if running in any terminal that supports the graphics protocol. *) 47 + 48 + val is_tmux : unit -> bool 49 + (** Detect if running inside tmux. *) 50 + 51 + val is_pager : unit -> bool 52 + (** Detect if output is likely going to a pager. 53 + 54 + Returns [true] if: 55 + - stdout is not a tty, or 56 + - [PAGER] environment variable is set and we're not in a known 57 + graphics-capable terminal *) 58 + 59 + val is_interactive : unit -> bool 60 + (** Detect if running interactively (stdout is a tty). *) 61 + 62 + (** {1 Mode Resolution} *) 63 + 64 + val resolve_mode : graphics_mode -> [ `Graphics | `Tmux | `Placeholder ] 65 + (** Resolve a graphics mode to the actual output method. 66 + 67 + - [`Graphics] - use direct graphics protocol 68 + - [`Tmux] - use graphics protocol with tmux passthrough 69 + - [`Placeholder] - use text placeholders (block characters) 70 + 71 + For [Auto] mode: 72 + - If in a pager or non-interactive: [`Placeholder] 73 + - If in tmux with graphics terminal: [`Tmux] 74 + - If in graphics terminal: [`Graphics] 75 + - Otherwise: [`Placeholder] *) 76 + 77 + val supports_graphics : graphics_mode -> bool 78 + (** Check if the resolved mode supports graphics output. 79 + 80 + Returns [true] for [`Graphics] and [`Tmux], [false] for [`Placeholder]. *)
+23
lib/kgp_tmux.ml
··· 1 + (* Tmux Passthrough Support - Implementation *) 2 + 3 + let is_active () = 4 + Option.is_some (Sys.getenv_opt "TMUX") 5 + 6 + let write_wrapped buf s = 7 + (* DCS passthrough prefix: ESC P tmux ; *) 8 + Buffer.add_string buf "\027Ptmux;"; 9 + (* Double all ESC characters in the content *) 10 + String.iter (fun c -> 11 + if c = '\027' then Buffer.add_string buf "\027\027" 12 + else Buffer.add_char buf c 13 + ) s; 14 + (* DCS terminator: ESC \ *) 15 + Buffer.add_string buf "\027\\" 16 + 17 + let wrap_always s = 18 + let buf = Buffer.create (String.length s * 2 + 10) in 19 + write_wrapped buf s; 20 + Buffer.contents buf 21 + 22 + let wrap s = 23 + if is_active () then wrap_always s else s
+63
lib/kgp_tmux.mli
··· 1 + (** Tmux Passthrough Support 2 + 3 + Support for passing graphics protocol escape sequences through tmux 4 + to the underlying terminal emulator. 5 + 6 + {2 Background} 7 + 8 + When running inside tmux, graphics protocol escape sequences need to be 9 + wrapped in a DCS (Device Control String) passthrough sequence so that 10 + tmux forwards them to the actual terminal (kitty, wezterm, ghostty, etc.) 11 + rather than interpreting them itself. 12 + 13 + The passthrough format is: 14 + - Prefix: [ESC P tmux ;] 15 + - Content with all ESC characters doubled 16 + - Suffix: [ESC] 17 + 18 + {2 Requirements} 19 + 20 + For tmux passthrough to work: 21 + - tmux version 3.3 or later 22 + - [allow-passthrough] must be enabled in tmux.conf: 23 + {v set -g allow-passthrough on v} 24 + 25 + {2 Usage} 26 + 27 + {[ 28 + if Kgp.Tmux.is_active () then 29 + let wrapped = Kgp.Tmux.wrap graphics_command in 30 + print_string wrapped 31 + else 32 + print_string graphics_command 33 + ]} *) 34 + 35 + val is_active : unit -> bool 36 + (** Detect if we are running inside tmux. 37 + 38 + Returns [true] if the [TMUX] environment variable is set, 39 + indicating the process is running inside a tmux session. *) 40 + 41 + val wrap : string -> string 42 + (** Wrap an escape sequence for tmux passthrough. 43 + 44 + Takes a graphics protocol escape sequence and wraps it in the 45 + tmux DCS passthrough format: 46 + - Adds [ESC P tmux ;] prefix 47 + - Doubles all ESC characters in the content 48 + - Adds [ESC] suffix 49 + 50 + If not running inside tmux, returns the input unchanged. *) 51 + 52 + val wrap_always : string -> string 53 + (** Wrap an escape sequence for tmux passthrough unconditionally. 54 + 55 + Like {!wrap} but always applies the wrapping, regardless of 56 + whether we are inside tmux. Useful when you want to pre-generate 57 + tmux-compatible output. *) 58 + 59 + val write_wrapped : Buffer.t -> string -> unit 60 + (** Write a wrapped escape sequence directly to a buffer. 61 + 62 + More efficient than {!wrap_always} when building output in a buffer, 63 + as it avoids allocating an intermediate string. *)
+15 -8
lib/kgp_unicode.ml
··· 44 44 let column_diacritic = diacritic 45 45 let id_high_byte_diacritic = diacritic 46 46 47 + let next_image_id () = 48 + let rec gen () = 49 + let id = Random.int32 Int32.max_int |> Int32.to_int in 50 + (* Ensure high byte and middle bytes are non-zero *) 51 + if id land 0xFF000000 = 0 || id land 0x00FFFF00 = 0 then gen () 52 + else id 53 + in 54 + gen () 55 + 47 56 let add_uchar buf u = 48 57 let code = Uchar.to_int u in 49 58 let put = Buffer.add_char buf in ··· 62 71 put (Char.chr (0x80 lor (code land 0x3F)))) 63 72 64 73 let write buf ~image_id ?placement_id ~rows ~cols () = 65 - (* Set foreground color *) 66 - Printf.bprintf buf "\027[38;2;%d;%d;%dm" 74 + (* Set foreground color using colon subparameter format *) 75 + Printf.bprintf buf "\027[38:2:%d:%d:%dm" 67 76 ((image_id lsr 16) land 0xFF) 68 77 ((image_id lsr 8) land 0xFF) 69 78 (image_id land 0xFF); 70 79 (* Optional placement ID in underline color *) 71 80 placement_id 72 81 |> Option.iter (fun pid -> 73 - Printf.bprintf buf "\027[58;2;%d;%d;%dm" 82 + Printf.bprintf buf "\027[58:2:%d:%d:%dm" 74 83 ((pid lsr 16) land 0xFF) 75 84 ((pid lsr 8) land 0xFF) 76 85 (pid land 0xFF)); 77 - (* High byte diacritic *) 86 + (* High byte diacritic - always written, even when 0 *) 78 87 let high_byte = (image_id lsr 24) land 0xFF in 79 - let high_diac = 80 - if high_byte > 0 then Some (id_high_byte_diacritic high_byte) else None 81 - in 88 + let id_diac = id_high_byte_diacritic high_byte in 82 89 (* Write grid *) 83 90 for row = 0 to rows - 1 do 84 91 for col = 0 to cols - 1 do 85 92 add_uchar buf placeholder_char; 86 93 add_uchar buf (row_diacritic row); 87 94 add_uchar buf (column_diacritic col); 88 - high_diac |> Option.iter (add_uchar buf) 95 + add_uchar buf id_diac 89 96 done; 90 97 if row < rows - 1 then Buffer.add_string buf "\n\r" 91 98 done;
+26 -2
lib/kgp_unicode.mli
··· 1 1 (** Kitty Graphics Protocol Unicode Placeholders 2 2 3 3 Support for invisible Unicode placeholder characters that encode 4 - image position metadata for accessibility and compatibility. *) 4 + image position metadata for accessibility and compatibility. 5 + 6 + {2 Image ID Requirements} 7 + 8 + When using unicode placeholders, image IDs must have non-zero bytes in 9 + specific positions for correct rendering: 10 + - High byte (bits 24-31): encoded as the third combining diacritic 11 + - Middle bytes (bits 8-23): encoded in the foreground RGB color 12 + 13 + Use {!next_image_id} to generate IDs that satisfy these requirements. *) 5 14 6 15 val placeholder_char : Uchar.t 7 16 (** The Unicode placeholder character U+10EEEE. *) 8 17 18 + val next_image_id : unit -> int 19 + (** Generate a random image ID suitable for unicode placeholders. 20 + 21 + The returned ID has non-zero bytes in all required positions: 22 + - High byte (bits 24-31) is non-zero 23 + - Middle bytes (bits 8-23) are non-zero 24 + 25 + This ensures the foreground color encoding and diacritic encoding 26 + work correctly. Uses [Random] internally. *) 27 + 9 28 val write : 10 29 Buffer.t -> 11 30 image_id:int -> ··· 14 33 cols:int -> 15 34 unit -> 16 35 unit 17 - (** Write placeholder characters to a buffer. *) 36 + (** Write placeholder characters to a buffer. 37 + 38 + @param image_id Should be generated with {!next_image_id} for correct rendering. 39 + @param placement_id Optional placement ID for multiple placements of same image. 40 + @param rows Number of rows in the placeholder grid. 41 + @param cols Number of columns in the placeholder grid. *) 18 42 19 43 val row_diacritic : int -> Uchar.t 20 44 (** Get the combining diacritic for a row number (0-based). *)