Kitty Graphics Protocol in OCaml
terminal graphics ocaml

Compare changes

Choose any two refs to compare.

Changed files
+500 -5
.tangled
workflows
bin
example
+49
.tangled/workflows/build.yml
···
+
when:
+
- event: ["push", "pull_request"]
+
branch: ["main"]
+
+
engine: nixery
+
+
dependencies:
+
nixpkgs:
+
- shell
+
- stdenv
+
- findutils
+
- binutils
+
- libunwind
+
- ncurses
+
- opam
+
- git
+
- gawk
+
- gnupatch
+
- gnum4
+
- gnumake
+
- gnutar
+
- gnused
+
- gnugrep
+
- diffutils
+
- gzip
+
- bzip2
+
- gcc
+
- ocaml
+
+
steps:
+
- name: opam
+
command: |
+
opam init --disable-sandboxing -any
+
- name: switch
+
command: |
+
opam install . --confirm-level=unsafe-yes --deps-only
+
- name: build
+
command: |
+
opam exec -- dune build
+
- name: switch-test
+
command: |
+
opam install . --confirm-level=unsafe-yes --deps-only --with-test
+
- name: test
+
command: |
+
opam exec -- dune runtest --verbose
+
- name: doc
+
command: |
+
opam install -y odoc
+
opam exec -- dune build @doc
+4
CHANGES.md
···
+
v1.0.0 (dev)
+
------------
+
+
- Initial public release (@avsm)
+18
LICENSE.md
···
+
(*
+
* ISC License
+
*
+
* Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>
+
*
+
* Permission to use, copy, modify, and distribute this software for any
+
* purpose with or without fee is hereby granted, provided that the above
+
* copyright notice and this permission notice appear in all copies.
+
*
+
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
*
+
*)
+66
README.md
···
+
# kgp - Kitty Graphics Protocol for OCaml
+
+
An OCaml library for displaying images in terminals using the [Kitty Graphics Protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/).
+
+
This library can display PNG images in supported terminals (e.g. Kitty, WezTerm, Konsole, Ghostty) with varying levels of support depending on the terminal. Most core features work but some advanced things like animations might be only partially supported outside of Kitty.
+
+
Other features include:
+
- Image transmission with automatic chunking and base64 encoding
+
- Multiple placements of the same image
+
- Animation support with frame deltas
+
- Unicode placeholder mode for proper scrolling
+
- tmux passthrough support (requires tmux 3.3+)
+
- Terminal capability detection
+
+
The library provides a Cmdliner term via the `kgp.cli` package to make integration into other CLI tools easier.
+
+
## Installation
+
+
```
+
opam install kgp
+
```
+
+
## Usage
+
+
### Library
+
+
```ocaml
+
(* Display a PNG image *)
+
let png_data = read_file "image.png" in
+
let cmd = Kgp.transmit_and_display ~format:`Png () in
+
let buf = Buffer.create 1024 in
+
Kgp.write buf cmd ~data:png_data;
+
print_string (Buffer.contents buf)
+
```
+
+
```ocaml
+
(* Transmit once, display multiple times *)
+
let cmd = Kgp.transmit ~image_id:1 ~format:`Png () in
+
Kgp.write buf cmd ~data:png_data;
+
+
let cmd = Kgp.display ~image_id:1 () in
+
Kgp.write buf cmd ~data:""
+
```
+
+
### CLI
+
+
```
+
# Display an image
+
kgpcat image.png
+
+
# Display at specific size and position
+
kgpcat --place 40x20@10,5 image.png
+
+
# Display from stdin
+
curl -s https://example.com/image.png | kgpcat
+
+
# Detect terminal support
+
kgpcat --detect-support
+
+
# Clear displayed images
+
kgpcat --clear
+
```
+
+
## License
+
+
ISC
+1
TODO.md
···
+
- Support zlib via decompress or similar
+5
bin/dune
···
+
(executable
+
(name kgpcat)
+
(public_name kgpcat)
+
(package kgp)
+
(libraries kgp kgp.cli cmdliner unix))
+352
bin/kgpcat.ml
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
+
(* kgpcat - Display images in the terminal using Kitty Graphics Protocol *)
+
+
open Cmdliner
+
+
module K = Kgp
+
+
type align = Center | Left | Right
+
type fit = Width | Height | Both | None_
+
+
type config = {
+
files : string list;
+
clear : bool;
+
clear_all : bool;
+
detect_support : bool;
+
align : align; [@warning "-69"]
+
place : (int * int * int * int) option;
+
scale_up : bool; [@warning "-69"]
+
fit : fit; [@warning "-69"]
+
z_index : int;
+
unicode_placeholder : bool;
+
no_trailing_newline : bool;
+
hold : bool;
+
graphics_mode : K.Terminal.graphics_mode;
+
}
+
+
(* Default image size in cells when not using --place *)
+
let default_rows = 10
+
let default_cols = 20
+
+
(* Read file contents *)
+
let read_file filename =
+
let ic = open_in_bin filename in
+
let n = in_channel_length ic in
+
let s = really_input_string ic n in
+
close_in ic;
+
s
+
+
(* Read from stdin *)
+
let read_stdin () =
+
let buf = Buffer.create 4096 in
+
try
+
while true do
+
Buffer.add_channel buf stdin 4096
+
done;
+
assert false
+
with End_of_file -> Buffer.contents buf
+
+
(* Detect if stdin has data *)
+
let stdin_has_data () =
+
let fd = Unix.descr_of_in_channel stdin in
+
not (Unix.isatty fd)
+
+
(* Send command to terminal *)
+
let send ?(tmux = false) cmd ~data =
+
let s = if tmux then K.to_string_tmux cmd ~data else K.to_string cmd ~data in
+
print_string s;
+
flush stdout
+
+
(* Check if file is a supported format (PNG only) *)
+
let is_supported_format filename =
+
let ext =
+
try
+
let dot = String.rindex filename '.' in
+
String.lowercase_ascii (String.sub filename (dot + 1) (String.length filename - dot - 1))
+
with Not_found -> ""
+
in
+
ext = "png" || filename = "stdin"
+
+
(* Display a single image *)
+
let display_image config filename data =
+
let resolved_mode = K.Terminal.resolve_mode config.graphics_mode in
+
match resolved_mode with
+
| `Placeholder ->
+
(* No graphics support - show placeholder text *)
+
Printf.printf "[Image: %s (%d bytes)]\n" filename (String.length data)
+
| `Graphics | `Tmux ->
+
let use_tmux = resolved_mode = `Tmux in
+
(* Use unicode placeholders if requested or if in tmux mode *)
+
let use_unicode = config.unicode_placeholder || use_tmux in
+
let cols, rows =
+
match config.place with
+
| Some (w, h, _, _) -> (w, h)
+
| None -> (default_cols, default_rows)
+
in
+
if use_unicode then (
+
(* Unicode placeholder mode: transmit image, then output placeholder chars *)
+
let image_id = K.next_image_id () in
+
let placement =
+
match config.place with
+
| Some (w, h, x, y) ->
+
K.Placement.make ~columns:w ~rows:h ~cell_x_offset:x ~cell_y_offset:y
+
~z_index:config.z_index ~unicode_placeholder:true ~cursor:`Static ()
+
| None ->
+
let z = if config.z_index <> 0 then Some config.z_index else None in
+
K.Placement.make ~columns:cols ~rows ?z_index:z ~unicode_placeholder:true
+
~cursor:`Static ()
+
in
+
(* Transmit the image data with virtual placement *)
+
let cmd =
+
K.transmit_and_display ~image_id ~format:`Png ~placement ~quiet:`Errors_only ()
+
in
+
send ~tmux:use_tmux cmd ~data;
+
(* Output unicode placeholder characters that reference the image *)
+
let buf = Buffer.create 256 in
+
K.Unicode_placeholder.write buf ~image_id ~rows ~cols ();
+
print_string (Buffer.contents buf);
+
if not config.no_trailing_newline then print_newline ()
+
) else (
+
(* Direct graphics mode *)
+
let image_id = K.next_image_id () in
+
let placement =
+
match config.place with
+
| Some (w, h, x, y) ->
+
Some
+
(K.Placement.make ~columns:w ~rows:h ~cell_x_offset:x ~cell_y_offset:y
+
~z_index:config.z_index ~cursor:`Static ())
+
| None ->
+
let z = if config.z_index <> 0 then Some config.z_index else None in
+
Some
+
(K.Placement.make ?z_index:z
+
~cursor:(if config.no_trailing_newline then `Static else `Move) ())
+
in
+
let cmd =
+
K.transmit_and_display ~image_id ~format:`Png ?placement ~quiet:`Errors_only ()
+
in
+
send ~tmux:use_tmux cmd ~data;
+
if not config.no_trailing_newline && config.place = None then print_newline ()
+
)
+
+
(* Clear all images *)
+
let do_clear () =
+
send (K.delete `All_visible) ~data:""
+
+
(* Clear all images including scrollback *)
+
let do_clear_all () =
+
(* Delete all images by ID range 1 to max, freeing data *)
+
send (K.delete ~free:true (`By_id_range (1, 4294967295))) ~data:""
+
+
(* Detect terminal support *)
+
let do_detect_support () =
+
if K.Terminal.is_graphics_terminal () then (
+
let mode =
+
if K.Terminal.is_tmux () then "tmux"
+
else if K.Terminal.is_kitty () then "kitty"
+
else if K.Terminal.is_wezterm () then "wezterm"
+
else if K.Terminal.is_ghostty () then "ghostty"
+
else "stream"
+
in
+
Printf.eprintf "%s\n" mode;
+
0
+
) else (
+
Printf.eprintf "not supported\n";
+
1
+
)
+
+
(* Main run function *)
+
let run config =
+
(* Handle clear operations first *)
+
if config.clear_all then do_clear_all ()
+
else if config.clear then do_clear ();
+
+
(* Handle detect support *)
+
if config.detect_support then exit (do_detect_support ());
+
+
(* Process files *)
+
let files =
+
if config.files = [] && stdin_has_data () then [ "-" ] else config.files
+
in
+
if files = [] && not config.clear && not config.clear_all then (
+
Printf.eprintf "Usage: kgpcat [OPTIONS] IMAGE_FILE...\n";
+
Printf.eprintf "Try 'kgpcat --help' for more information.\n";
+
exit 1);
+
+
List.iter
+
(fun file ->
+
let name = if file = "-" then "stdin" else file in
+
if not (is_supported_format name) then
+
Printf.eprintf "Error: %s is not a PNG file (only PNG format is supported)\n" file
+
else
+
try
+
let data =
+
if file = "-" then read_stdin () else read_file file
+
in
+
display_image config name data
+
with
+
| Sys_error msg ->
+
Printf.eprintf "Error reading %s: %s\n" file msg
+
| exn ->
+
Printf.eprintf "Error processing %s: %s\n" file (Printexc.to_string exn))
+
files;
+
+
(* Ensure all output is flushed before exiting *)
+
flush stdout;
+
+
if config.hold then (
+
Printf.eprintf "Press Enter to exit...";
+
flush stderr;
+
ignore (read_line ()))
+
+
(* Cmdliner argument definitions *)
+
+
let files_arg =
+
Arg.(value & pos_all file [] & info [] ~docv:"IMAGE" ~doc:"Image files to display.")
+
+
let clear_arg =
+
let doc = "Remove all images currently displayed on the screen." in
+
Arg.(value & flag & info [ "clear" ] ~doc)
+
+
let clear_all_arg =
+
let doc = "Remove all images from screen and scrollback." in
+
Arg.(value & flag & info [ "clear-all" ] ~doc)
+
+
let detect_support_arg =
+
let doc =
+
"Detect support for image display in the terminal. Exits with code 0 if \
+
supported, 1 otherwise. Prints the supported transfer mode to stderr."
+
in
+
Arg.(value & flag & info [ "detect-support" ] ~doc)
+
+
let align_arg =
+
let doc = "Horizontal alignment for the displayed image." in
+
let align_enum = Arg.enum [ ("center", Center); ("left", Left); ("right", Right) ] in
+
Arg.(value & opt align_enum Center & info [ "align" ] ~doc ~docv:"ALIGN")
+
+
let place_arg =
+
let doc =
+
"Display image in specified rectangle. Format: WxH@X,Y where W and H are \
+
width and height in cells, and X,Y is the position. Example: 40x20@10,5"
+
in
+
let parse s =
+
try
+
let at_pos = String.index s '@' in
+
let size_part = String.sub s 0 at_pos in
+
let pos_part = String.sub s (at_pos + 1) (String.length s - at_pos - 1) in
+
let x_pos = String.index size_part 'x' in
+
let w = int_of_string (String.sub size_part 0 x_pos) in
+
let h = int_of_string (String.sub size_part (x_pos + 1) (String.length size_part - x_pos - 1)) in
+
let comma_pos = String.index pos_part ',' in
+
let x = int_of_string (String.sub pos_part 0 comma_pos) in
+
let y = int_of_string (String.sub pos_part (comma_pos + 1) (String.length pos_part - comma_pos - 1)) in
+
Ok (w, h, x, y)
+
with _ -> Error (`Msg "Invalid place format. Use WxH@X,Y (e.g., 40x20@10,5)")
+
in
+
let print ppf (w, h, x, y) = Format.fprintf ppf "%dx%d@%d,%d" w h x y in
+
let place_conv = Arg.conv (parse, print) in
+
Arg.(value & opt (some place_conv) None & info [ "place" ] ~doc ~docv:"WxH@X,Y")
+
+
let scale_up_arg =
+
let doc =
+
"Scale up images smaller than the specified area to use as much of the \
+
area as possible."
+
in
+
Arg.(value & flag & info [ "scale-up" ] ~doc)
+
+
let fit_arg =
+
let doc = "Control how the image is scaled relative to the screen." in
+
let fit_enum =
+
Arg.enum [ ("width", Width); ("height", Height); ("both", Both); ("none", None_) ]
+
in
+
Arg.(value & opt fit_enum Width & info [ "fit" ] ~doc ~docv:"FIT")
+
+
let z_index_arg =
+
let doc =
+
"Z-index of the image. Negative values display text on top of the image."
+
in
+
Arg.(value & opt int 0 & info [ "z"; "z-index" ] ~doc ~docv:"Z")
+
+
let unicode_placeholder_arg =
+
let doc =
+
"Use Unicode placeholder method to display images. This allows images to \
+
scroll properly with text in terminals and multiplexers. Automatically \
+
enabled when using tmux passthrough mode."
+
in
+
Arg.(value & flag & info [ "unicode-placeholder" ] ~doc)
+
+
let no_trailing_newline_arg =
+
let doc = "Don't move cursor to next line after displaying an image." in
+
Arg.(value & flag & info [ "n"; "no-trailing-newline" ] ~doc)
+
+
let hold_arg =
+
let doc = "Wait for a key press before exiting after displaying images." in
+
Arg.(value & flag & info [ "hold" ] ~doc)
+
+
let config_term =
+
let combine files clear clear_all detect_support align place scale_up fit z_index
+
unicode_placeholder no_trailing_newline hold graphics_mode =
+
{
+
files;
+
clear;
+
clear_all;
+
detect_support;
+
align;
+
place;
+
scale_up;
+
fit;
+
z_index;
+
unicode_placeholder;
+
no_trailing_newline;
+
hold;
+
graphics_mode;
+
}
+
in
+
Term.(
+
const combine
+
$ files_arg
+
$ clear_arg
+
$ clear_all_arg
+
$ detect_support_arg
+
$ align_arg
+
$ place_arg
+
$ scale_up_arg
+
$ fit_arg
+
$ z_index_arg
+
$ unicode_placeholder_arg
+
$ no_trailing_newline_arg
+
$ hold_arg
+
$ Kgp_cli.graphics_term)
+
+
let cmd =
+
let doc = "Display images in the terminal using Kitty Graphics Protocol" in
+
let man =
+
[
+
`S Manpage.s_description;
+
`P
+
"$(tname) displays images in terminals that support the Kitty Graphics \
+
Protocol (Kitty, WezTerm, Konsole, Ghostty, etc.).";
+
`P
+
"You can specify multiple image files. If no files are given and stdin \
+
is not a terminal, image data is read from stdin.";
+
`S Manpage.s_examples;
+
`P "Display an image:";
+
`Pre " $(tname) photo.png";
+
`P "Display multiple images:";
+
`Pre " $(tname) *.png";
+
`P "Display image from stdin:";
+
`Pre " curl -s https://example.com/image.png | $(tname)";
+
`P "Display image at specific position:";
+
`Pre " $(tname) --place 40x20@10,5 photo.png";
+
`P "Clear all displayed images:";
+
`Pre " $(tname) --clear";
+
`S Kgp_cli.graphics_docs;
+
]
+
in
+
let info = Cmd.info "kgpcat" ~version:"%%VERSION%%" ~doc ~man in
+
Cmd.v info Term.(const run $ config_term)
+
+
let () = exit (Cmd.eval cmd)
+1 -1
example/example.ml
···
SPDX-License-Identifier: ISC
---------------------------------------------------------------------------*)
-
(* Kitty Graphics Protocol Demo - Matching kgp/examples/demo *)
+
(* Kitty Graphics Protocol Demo *)
module K = Kgp
+4 -4
example/tiny_anim.ml
···
(* 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 *)
+
(* 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.transmit ~image_id ~format:`Rgba32 ~width ~height ~quiet:`Errors_only ())
~data:red_frame;
-
(* Step 2: Add frame (orange) with 100ms gap - like Go *)
+
(* Step 2: Add frame (orange) with 100ms gap *)
let orange_frame =
solid_color_rgba ~width ~height ~r:255 ~g:165 ~b:0 ~a:255
in
···
~quiet:`Errors_only ())
~data:green_frame;
-
(* Step 5: Create placement - exactly like Go *)
+
(* Step 5: Create placement *)
send
(K.display ~image_id
~placement:
···
~quiet:`Errors_only ())
~data:"";
-
(* Step 6: Start animation - exactly like Go (NO root frame gap) *)
+
(* Step 6: Start animation *)
send (K.animate ~image_id (K.Animation.set_state ~loops:1 `Run)) ~data:"";
print_endline "";