Kitty Graphics Protocol in OCaml
terminal graphics ocaml

sync

+1
dune-project
···
"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.")
(depends
(ocaml (>= 4.14.0))
base64))
···
"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.")
(depends
(ocaml (>= 4.14.0))
+
cmdliner
base64))
-97
example/anim_test.ml
···
-
(* Minimal animation test - shows exact bytes sent *)
-
-
module K = Kgp
-
-
let solid_color_rgba ~width ~height ~r ~g ~b ~a =
-
let pixels = Bytes.create (width * height * 4) in
-
for i = 0 to (width * height) - 1 do
-
let idx = i * 4 in
-
Bytes.set pixels idx (Char.chr r);
-
Bytes.set pixels (idx + 1) (Char.chr g);
-
Bytes.set pixels (idx + 2) (Char.chr b);
-
Bytes.set pixels (idx + 3) (Char.chr a)
-
done;
-
Bytes.to_string pixels
-
-
let send cmd ~data =
-
print_string (K.Command.to_string cmd ~data);
-
flush stdout
-
-
let () =
-
let width, height = 40, 40 in (* Smaller for faster testing *)
-
let image_id = 500 in
-
-
(* Clear any existing image *)
-
send (K.Command.delete ~free:true ~quiet:`Errors_only `All_visible) ~data:"";
-
-
(* Step 1: Transmit base frame (red) *)
-
let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in
-
send
-
(K.Command.transmit
-
~image_id
-
~format:`Rgba32
-
~width ~height
-
~quiet:`Errors_only
-
())
-
~data:red_frame;
-
-
(* Step 2: Add frame (blue) *)
-
let blue_frame = solid_color_rgba ~width ~height ~r:0 ~g:0 ~b:255 ~a:255 in
-
send
-
(K.Command.frame
-
~image_id
-
~format:`Rgba32
-
~width ~height
-
~frame:(K.Frame.make ~gap_ms:500 ~composition:`Overwrite ())
-
~quiet:`Errors_only
-
())
-
~data:blue_frame;
-
-
(* Step 3: Add frame (green) *)
-
let green_frame = solid_color_rgba ~width ~height ~r:0 ~g:255 ~b:0 ~a:255 in
-
send
-
(K.Command.frame
-
~image_id
-
~format:`Rgba32
-
~width ~height
-
~frame:(K.Frame.make ~gap_ms:500 ~composition:`Overwrite ())
-
~quiet:`Errors_only
-
())
-
~data:green_frame;
-
-
(* Step 4: Create placement *)
-
send
-
(K.Command.display
-
~image_id
-
~placement:(K.Placement.make
-
~placement_id:1
-
~cursor:`Static
-
())
-
~quiet:`Errors_only
-
())
-
~data:"";
-
-
(* Step 5: Set root frame gap - IMPORTANT: root frame has no gap by default *)
-
send
-
(K.Command.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:500))
-
~data:"";
-
-
(* Step 6: Start animation *)
-
send
-
(K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run))
-
~data:"";
-
-
print_endline "";
-
print_endline "Animation should be playing (red -> blue -> green).";
-
print_endline "Press Enter to stop...";
-
flush stdout;
-
let _ = read_line () in
-
-
(* Stop animation *)
-
send
-
(K.Command.animate ~image_id (K.Animation.set_state `Stop))
-
~data:"";
-
-
(* Clean up *)
-
send (K.Command.delete ~free:true ~quiet:`Errors_only `All_visible) ~data:"";
-
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
···
-
(* Debug: Output animation escape sequences for comparison with Go *)
-
-
module K = Kgp
-
-
let solid_color_rgba ~width ~height ~r ~g ~b ~a =
-
let pixels = Bytes.create (width * height * 4) in
-
for i = 0 to (width * height) - 1 do
-
let idx = i * 4 in
-
Bytes.set pixels idx (Char.chr r);
-
Bytes.set pixels (idx + 1) (Char.chr g);
-
Bytes.set pixels (idx + 2) (Char.chr b);
-
Bytes.set pixels (idx + 3) (Char.chr a)
-
done;
-
Bytes.to_string pixels
-
-
let send cmd ~data =
-
let s = K.Command.to_string cmd ~data in
-
(* Print escaped version for debugging *)
-
String.iter (fun c ->
-
let code = Char.code c in
-
if code = 27 then print_string "\\x1b"
-
else if code < 32 || code > 126 then Printf.printf "\\x%02x" code
-
else print_char c
-
) s;
-
print_newline ()
-
-
let () =
-
let width, height = 80, 80 in
-
let image_id = 300 in
-
-
print_endline "=== OCaml Animation Debug ===\n";
-
-
(* Step 1: Transmit base frame *)
-
print_endline "1. Transmit base frame (a=t):";
-
let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in
-
send
-
(K.Command.transmit
-
~image_id
-
~format:`Rgba32
-
~width ~height
-
~quiet:`Errors_only
-
())
-
~data:red_frame;
-
print_newline ();
-
-
(* Step 2: Add frame *)
-
print_endline "2. Add frame (a=f):";
-
let orange_frame = solid_color_rgba ~width ~height ~r:255 ~g:165 ~b:0 ~a:255 in
-
send
-
(K.Command.frame
-
~image_id
-
~format:`Rgba32
-
~width ~height
-
~frame:(K.Frame.make ~gap_ms:100 ~composition:`Overwrite ())
-
~quiet:`Errors_only
-
())
-
~data:orange_frame;
-
print_newline ();
-
-
(* Step 3: Put/display placement *)
-
print_endline "3. Create placement (a=p):";
-
send
-
(K.Command.display
-
~image_id
-
~placement:(K.Placement.make
-
~placement_id:1
-
~cell_x_offset:0
-
~cell_y_offset:0
-
~cursor:`Static
-
())
-
~quiet:`Errors_only
-
())
-
~data:"";
-
print_newline ();
-
-
(* Step 4: Set root frame gap *)
-
print_endline "4. Set root frame gap (a=a,r=1,z=100):";
-
send
-
(K.Command.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:100))
-
~data:"";
-
print_newline ();
-
-
(* Step 5: Animate *)
-
print_endline "5. Start animation (a=a,s=3,v=1):";
-
send
-
(K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run))
-
~data:"";
-
print_newline ();
-
-
(* Step 6: Stop animation *)
-
print_endline "6. Stop animation:";
-
send
-
(K.Command.animate ~image_id (K.Animation.set_state `Stop))
-
~data:""
···
example/debug_anim.mli

This is a binary file and will not be displayed.

+3 -11
example/dune
···
(name example)
(libraries kgp unix))
-
(executable
-
(name debug_anim)
-
(libraries kgp))
-
-
(executable
-
(name test_output)
-
(libraries kgp))
-
-
(executable
-
(name anim_test)
-
(libraries kgp))
(executable
(name tiny_anim)
···
(name example)
(libraries kgp unix))
+
(alias
+
(name example)
+
(deps example.exe camel.png))
(executable
(name tiny_anim)
+34 -34
example/example.ml
···
s
let send cmd ~data =
-
print_string (K.Command.to_string cmd ~data);
flush stdout
let wait_for_enter () =
···
(* Demo 1: Basic formats - PNG *)
clear_screen ();
print_endline "Demo 1: Image Formats - PNG format";
-
(* Read sf.png and display a small portion as demo *)
(try
-
let png_data = read_file "sf.png" in
send
-
(K.Command.transmit_and_display
~image_id:1
~format:`Png
~quiet:`Errors_only
~placement:(K.Placement.make ~columns:15 ~rows:8 ())
())
~data:png_data;
-
print_endline "sf.png displayed using PNG format"
with _ ->
(* Fallback: red square as RGBA *)
let red_data = solid_color_rgba ~width:100 ~height:100 ~r:255 ~g:0 ~b:0 ~a:255 in
send
-
(K.Command.transmit_and_display
~image_id:1
~format:`Rgba32
~width:100 ~height:100
~quiet:`Errors_only
())
~data:red_data;
-
print_endline "Red square displayed (sf.png not found)");
print_newline ();
wait_for_enter ();
···
print_endline "Demo 2: Image Formats - RGBA format (32-bit)";
let blue_data = solid_color_rgba ~width:100 ~height:100 ~r:0 ~g:0 ~b:255 ~a:255 in
send
-
(K.Command.transmit_and_display
~image_id:2
~format:`Rgba32
~width:100 ~height:100
···
print_endline "Demo 3: Image Formats - RGB format (24-bit)";
let green_data = solid_color_rgb ~width:100 ~height:100 ~r:0 ~g:255 ~b:0 in
send
-
(K.Command.transmit_and_display
~image_id:3
~format:`Rgb24
~width:100 ~height:100
···
print_endline "Demo 4: Large Image (compression requires zlib library)";
let orange_data = solid_color_rgba ~width:200 ~height:200 ~r:255 ~g:165 ~b:0 ~a:255 in
send
-
(K.Command.transmit_and_display
~image_id:4
~format:`Rgba32
~width:200 ~height:200
···
(* Demo 5: Load and display external PNG file *)
clear_screen ();
-
print_endline "Demo 5: Loading external PNG file (sf.png)";
(try
-
let png_data = read_file "sf.png" in
send
-
(K.Command.transmit_and_display
~image_id:10
~format:`Png
~quiet:`Errors_only
())
~data:png_data;
-
print_endline "sf.png loaded and displayed"
with Sys_error msg ->
-
Printf.printf "sf.png not found: %s\n" msg);
print_newline ();
wait_for_enter ();
···
print_endline "Demo 6: Cropping and Scaling - Display part of an image";
let gradient = gradient_rgba ~width:200 ~height:200 in
send
-
(K.Command.transmit_and_display
~image_id:20
~format:`Rgba32
~width:200 ~height:200
···
let cyan_data = solid_color_rgba ~width:80 ~height:80 ~r:0 ~g:255 ~b:255 ~a:255 in
(* Transmit once with an ID *)
send
-
(K.Command.transmit
~image_id:100
~format:`Rgba32
~width:80 ~height:80
···
~data:cyan_data;
(* Create first placement *)
send
-
(K.Command.display
~image_id:100
~placement:(K.Placement.make ~columns:10 ~rows:5 ())
~quiet:`Errors_only
···
~data:"";
(* Create second placement *)
send
-
(K.Command.display
~image_id:100
~placement:(K.Placement.make ~columns:5 ~rows:3 ())
~quiet:`Errors_only
···
let grad_small = gradient_rgba ~width:100 ~height:100 in
(* Transmit once *)
send
-
(K.Command.transmit
~image_id:160
~format:`Rgba32
~width:100 ~height:100
···
~data:grad_small;
(* Place same image three times at different sizes *)
send
-
(K.Command.display
~image_id:160
~placement:(K.Placement.make ~columns:5 ~rows:5 ())
~quiet:`Errors_only
···
~data:"";
print_string " ";
send
-
(K.Command.display
~image_id:160
~placement:(K.Placement.make ~columns:8 ~rows:8 ())
~quiet:`Errors_only
···
~data:"";
print_string " ";
send
-
(K.Command.display
~image_id:160
~placement:(K.Placement.make ~columns:12 ~rows:12 ())
~quiet:`Errors_only
···
print_endline "Demo 9: Z-Index Layering - Images above/below text";
let bg_data = solid_color_rgba ~width:200 ~height:100 ~r:255 ~g:165 ~b:0 ~a:128 in
send
-
(K.Command.transmit_and_display
~image_id:200
~format:`Rgba32
~width:200 ~height:100
···
(* Create base frame (red) - transmit without displaying *)
let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in
send
-
(K.Command.transmit
~image_id
~format:`Rgba32
~width ~height
···
(* Add frames with composition replace *)
let orange_frame = solid_color_rgba ~width ~height ~r:255 ~g:165 ~b:0 ~a:255 in
send
-
(K.Command.frame
~image_id
~format:`Rgba32
~width ~height
···
let yellow_frame = solid_color_rgba ~width ~height ~r:255 ~g:255 ~b:0 ~a:255 in
send
-
(K.Command.frame
~image_id
~format:`Rgba32
~width ~height
···
let green_frame = solid_color_rgba ~width ~height ~r:0 ~g:255 ~b:0 ~a:255 in
send
-
(K.Command.frame
~image_id
~format:`Rgba32
~width ~height
···
(* Create placement and start animation *)
send
-
(K.Command.display
~image_id
~placement:(K.Placement.make
~placement_id:1
···
(* Set root frame gap - root frame has no gap by default per Kitty protocol *)
send
-
(K.Command.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:100))
~data:"";
(* Start animation with infinite looping *)
send
-
(K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run))
~data:"";
print_newline ();
···
(* Delete the current placement *)
send
-
(K.Command.delete ~quiet:`Errors_only (`By_id (image_id, Some 1)))
~data:"";
(* Create new placement at next position *)
send
-
(K.Command.display
~image_id
~placement:(K.Placement.make
~placement_id:1
···
(* Stop the animation *)
send
-
(K.Command.animate ~image_id (K.Animation.set_state `Stop))
~data:"";
print_endline "Animation stopped.";
···
s
let send cmd ~data =
+
print_string (K.to_string cmd ~data);
flush stdout
let wait_for_enter () =
···
(* Demo 1: Basic formats - PNG *)
clear_screen ();
print_endline "Demo 1: Image Formats - PNG format";
+
(* Read camel.png and display a small portion as demo *)
(try
+
let png_data = read_file "camel.png" in
send
+
(K.transmit_and_display
~image_id:1
~format:`Png
~quiet:`Errors_only
~placement:(K.Placement.make ~columns:15 ~rows:8 ())
())
~data:png_data;
+
print_endline "camel.png displayed using PNG format"
with _ ->
(* Fallback: red square as RGBA *)
let red_data = solid_color_rgba ~width:100 ~height:100 ~r:255 ~g:0 ~b:0 ~a:255 in
send
+
(K.transmit_and_display
~image_id:1
~format:`Rgba32
~width:100 ~height:100
~quiet:`Errors_only
())
~data:red_data;
+
print_endline "Red square displayed (camel.png not found)");
print_newline ();
wait_for_enter ();
···
print_endline "Demo 2: Image Formats - RGBA format (32-bit)";
let blue_data = solid_color_rgba ~width:100 ~height:100 ~r:0 ~g:0 ~b:255 ~a:255 in
send
+
(K.transmit_and_display
~image_id:2
~format:`Rgba32
~width:100 ~height:100
···
print_endline "Demo 3: Image Formats - RGB format (24-bit)";
let green_data = solid_color_rgb ~width:100 ~height:100 ~r:0 ~g:255 ~b:0 in
send
+
(K.transmit_and_display
~image_id:3
~format:`Rgb24
~width:100 ~height:100
···
print_endline "Demo 4: Large Image (compression requires zlib library)";
let orange_data = solid_color_rgba ~width:200 ~height:200 ~r:255 ~g:165 ~b:0 ~a:255 in
send
+
(K.transmit_and_display
~image_id:4
~format:`Rgba32
~width:200 ~height:200
···
(* Demo 5: Load and display external PNG file *)
clear_screen ();
+
print_endline "Demo 5: Loading external PNG file (camel.png)";
(try
+
let png_data = read_file "camel.png" in
send
+
(K.transmit_and_display
~image_id:10
~format:`Png
~quiet:`Errors_only
())
~data:png_data;
+
print_endline "camel.png loaded and displayed"
with Sys_error msg ->
+
Printf.printf "camel.png not found: %s\n" msg);
print_newline ();
wait_for_enter ();
···
print_endline "Demo 6: Cropping and Scaling - Display part of an image";
let gradient = gradient_rgba ~width:200 ~height:200 in
send
+
(K.transmit_and_display
~image_id:20
~format:`Rgba32
~width:200 ~height:200
···
let cyan_data = solid_color_rgba ~width:80 ~height:80 ~r:0 ~g:255 ~b:255 ~a:255 in
(* Transmit once with an ID *)
send
+
(K.transmit
~image_id:100
~format:`Rgba32
~width:80 ~height:80
···
~data:cyan_data;
(* Create first placement *)
send
+
(K.display
~image_id:100
~placement:(K.Placement.make ~columns:10 ~rows:5 ())
~quiet:`Errors_only
···
~data:"";
(* Create second placement *)
send
+
(K.display
~image_id:100
~placement:(K.Placement.make ~columns:5 ~rows:3 ())
~quiet:`Errors_only
···
let grad_small = gradient_rgba ~width:100 ~height:100 in
(* Transmit once *)
send
+
(K.transmit
~image_id:160
~format:`Rgba32
~width:100 ~height:100
···
~data:grad_small;
(* Place same image three times at different sizes *)
send
+
(K.display
~image_id:160
~placement:(K.Placement.make ~columns:5 ~rows:5 ())
~quiet:`Errors_only
···
~data:"";
print_string " ";
send
+
(K.display
~image_id:160
~placement:(K.Placement.make ~columns:8 ~rows:8 ())
~quiet:`Errors_only
···
~data:"";
print_string " ";
send
+
(K.display
~image_id:160
~placement:(K.Placement.make ~columns:12 ~rows:12 ())
~quiet:`Errors_only
···
print_endline "Demo 9: Z-Index Layering - Images above/below text";
let bg_data = solid_color_rgba ~width:200 ~height:100 ~r:255 ~g:165 ~b:0 ~a:128 in
send
+
(K.transmit_and_display
~image_id:200
~format:`Rgba32
~width:200 ~height:100
···
(* Create base frame (red) - transmit without displaying *)
let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in
send
+
(K.transmit
~image_id
~format:`Rgba32
~width ~height
···
(* Add frames with composition replace *)
let orange_frame = solid_color_rgba ~width ~height ~r:255 ~g:165 ~b:0 ~a:255 in
send
+
(K.frame
~image_id
~format:`Rgba32
~width ~height
···
let yellow_frame = solid_color_rgba ~width ~height ~r:255 ~g:255 ~b:0 ~a:255 in
send
+
(K.frame
~image_id
~format:`Rgba32
~width ~height
···
let green_frame = solid_color_rgba ~width ~height ~r:0 ~g:255 ~b:0 ~a:255 in
send
+
(K.frame
~image_id
~format:`Rgba32
~width ~height
···
(* Create placement and start animation *)
send
+
(K.display
~image_id
~placement:(K.Placement.make
~placement_id:1
···
(* Set root frame gap - root frame has no gap by default per Kitty protocol *)
send
+
(K.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:100))
~data:"";
(* Start animation with infinite looping *)
send
+
(K.animate ~image_id (K.Animation.set_state ~loops:1 `Run))
~data:"";
print_newline ();
···
(* Delete the current placement *)
send
+
(K.delete ~quiet:`Errors_only (`By_id (image_id, Some 1)))
~data:"";
(* Create new placement at next position *)
send
+
(K.display
~image_id
~placement:(K.Placement.make
~placement_id:1
···
(* Stop the animation *)
send
+
(K.animate ~image_id (K.Animation.set_state `Stop))
~data:"";
print_endline "Animation stopped.";
-59
example/test_output.ml
···
-
(* Simple test to show exact escape sequences without data *)
-
-
module K = Kgp
-
-
let print_escaped s =
-
String.iter (fun c ->
-
let code = Char.code c in
-
if code = 27 then print_string "\\x1b"
-
else if code < 32 || code > 126 then Printf.printf "\\x%02x" code
-
else print_char c
-
) s;
-
print_newline ()
-
-
let () =
-
let image_id = 300 in
-
let width, height = 80, 80 in
-
-
print_endline "=== Animation Escape Sequences (no data) ===\n";
-
-
(* 1. Transmit base frame (no data for testing) *)
-
print_endline "1. Transmit (a=t):";
-
let cmd1 = K.Command.transmit
-
~image_id ~format:`Rgba32 ~width ~height ~quiet:`Errors_only () in
-
print_escaped (K.Command.to_string cmd1 ~data:"");
-
-
(* 2. Frame command *)
-
print_endline "\n2. Frame (a=f):";
-
let cmd2 = K.Command.frame
-
~image_id ~format:`Rgba32 ~width ~height
-
~frame:(K.Frame.make ~gap_ms:100 ~composition:`Overwrite ())
-
~quiet:`Errors_only () in
-
print_escaped (K.Command.to_string cmd2 ~data:"");
-
-
(* 3. Put/display command *)
-
print_endline "\n3. Display/Put (a=p):";
-
let cmd3 = K.Command.display
-
~image_id
-
~placement:(K.Placement.make
-
~placement_id:1
-
~cell_x_offset:0
-
~cell_y_offset:0
-
~cursor:`Static ())
-
~quiet:`Errors_only () in
-
print_escaped (K.Command.to_string cmd3 ~data:"");
-
-
(* 4. Set root frame gap - IMPORTANT for animation! *)
-
print_endline "\n4. Set root frame gap (a=a, r=1, z=100):";
-
let cmd4 = K.Command.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:100) in
-
print_escaped (K.Command.to_string cmd4 ~data:"");
-
-
(* 5. Animate - start *)
-
print_endline "\n5. Animate start (a=a, s=3, v=1):";
-
let cmd5 = K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run) in
-
print_escaped (K.Command.to_string cmd5 ~data:"");
-
-
(* 6. Animate - stop *)
-
print_endline "\n6. Animate stop (a=a, s=1):";
-
let cmd6 = K.Command.animate ~image_id (K.Animation.set_state `Stop) in
-
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
···
Bytes.to_string pixels
let send cmd ~data =
-
print_string (K.Command.to_string cmd ~data);
flush stdout
let () =
···
let image_id = 999 in
(* Clear any existing images *)
-
send (K.Command.delete ~free:true ~quiet:`Errors_only `All_visible) ~data:"";
(* Step 1: Transmit base frame (red) - matching Go's sequence *)
let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in
send
-
(K.Command.transmit
~image_id
~format:`Rgba32
~width ~height
···
(* Step 2: Add frame (orange) with 100ms gap - like Go *)
let orange_frame = solid_color_rgba ~width ~height ~r:255 ~g:165 ~b:0 ~a:255 in
send
-
(K.Command.frame
~image_id
~format:`Rgba32
~width ~height
···
(* Step 3: Add frame (yellow) *)
let yellow_frame = solid_color_rgba ~width ~height ~r:255 ~g:255 ~b:0 ~a:255 in
send
-
(K.Command.frame
~image_id
~format:`Rgba32
~width ~height
···
(* Step 4: Add frame (green) *)
let green_frame = solid_color_rgba ~width ~height ~r:0 ~g:255 ~b:0 ~a:255 in
send
-
(K.Command.frame
~image_id
~format:`Rgba32
~width ~height
···
(* Step 5: Create placement - exactly like Go *)
send
-
(K.Command.display
~image_id
~placement:(K.Placement.make
~placement_id:1
···
(* Step 6: Start animation - exactly like Go (NO root frame gap) *)
send
-
(K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run))
~data:"";
print_endline "";
···
(* Stop animation *)
send
-
(K.Command.animate ~image_id (K.Animation.set_state `Stop))
~data:"";
(* Clean up *)
-
send (K.Command.delete ~free:true ~quiet:`Errors_only `All_visible) ~data:"";
print_endline "Done."
···
Bytes.to_string pixels
let send cmd ~data =
+
print_string (K.to_string cmd ~data);
flush stdout
let () =
···
let image_id = 999 in
(* Clear any existing images *)
+
send (K.delete ~free:true ~quiet:`Errors_only `All_visible) ~data:"";
(* Step 1: Transmit base frame (red) - matching Go's sequence *)
let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in
send
+
(K.transmit
~image_id
~format:`Rgba32
~width ~height
···
(* Step 2: Add frame (orange) with 100ms gap - like Go *)
let orange_frame = solid_color_rgba ~width ~height ~r:255 ~g:165 ~b:0 ~a:255 in
send
+
(K.frame
~image_id
~format:`Rgba32
~width ~height
···
(* Step 3: Add frame (yellow) *)
let yellow_frame = solid_color_rgba ~width ~height ~r:255 ~g:255 ~b:0 ~a:255 in
send
+
(K.frame
~image_id
~format:`Rgba32
~width ~height
···
(* Step 4: Add frame (green) *)
let green_frame = solid_color_rgba ~width ~height ~r:0 ~g:255 ~b:0 ~a:255 in
send
+
(K.frame
~image_id
~format:`Rgba32
~width ~height
···
(* Step 5: Create placement - exactly like Go *)
send
+
(K.display
~image_id
~placement:(K.Placement.make
~placement_id:1
···
(* Step 6: Start animation - exactly like Go (NO root frame gap) *)
send
+
(K.animate ~image_id (K.Animation.set_state ~loops:1 `Run))
~data:"";
print_endline "";
···
(* Stop animation *)
send
+
(K.animate ~image_id (K.Animation.set_state `Stop))
~data:"";
(* Clean up *)
+
send (K.delete ~free:true ~quiet:`Errors_only `All_visible) ~data:"";
print_endline "Done."
example/tiny_anim.mli

This is a binary file and will not be displayed.

+4
lib-cli/dune
···
···
+
(library
+
(name kgp_cli)
+
(public_name kgp.cli)
+
(libraries kgp cmdliner))
+23
lib-cli/kgp_cli.ml
···
···
+
(* Cmdliner Support for Kitty Graphics Protocol *)
+
+
open Cmdliner
+
+
let graphics_docs = "GRAPHICS OPTIONS"
+
+
let graphics_term =
+
let doc = "Force graphics output enabled, ignoring terminal detection." in
+
let enable = Arg.info ["g"; "graphics"] ~doc ~docs:graphics_docs in
+
let doc = "Disable graphics output, use text placeholders instead." in
+
let disable = Arg.info ["no-graphics"] ~doc ~docs:graphics_docs in
+
let doc = "Force tmux passthrough mode for graphics." in
+
let tmux = Arg.info ["tmux"] ~doc ~docs:graphics_docs in
+
let choose enable disable tmux : Kgp.Terminal.graphics_mode =
+
if enable then `Enabled
+
else if disable then `Disabled
+
else if tmux then `Tmux
+
else `Auto
+
in
+
Term.(const choose
+
$ Arg.(value & flag enable)
+
$ Arg.(value & flag disable)
+
$ Arg.(value & flag tmux))
+46
lib-cli/kgp_cli.mli
···
···
+
(** Cmdliner Support for Kitty Graphics Protocol
+
+
This module provides Cmdliner terms for configuring graphics output mode
+
in CLI applications. It allows users to override auto-detection with
+
command-line flags.
+
+
{2 Usage}
+
+
Add the graphics term to your command:
+
+
{[
+
let cmd =
+
let graphics = Kgp_cli.graphics_term in
+
let my_args = ... in
+
Cmd.v info Term.(const my_run $ graphics $ my_args)
+
]}
+
+
Then use the resolved mode in your application:
+
+
{[
+
let my_run graphics_mode args =
+
if Kgp.Terminal.supports_graphics graphics_mode then
+
(* render with graphics *)
+
else
+
(* use text fallback *)
+
]} *)
+
+
(** {1 Terms} *)
+
+
val graphics_term : Kgp.Terminal.graphics_mode Cmdliner.Term.t
+
(** Cmdliner term for graphics mode selection.
+
+
Provides the following command-line options:
+
+
- [--graphics] / [-g]: Force graphics output enabled
+
- [--no-graphics]: Force graphics output disabled (use placeholders)
+
- [--tmux]: Force tmux passthrough mode
+
- (default): Auto-detect based on terminal environment
+
+
The term evaluates to a {!Kgp.Terminal.graphics_mode} value which can
+
be passed to {!Kgp.Terminal.supports_graphics} or {!Kgp.Terminal.resolve_mode}. *)
+
+
val graphics_docs : string
+
(** Section name for graphics options in help output ("GRAPHICS OPTIONS").
+
+
Use this when grouping graphics options in a separate help section. *)
+1 -1
lib/dune
···
(library
(name kgp)
(public_name kgp)
-
(libraries base64))
···
(library
(name kgp)
(public_name kgp)
+
(libraries base64 unix))
+4 -3
lib/kgp.ml
···
-
(* Kitty Terminal Graphics Protocol - Main Module *)
-
(* Type modules *)
module Format = Kgp_format
module Transmission = Kgp_transmission
···
let compose = Kgp_command.compose
let write = Kgp_command.write
let to_string = Kgp_command.to_string
(* Core modules *)
-
module Command = Kgp_command
module Response = Kgp_response
(* Utility modules *)
module Unicode_placeholder = Kgp_unicode
module Detect = Kgp_detect
···
(* Type modules *)
module Format = Kgp_format
module Transmission = Kgp_transmission
···
let compose = Kgp_command.compose
let write = Kgp_command.write
let to_string = Kgp_command.to_string
+
let write_tmux = Kgp_command.write_tmux
+
let to_string_tmux = Kgp_command.to_string_tmux
(* Core modules *)
module Response = Kgp_response
(* Utility modules *)
module Unicode_placeholder = Kgp_unicode
module Detect = Kgp_detect
+
module Tmux = Kgp_tmux
+
module Terminal = Kgp_terminal
+23 -5
lib/kgp.mli
···
All graphics commands use the Application Programming Command (APC) format:
-
{v <ESC>_G<control data>;<payload><ESC>\ v}
Where:
- [ESC _G] is the APC start sequence (bytes 0x1B 0x5F 0x47)
···
Convenience wrapper around {!write} that returns the serialized
command as a string. *)
(** {1 Response} *)
module Response = Kgp_response
···
module Unicode_placeholder = Kgp_unicode
module Detect = Kgp_detect
-
(** {1 Low-level Access} *)
-
module Command = Kgp_command
-
(** Low-level command module. The command functions are also available
-
at the top level of this module for convenience. *)
···
All graphics commands use the Application Programming Command (APC) format:
+
{v <ESC>_G<control data>;<payload><ESC> v}
Where:
- [ESC _G] is the APC start sequence (bytes 0x1B 0x5F 0x47)
···
Convenience wrapper around {!write} that returns the serialized
command as a string. *)
+
val write_tmux : Buffer.t -> command -> data:string -> unit
+
(** Write the command to a buffer with tmux passthrough support.
+
+
If running inside tmux (detected via [TMUX] environment variable),
+
wraps the graphics command in a DCS passthrough sequence so it
+
reaches the underlying terminal. Otherwise, behaves like {!write}.
+
+
Requires tmux 3.3+ with [allow-passthrough] enabled. *)
+
+
val to_string_tmux : command -> data:string -> string
+
(** Convert command to a string with tmux passthrough support.
+
+
Convenience wrapper around {!write_tmux}. If running inside tmux,
+
wraps the output for passthrough. Otherwise, behaves like {!to_string}. *)
+
(** {1 Response} *)
module Response = Kgp_response
···
module Unicode_placeholder = Kgp_unicode
module Detect = Kgp_detect
+
module Tmux = Kgp_tmux
+
(** Tmux passthrough support. Provides functions to detect if running
+
inside tmux and to wrap escape sequences for passthrough. *)
+
+
module Terminal = Kgp_terminal
+
(** Terminal environment detection. Provides functions to detect terminal
+
capabilities, pager mode, and resolve graphics output mode. *)
+13
lib/kgp_command.ml
···
let buf = Buffer.create 1024 in
write buf cmd ~data;
Buffer.contents buf
···
let buf = Buffer.create 1024 in
write buf cmd ~data;
Buffer.contents buf
+
+
let write_tmux buf cmd ~data =
+
if Kgp_tmux.is_active () then begin
+
let inner_buf = Buffer.create 1024 in
+
write inner_buf cmd ~data;
+
Kgp_tmux.write_wrapped buf (Buffer.contents inner_buf)
+
end else
+
write buf cmd ~data
+
+
let to_string_tmux cmd ~data =
+
let buf = Buffer.create 1024 in
+
write_tmux buf cmd ~data;
+
Buffer.contents buf
+13
lib/kgp_command.mli
···
val to_string : t -> data:string -> string
(** Convert command to a string. *)
···
val to_string : t -> data:string -> string
(** Convert command to a string. *)
+
+
val write_tmux : Buffer.t -> t -> data:string -> unit
+
(** Write the command to a buffer with tmux passthrough wrapping.
+
+
If running inside tmux (detected via [TMUX] environment variable),
+
wraps the graphics command in a DCS passthrough sequence. Otherwise,
+
behaves like {!write}. *)
+
+
val to_string_tmux : t -> data:string -> string
+
(** Convert command to a string with tmux passthrough wrapping.
+
+
If running inside tmux, wraps the output for passthrough.
+
Otherwise, behaves like {!to_string}. *)
+59
lib/kgp_terminal.ml
···
···
+
(* Terminal Environment Detection *)
+
+
type graphics_mode = [ `Auto | `Enabled | `Disabled | `Tmux ]
+
+
let is_kitty () =
+
Option.is_some (Sys.getenv_opt "KITTY_WINDOW_ID") ||
+
(match Sys.getenv_opt "TERM" with
+
| Some term -> String.lowercase_ascii term = "xterm-kitty"
+
| None -> false) ||
+
(match Sys.getenv_opt "TERM_PROGRAM" with
+
| Some prog -> String.lowercase_ascii prog = "kitty"
+
| None -> false)
+
+
let is_wezterm () =
+
Option.is_some (Sys.getenv_opt "WEZTERM_PANE") ||
+
(match Sys.getenv_opt "TERM_PROGRAM" with
+
| Some prog -> String.lowercase_ascii prog = "wezterm"
+
| None -> false)
+
+
let is_ghostty () =
+
Option.is_some (Sys.getenv_opt "GHOSTTY_RESOURCES_DIR") ||
+
(match Sys.getenv_opt "TERM_PROGRAM" with
+
| Some prog -> String.lowercase_ascii prog = "ghostty"
+
| None -> false)
+
+
let is_graphics_terminal () =
+
is_kitty () || is_wezterm () || is_ghostty ()
+
+
let is_tmux () = Kgp_tmux.is_active ()
+
+
let is_interactive () =
+
Unix.isatty Unix.stdout
+
+
let is_pager () =
+
(* Not interactive = likely piped to pager *)
+
not (is_interactive ()) ||
+
(* PAGER set and not in a known graphics terminal *)
+
(Option.is_some (Sys.getenv_opt "PAGER") && not (is_graphics_terminal ()))
+
+
let resolve_mode = function
+
| `Disabled -> `Placeholder
+
| `Enabled -> `Graphics
+
| `Tmux -> `Tmux
+
| `Auto ->
+
if is_pager () || not (is_interactive ()) then
+
`Placeholder
+
else if is_tmux () then
+
(* Inside tmux - use passthrough if underlying terminal supports graphics *)
+
if is_graphics_terminal () then `Tmux
+
else `Placeholder
+
else if is_graphics_terminal () then
+
`Graphics
+
else
+
`Placeholder
+
+
let supports_graphics mode =
+
match resolve_mode mode with
+
| `Graphics | `Tmux -> true
+
| `Placeholder -> false
+80
lib/kgp_terminal.mli
···
···
+
(** Terminal Environment Detection
+
+
Detect terminal capabilities and environment for graphics protocol support.
+
+
{2 Supported Terminals}
+
+
The following terminals support the Kitty Graphics Protocol:
+
- Kitty (the original implementation)
+
- WezTerm
+
- Ghostty
+
- Konsole (partial support)
+
+
{2 Environment Detection}
+
+
Detection is based on environment variables:
+
- [KITTY_WINDOW_ID] - set by Kitty
+
- [WEZTERM_PANE] - set by WezTerm
+
- [GHOSTTY_RESOURCES_DIR] - set by Ghostty
+
- [TERM_PROGRAM] - may contain terminal name
+
- [TERM] - may contain "kitty"
+
- [TMUX] - set when inside tmux
+
- [PAGER] / output to non-tty - indicates pager mode *)
+
+
(** {1 Graphics Mode} *)
+
+
type graphics_mode = [ `Auto | `Enabled | `Disabled | `Tmux ]
+
(** Graphics output mode.
+
+
- [`Auto] - Auto-detect based on environment
+
- [`Enabled] - Force graphics enabled
+
- [`Disabled] - Force graphics disabled (use placeholders)
+
- [`Tmux] - Force tmux passthrough mode *)
+
+
(** {1 Detection} *)
+
+
val is_kitty : unit -> bool
+
(** Detect if running in Kitty terminal. *)
+
+
val is_wezterm : unit -> bool
+
(** Detect if running in WezTerm terminal. *)
+
+
val is_ghostty : unit -> bool
+
(** Detect if running in Ghostty terminal. *)
+
+
val is_graphics_terminal : unit -> bool
+
(** Detect if running in any terminal that supports the graphics protocol. *)
+
+
val is_tmux : unit -> bool
+
(** Detect if running inside tmux. *)
+
+
val is_pager : unit -> bool
+
(** Detect if output is likely going to a pager.
+
+
Returns [true] if:
+
- stdout is not a tty, or
+
- [PAGER] environment variable is set and we're not in a known
+
graphics-capable terminal *)
+
+
val is_interactive : unit -> bool
+
(** Detect if running interactively (stdout is a tty). *)
+
+
(** {1 Mode Resolution} *)
+
+
val resolve_mode : graphics_mode -> [ `Graphics | `Tmux | `Placeholder ]
+
(** Resolve a graphics mode to the actual output method.
+
+
- [`Graphics] - use direct graphics protocol
+
- [`Tmux] - use graphics protocol with tmux passthrough
+
- [`Placeholder] - use text placeholders (block characters)
+
+
For [Auto] mode:
+
- If in a pager or non-interactive: [`Placeholder]
+
- If in tmux with graphics terminal: [`Tmux]
+
- If in graphics terminal: [`Graphics]
+
- Otherwise: [`Placeholder] *)
+
+
val supports_graphics : graphics_mode -> bool
+
(** Check if the resolved mode supports graphics output.
+
+
Returns [true] for [`Graphics] and [`Tmux], [false] for [`Placeholder]. *)
+23
lib/kgp_tmux.ml
···
···
+
(* Tmux Passthrough Support - Implementation *)
+
+
let is_active () =
+
Option.is_some (Sys.getenv_opt "TMUX")
+
+
let write_wrapped buf s =
+
(* DCS passthrough prefix: ESC P tmux ; *)
+
Buffer.add_string buf "\027Ptmux;";
+
(* Double all ESC characters in the content *)
+
String.iter (fun c ->
+
if c = '\027' then Buffer.add_string buf "\027\027"
+
else Buffer.add_char buf c
+
) s;
+
(* DCS terminator: ESC \ *)
+
Buffer.add_string buf "\027\\"
+
+
let wrap_always s =
+
let buf = Buffer.create (String.length s * 2 + 10) in
+
write_wrapped buf s;
+
Buffer.contents buf
+
+
let wrap s =
+
if is_active () then wrap_always s else s
+63
lib/kgp_tmux.mli
···
···
+
(** Tmux Passthrough Support
+
+
Support for passing graphics protocol escape sequences through tmux
+
to the underlying terminal emulator.
+
+
{2 Background}
+
+
When running inside tmux, graphics protocol escape sequences need to be
+
wrapped in a DCS (Device Control String) passthrough sequence so that
+
tmux forwards them to the actual terminal (kitty, wezterm, ghostty, etc.)
+
rather than interpreting them itself.
+
+
The passthrough format is:
+
- Prefix: [ESC P tmux ;]
+
- Content with all ESC characters doubled
+
- Suffix: [ESC]
+
+
{2 Requirements}
+
+
For tmux passthrough to work:
+
- tmux version 3.3 or later
+
- [allow-passthrough] must be enabled in tmux.conf:
+
{v set -g allow-passthrough on v}
+
+
{2 Usage}
+
+
{[
+
if Kgp.Tmux.is_active () then
+
let wrapped = Kgp.Tmux.wrap graphics_command in
+
print_string wrapped
+
else
+
print_string graphics_command
+
]} *)
+
+
val is_active : unit -> bool
+
(** Detect if we are running inside tmux.
+
+
Returns [true] if the [TMUX] environment variable is set,
+
indicating the process is running inside a tmux session. *)
+
+
val wrap : string -> string
+
(** Wrap an escape sequence for tmux passthrough.
+
+
Takes a graphics protocol escape sequence and wraps it in the
+
tmux DCS passthrough format:
+
- Adds [ESC P tmux ;] prefix
+
- Doubles all ESC characters in the content
+
- Adds [ESC] suffix
+
+
If not running inside tmux, returns the input unchanged. *)
+
+
val wrap_always : string -> string
+
(** Wrap an escape sequence for tmux passthrough unconditionally.
+
+
Like {!wrap} but always applies the wrapping, regardless of
+
whether we are inside tmux. Useful when you want to pre-generate
+
tmux-compatible output. *)
+
+
val write_wrapped : Buffer.t -> string -> unit
+
(** Write a wrapped escape sequence directly to a buffer.
+
+
More efficient than {!wrap_always} when building output in a buffer,
+
as it avoids allocating an intermediate string. *)
+15 -8
lib/kgp_unicode.ml
···
let column_diacritic = diacritic
let id_high_byte_diacritic = diacritic
let add_uchar buf u =
let code = Uchar.to_int u in
let put = Buffer.add_char buf in
···
put (Char.chr (0x80 lor (code land 0x3F))))
let write buf ~image_id ?placement_id ~rows ~cols () =
-
(* Set foreground color *)
-
Printf.bprintf buf "\027[38;2;%d;%d;%dm"
((image_id lsr 16) land 0xFF)
((image_id lsr 8) land 0xFF)
(image_id land 0xFF);
(* Optional placement ID in underline color *)
placement_id
|> Option.iter (fun pid ->
-
Printf.bprintf buf "\027[58;2;%d;%d;%dm"
((pid lsr 16) land 0xFF)
((pid lsr 8) land 0xFF)
(pid land 0xFF));
-
(* High byte diacritic *)
let high_byte = (image_id lsr 24) land 0xFF in
-
let high_diac =
-
if high_byte > 0 then Some (id_high_byte_diacritic high_byte) else None
-
in
(* Write grid *)
for row = 0 to rows - 1 do
for col = 0 to cols - 1 do
add_uchar buf placeholder_char;
add_uchar buf (row_diacritic row);
add_uchar buf (column_diacritic col);
-
high_diac |> Option.iter (add_uchar buf)
done;
if row < rows - 1 then Buffer.add_string buf "\n\r"
done;
···
let column_diacritic = diacritic
let id_high_byte_diacritic = diacritic
+
let next_image_id () =
+
let rec gen () =
+
let id = Random.int32 Int32.max_int |> Int32.to_int in
+
(* Ensure high byte and middle bytes are non-zero *)
+
if id land 0xFF000000 = 0 || id land 0x00FFFF00 = 0 then gen ()
+
else id
+
in
+
gen ()
+
let add_uchar buf u =
let code = Uchar.to_int u in
let put = Buffer.add_char buf in
···
put (Char.chr (0x80 lor (code land 0x3F))))
let write buf ~image_id ?placement_id ~rows ~cols () =
+
(* Set foreground color using colon subparameter format *)
+
Printf.bprintf buf "\027[38:2:%d:%d:%dm"
((image_id lsr 16) land 0xFF)
((image_id lsr 8) land 0xFF)
(image_id land 0xFF);
(* Optional placement ID in underline color *)
placement_id
|> Option.iter (fun pid ->
+
Printf.bprintf buf "\027[58:2:%d:%d:%dm"
((pid lsr 16) land 0xFF)
((pid lsr 8) land 0xFF)
(pid land 0xFF));
+
(* High byte diacritic - always written, even when 0 *)
let high_byte = (image_id lsr 24) land 0xFF in
+
let id_diac = id_high_byte_diacritic high_byte in
(* Write grid *)
for row = 0 to rows - 1 do
for col = 0 to cols - 1 do
add_uchar buf placeholder_char;
add_uchar buf (row_diacritic row);
add_uchar buf (column_diacritic col);
+
add_uchar buf id_diac
done;
if row < rows - 1 then Buffer.add_string buf "\n\r"
done;
+26 -2
lib/kgp_unicode.mli
···
(** Kitty Graphics Protocol Unicode Placeholders
Support for invisible Unicode placeholder characters that encode
-
image position metadata for accessibility and compatibility. *)
val placeholder_char : Uchar.t
(** The Unicode placeholder character U+10EEEE. *)
val write :
Buffer.t ->
image_id:int ->
···
cols:int ->
unit ->
unit
-
(** Write placeholder characters to a buffer. *)
val row_diacritic : int -> Uchar.t
(** Get the combining diacritic for a row number (0-based). *)
···
(** Kitty Graphics Protocol Unicode Placeholders
Support for invisible Unicode placeholder characters that encode
+
image position metadata for accessibility and compatibility.
+
+
{2 Image ID Requirements}
+
+
When using unicode placeholders, image IDs must have non-zero bytes in
+
specific positions for correct rendering:
+
- High byte (bits 24-31): encoded as the third combining diacritic
+
- Middle bytes (bits 8-23): encoded in the foreground RGB color
+
+
Use {!next_image_id} to generate IDs that satisfy these requirements. *)
val placeholder_char : Uchar.t
(** The Unicode placeholder character U+10EEEE. *)
+
val next_image_id : unit -> int
+
(** Generate a random image ID suitable for unicode placeholders.
+
+
The returned ID has non-zero bytes in all required positions:
+
- High byte (bits 24-31) is non-zero
+
- Middle bytes (bits 8-23) are non-zero
+
+
This ensures the foreground color encoding and diacritic encoding
+
work correctly. Uses [Random] internally. *)
+
val write :
Buffer.t ->
image_id:int ->
···
cols:int ->
unit ->
unit
+
(** Write placeholder characters to a buffer.
+
+
@param image_id Should be generated with {!next_image_id} for correct rendering.
+
@param placement_id Optional placement ID for multiple placements of same image.
+
@param rows Number of rows in the placeholder grid.
+
@param cols Number of columns in the placeholder grid. *)
val row_diacritic : int -> Uchar.t
(** Get the combining diacritic for a row number (0-based). *)