Kitty Graphics Protocol in OCaml
terminal graphics ocaml

kgpcat

Changed files
+357
bin
+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)