My agentic slop goes here. Not intended for anyone else!

kitty

Changed files
+1830 -92
claudeio
stack
+3 -3
claudeio/lib/client.mli
···
(** [receive t] returns a lazy sequence of messages from Claude.
The sequence yields messages as they arrive from Claude, including:
-
- {!Message.Assistant} - Claude's responses
-
- {!Message.System} - System notifications
-
- {!Message.Result} - Final result with usage statistics
+
- {!constructor:Message.Assistant} - Claude's responses
+
- {!constructor:Message.System} - System notifications
+
- {!constructor:Message.Result} - Final result with usage statistics
Control messages (permission requests, hook callbacks) are handled
internally and not yielded to the sequence. *)
+11
stack/kitty_graphics/dune-project
···
+
(lang dune 3.20)
+
(name kitty_graphics)
+
+
(package
+
(name kitty_graphics)
+
(synopsis "OCaml implementation of the Kitty terminal graphics protocol")
+
(description
+
"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))
+3
stack/kitty_graphics/example/dune
···
+
(executable
+
(name example)
+
(libraries kitty_graphics))
+116
stack/kitty_graphics/example/example.ml
···
+
(* Example usage of the Kitty Graphics Protocol library *)
+
+
(* Create a 64x64 colorful gradient image in RGBA format *)
+
let test_rgba_image () =
+
let size = 64 in
+
let pixels = Bytes.create (size * size * 4) in
+
for y = 0 to size - 1 do
+
for x = 0 to size - 1 do
+
let offset = (y * size + x) * 4 in
+
(* Red gradient left to right *)
+
Bytes.set pixels offset (Char.chr (x * 4 land 0xFF));
+
(* Green gradient top to bottom *)
+
Bytes.set pixels (offset + 1) (Char.chr (y * 4 land 0xFF));
+
(* Blue diagonal gradient *)
+
Bytes.set pixels (offset + 2) (Char.chr ((x + y) * 2 land 0xFF));
+
(* Fully opaque *)
+
Bytes.set pixels (offset + 3) '\xff'
+
done
+
done;
+
(size, Bytes.to_string pixels)
+
+
let () =
+
print_endline "Kitty Graphics Protocol Example";
+
print_endline "================================";
+
print_newline ();
+
+
(* Example 1: Display a simple RGBA image *)
+
print_endline "1. Displaying a 64x64 RGBA gradient image:";
+
print_newline ();
+
flush stdout;
+
+
let (size, image_data) = test_rgba_image () in
+
let cmd =
+
Kitty_graphics.Command.transmit_and_display
+
~format:Kitty_graphics.Format.Rgba32
+
~width:size ~height:size
+
()
+
in
+
let buf = Buffer.create 4096 in
+
Kitty_graphics.Command.write buf cmd ~data:image_data;
+
print_string (Buffer.contents buf);
+
flush stdout;
+
print_newline ();
+
print_newline ();
+
+
(* Example 2: Display scaled to specific cell size *)
+
print_endline "2. Same image scaled to 20 columns x 10 rows:";
+
print_newline ();
+
flush stdout;
+
+
let placement =
+
Kitty_graphics.Placement.make ~columns:20 ~rows:10 ()
+
in
+
let cmd =
+
Kitty_graphics.Command.transmit_and_display
+
~format:Kitty_graphics.Format.Rgba32
+
~width:size ~height:size
+
~placement
+
()
+
in
+
Buffer.clear buf;
+
Kitty_graphics.Command.write buf cmd ~data:image_data;
+
print_string (Buffer.contents buf);
+
flush stdout;
+
print_newline ();
+
print_newline ();
+
+
(* Example 3: Query terminal support *)
+
print_endline "3. Query command (to test graphics support):";
+
let query = Kitty_graphics.Detect.make_query () in
+
Printf.printf " Query escape sequence: %S\n" query;
+
print_newline ();
+
+
(* Example 4: Delete command *)
+
print_endline "4. Delete all visible images:";
+
let del_cmd =
+
Kitty_graphics.Command.delete Kitty_graphics.Delete.All_visible
+
in
+
Buffer.clear buf;
+
Kitty_graphics.Command.write buf del_cmd ~data:"";
+
Printf.printf " Delete escape sequence: %S\n" (Buffer.contents buf);
+
print_newline ();
+
+
(* Example 5: Unicode placeholder *)
+
print_endline "5. Unicode placeholder (for tmux compatibility):";
+
print_newline ();
+
Buffer.clear buf;
+
Kitty_graphics.Unicode_placeholder.write buf ~image_id:42 ~rows:2 ~cols:4 ();
+
print_string (Buffer.contents buf);
+
print_newline ();
+
print_newline ();
+
+
(* Example 6: Parse a response *)
+
print_endline "6. Parsing terminal responses:";
+
let test_response = "\027_Gi=123;OK\027\\" in
+
(match Kitty_graphics.Response.parse test_response with
+
| Some r ->
+
Printf.printf " Parsed response: is_ok=%b, image_id=%s\n"
+
(Kitty_graphics.Response.is_ok r)
+
(match Kitty_graphics.Response.image_id r with
+
| Some id -> string_of_int id
+
| None -> "none")
+
| None -> print_endline " Failed to parse");
+
+
let error_response = "\027_Gi=456;ENOENT:Image not found\027\\" in
+
(match Kitty_graphics.Response.parse error_response with
+
| Some r ->
+
Printf.printf " Error response: code=%s, message=%s\n"
+
(match Kitty_graphics.Response.error_code r with
+
| Some c -> c
+
| None -> "none")
+
(Kitty_graphics.Response.message r)
+
| None -> print_endline " Failed to parse");
+
+
print_newline ();
+
print_endline "Done!"
+4
stack/kitty_graphics/lib/dune
···
+
(library
+
(name kitty_graphics)
+
(public_name kitty_graphics)
+
(libraries base64))
+901
stack/kitty_graphics/lib/kitty_graphics.ml
···
+
(* Kitty Terminal Graphics Protocol - Implementation *)
+
+
module Format = struct
+
type t = Rgba32 | Rgb24 | Png
+
+
let to_int = function Rgba32 -> 32 | Rgb24 -> 24 | Png -> 100
+
end
+
+
module Transmission = struct
+
type t = Direct | File | Tempfile
+
+
let to_char = function Direct -> 'd' | File -> 'f' | Tempfile -> 't'
+
end
+
+
module Compression = struct
+
type t = None | Zlib
+
+
let to_char = function None -> Option.none | Zlib -> Some 'z'
+
end
+
+
module Quiet = struct
+
type t = Noisy | Errors_only | Silent
+
+
let to_int = function Noisy -> 0 | Errors_only -> 1 | Silent -> 2
+
end
+
+
module Cursor = struct
+
type t = Move | Static
+
+
let to_int = function Move -> 0 | Static -> 1
+
end
+
+
module Composition = struct
+
type t = Alpha_blend | Overwrite
+
+
let to_int = function Alpha_blend -> 0 | Overwrite -> 1
+
end
+
+
module Delete = struct
+
type t =
+
| All_visible
+
| All_visible_and_free
+
| By_id of { image_id : int; placement_id : int option }
+
| By_id_and_free of { image_id : int; placement_id : int option }
+
| By_number of { image_number : int; placement_id : int option }
+
| By_number_and_free of { image_number : int; placement_id : int option }
+
| At_cursor
+
| At_cursor_and_free
+
| At_cell of { x : int; y : int }
+
| At_cell_and_free of { x : int; y : int }
+
| At_cell_z of { x : int; y : int; z : int }
+
| At_cell_z_and_free of { x : int; y : int; z : int }
+
| By_column of int
+
| By_column_and_free of int
+
| By_row of int
+
| By_row_and_free of int
+
| By_z_index of int
+
| By_z_index_and_free of int
+
| By_id_range of { min_id : int; max_id : int }
+
| By_id_range_and_free of { min_id : int; max_id : int }
+
| Frames
+
| Frames_and_free
+
end
+
+
module Placement = struct
+
type t = {
+
source_x : int option;
+
source_y : int option;
+
source_width : int option;
+
source_height : int option;
+
cell_x_offset : int option;
+
cell_y_offset : int option;
+
columns : int option;
+
rows : int option;
+
z_index : int option;
+
placement_id : int option;
+
cursor : Cursor.t option;
+
unicode_placeholder : bool;
+
}
+
+
let empty =
+
{
+
source_x = None;
+
source_y = None;
+
source_width = None;
+
source_height = None;
+
cell_x_offset = None;
+
cell_y_offset = None;
+
columns = None;
+
rows = None;
+
z_index = None;
+
placement_id = None;
+
cursor = None;
+
unicode_placeholder = false;
+
}
+
+
let make ?source_x ?source_y ?source_width ?source_height ?cell_x_offset
+
?cell_y_offset ?columns ?rows ?z_index ?placement_id ?cursor
+
?(unicode_placeholder = false) () =
+
{
+
source_x;
+
source_y;
+
source_width;
+
source_height;
+
cell_x_offset;
+
cell_y_offset;
+
columns;
+
rows;
+
z_index;
+
placement_id;
+
cursor;
+
unicode_placeholder;
+
}
+
end
+
+
module Frame = struct
+
type t = {
+
x : int option;
+
y : int option;
+
base_frame : int option;
+
edit_frame : int option;
+
gap_ms : int option;
+
composition : Composition.t option;
+
background_color : int32 option;
+
}
+
+
let empty =
+
{
+
x = None;
+
y = None;
+
base_frame = None;
+
edit_frame = None;
+
gap_ms = None;
+
composition = None;
+
background_color = None;
+
}
+
+
let make ?x ?y ?base_frame ?edit_frame ?gap_ms ?composition ?background_color
+
() =
+
{ x; y; base_frame; edit_frame; gap_ms; composition; background_color }
+
end
+
+
module Animation = struct
+
type state = Stop | Loading | Run
+
+
type t =
+
| Set_state of { state : state; loops : int option }
+
| Set_gap of { frame : int; gap_ms : int }
+
| Set_current of int
+
+
let set_state ?loops state = Set_state { state; loops }
+
let set_gap ~frame ~gap_ms = Set_gap { frame; gap_ms }
+
let set_current_frame frame = Set_current frame
+
end
+
+
module Compose = struct
+
type t = {
+
source_frame : int;
+
dest_frame : int;
+
width : int option;
+
height : int option;
+
source_x : int option;
+
source_y : int option;
+
dest_x : int option;
+
dest_y : int option;
+
composition : Composition.t option;
+
}
+
+
let make ~source_frame ~dest_frame ?width ?height ?source_x ?source_y ?dest_x
+
?dest_y ?composition () =
+
{
+
source_frame;
+
dest_frame;
+
width;
+
height;
+
source_x;
+
source_y;
+
dest_x;
+
dest_y;
+
composition;
+
}
+
end
+
+
module Command = struct
+
type action =
+
| Transmit
+
| Transmit_and_display
+
| Query
+
| Display
+
| Delete
+
| Frame
+
| Animate
+
| Compose
+
+
type t = {
+
action : action;
+
format : Format.t option;
+
transmission : Transmission.t option;
+
compression : Compression.t option;
+
width : int option;
+
height : int option;
+
size : int option;
+
offset : int option;
+
quiet : Quiet.t option;
+
image_id : int option;
+
image_number : int option;
+
placement : Placement.t option;
+
delete : Delete.t option;
+
frame : Frame.t option;
+
animation : Animation.t option;
+
compose : Compose.t option;
+
}
+
+
let make_base action =
+
{
+
action;
+
format = None;
+
transmission = None;
+
compression = None;
+
width = None;
+
height = None;
+
size = None;
+
offset = None;
+
quiet = None;
+
image_id = None;
+
image_number = None;
+
placement = None;
+
delete = None;
+
frame = None;
+
animation = None;
+
compose = None;
+
}
+
+
let transmit ?image_id ?image_number ?format ?transmission ?compression ?width
+
?height ?size ?offset ?quiet () =
+
{
+
(make_base Transmit) with
+
image_id;
+
image_number;
+
format;
+
transmission;
+
compression;
+
width;
+
height;
+
size;
+
offset;
+
quiet;
+
}
+
+
let transmit_and_display ?image_id ?image_number ?format ?transmission
+
?compression ?width ?height ?size ?offset ?quiet ?placement () =
+
{
+
(make_base Transmit_and_display) with
+
image_id;
+
image_number;
+
format;
+
transmission;
+
compression;
+
width;
+
height;
+
size;
+
offset;
+
quiet;
+
placement;
+
}
+
+
let query ?format ?transmission ?width ?height ?quiet () =
+
{ (make_base Query) with format; transmission; width; height; quiet }
+
+
let display ?image_id ?image_number ?placement ?quiet () =
+
{ (make_base Display) with image_id; image_number; placement; quiet }
+
+
let delete ?quiet del =
+
{ (make_base Delete) with quiet; delete = Some del }
+
+
let frame ?image_id ?image_number ?format ?transmission ?compression ?width
+
?height ?quiet ~frame () =
+
{
+
(make_base Frame) with
+
image_id;
+
image_number;
+
format;
+
transmission;
+
compression;
+
width;
+
height;
+
quiet;
+
frame = Some frame;
+
}
+
+
let animate ?image_id ?image_number ?quiet anim =
+
{ (make_base Animate) with image_id; image_number; quiet; animation = Some anim }
+
+
let compose ?image_id ?image_number ?quiet comp =
+
{ (make_base Compose) with image_id; image_number; quiet; compose = Some comp }
+
+
(* APC escape sequences *)
+
let apc_start = "\027_G"
+
let apc_end = "\027\\"
+
+
(* Helper to add key=value pairs *)
+
let add_kv buf key value =
+
Buffer.add_char buf key;
+
Buffer.add_char buf '=';
+
Buffer.add_string buf value
+
+
let add_kv_int buf key value =
+
Buffer.add_char buf key;
+
Buffer.add_char buf '=';
+
Buffer.add_string buf (string_of_int value)
+
+
let add_kv_int32 buf key value =
+
Buffer.add_char buf key;
+
Buffer.add_char buf '=';
+
Buffer.add_string buf (Int32.to_string value)
+
+
let add_comma buf = Buffer.add_char buf ','
+
+
let action_char = function
+
| Transmit -> 't'
+
| Transmit_and_display -> 'T'
+
| Query -> 'q'
+
| Display -> 'p'
+
| Delete -> 'd'
+
| Frame -> 'f'
+
| Animate -> 'a'
+
| Compose -> 'c'
+
+
let delete_char = function
+
| Delete.All_visible -> 'a'
+
| All_visible_and_free -> 'A'
+
| By_id _ -> 'i'
+
| By_id_and_free _ -> 'I'
+
| By_number _ -> 'n'
+
| By_number_and_free _ -> 'N'
+
| At_cursor -> 'c'
+
| At_cursor_and_free -> 'C'
+
| At_cell _ -> 'p'
+
| At_cell_and_free _ -> 'P'
+
| At_cell_z _ -> 'q'
+
| At_cell_z_and_free _ -> 'Q'
+
| By_column _ -> 'x'
+
| By_column_and_free _ -> 'X'
+
| By_row _ -> 'y'
+
| By_row_and_free _ -> 'Y'
+
| By_z_index _ -> 'z'
+
| By_z_index_and_free _ -> 'Z'
+
| By_id_range _ -> 'r'
+
| By_id_range_and_free _ -> 'R'
+
| Frames -> 'f'
+
| Frames_and_free -> 'F'
+
+
let write_control_data buf cmd =
+
let first = ref true in
+
let sep () =
+
if !first then first := false else add_comma buf
+
in
+
(* Action *)
+
sep ();
+
add_kv buf 'a' (String.make 1 (action_char cmd.action));
+
(* Quiet *)
+
Option.iter
+
(fun q ->
+
let v = Quiet.to_int q in
+
if v <> 0 then (
+
sep ();
+
add_kv_int buf 'q' v))
+
cmd.quiet;
+
(* Format *)
+
Option.iter
+
(fun f ->
+
sep ();
+
add_kv_int buf 'f' (Format.to_int f))
+
cmd.format;
+
(* Transmission *)
+
Option.iter
+
(fun t ->
+
let c = Transmission.to_char t in
+
if c <> 'd' then (
+
sep ();
+
add_kv buf 't' (String.make 1 c)))
+
cmd.transmission;
+
(* Compression *)
+
Option.iter
+
(fun c ->
+
match Compression.to_char c with
+
| Some ch ->
+
sep ();
+
add_kv buf 'o' (String.make 1 ch)
+
| None -> ())
+
cmd.compression;
+
(* Dimensions *)
+
Option.iter
+
(fun w ->
+
sep ();
+
add_kv_int buf 's' w)
+
cmd.width;
+
Option.iter
+
(fun h ->
+
sep ();
+
add_kv_int buf 'v' h)
+
cmd.height;
+
(* File size/offset *)
+
Option.iter
+
(fun s ->
+
sep ();
+
add_kv_int buf 'S' s)
+
cmd.size;
+
Option.iter
+
(fun o ->
+
sep ();
+
add_kv_int buf 'O' o)
+
cmd.offset;
+
(* Image ID *)
+
Option.iter
+
(fun id ->
+
sep ();
+
add_kv_int buf 'i' id)
+
cmd.image_id;
+
(* Image number *)
+
Option.iter
+
(fun n ->
+
sep ();
+
add_kv_int buf 'I' n)
+
cmd.image_number;
+
(* Placement options *)
+
Option.iter
+
(fun (p : Placement.t) ->
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'x' v)
+
p.source_x;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'y' v)
+
p.source_y;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'w' v)
+
p.source_width;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'h' v)
+
p.source_height;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'X' v)
+
p.cell_x_offset;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'Y' v)
+
p.cell_y_offset;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'c' v)
+
p.columns;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'r' v)
+
p.rows;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'z' v)
+
p.z_index;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'p' v)
+
p.placement_id;
+
Option.iter
+
(fun c ->
+
let v = Cursor.to_int c in
+
if v <> 0 then (
+
sep ();
+
add_kv_int buf 'C' v))
+
p.cursor;
+
if p.unicode_placeholder then (
+
sep ();
+
add_kv_int buf 'U' 1))
+
cmd.placement;
+
(* Delete options *)
+
Option.iter
+
(fun d ->
+
sep ();
+
add_kv buf 'd' (String.make 1 (delete_char d));
+
match d with
+
| Delete.By_id { image_id; placement_id }
+
| Delete.By_id_and_free { image_id; placement_id } ->
+
sep ();
+
add_kv_int buf 'i' image_id;
+
Option.iter
+
(fun p ->
+
sep ();
+
add_kv_int buf 'p' p)
+
placement_id
+
| Delete.By_number { image_number; placement_id }
+
| Delete.By_number_and_free { image_number; placement_id } ->
+
sep ();
+
add_kv_int buf 'I' image_number;
+
Option.iter
+
(fun p ->
+
sep ();
+
add_kv_int buf 'p' p)
+
placement_id
+
| Delete.At_cell { x; y } | Delete.At_cell_and_free { x; y } ->
+
sep ();
+
add_kv_int buf 'x' x;
+
sep ();
+
add_kv_int buf 'y' y
+
| Delete.At_cell_z { x; y; z }
+
| Delete.At_cell_z_and_free { x; y; z } ->
+
sep ();
+
add_kv_int buf 'x' x;
+
sep ();
+
add_kv_int buf 'y' y;
+
sep ();
+
add_kv_int buf 'z' z
+
| Delete.By_column c | Delete.By_column_and_free c ->
+
sep ();
+
add_kv_int buf 'x' c
+
| Delete.By_row r | Delete.By_row_and_free r ->
+
sep ();
+
add_kv_int buf 'y' r
+
| Delete.By_z_index z | Delete.By_z_index_and_free z ->
+
sep ();
+
add_kv_int buf 'z' z
+
| Delete.By_id_range { min_id; max_id }
+
| Delete.By_id_range_and_free { min_id; max_id } ->
+
sep ();
+
add_kv_int buf 'x' min_id;
+
sep ();
+
add_kv_int buf 'y' max_id
+
| _ -> ())
+
cmd.delete;
+
(* Frame options *)
+
Option.iter
+
(fun (f : Frame.t) ->
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'x' v)
+
f.x;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'y' v)
+
f.y;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'c' v)
+
f.base_frame;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'r' v)
+
f.edit_frame;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'z' v)
+
f.gap_ms;
+
Option.iter
+
(fun c ->
+
let v = Composition.to_int c in
+
if v <> 0 then (
+
sep ();
+
add_kv_int buf 'X' v))
+
f.composition;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int32 buf 'Y' v)
+
f.background_color)
+
cmd.frame;
+
(* Animation options *)
+
Option.iter
+
(fun a ->
+
match a with
+
| Animation.Set_state { state; loops } ->
+
let s =
+
match state with
+
| Animation.Stop -> 1
+
| Animation.Loading -> 2
+
| Animation.Run -> 3
+
in
+
sep ();
+
add_kv_int buf 's' s;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'v' v)
+
loops
+
| Animation.Set_gap { frame; gap_ms } ->
+
sep ();
+
add_kv_int buf 'r' frame;
+
sep ();
+
add_kv_int buf 'z' gap_ms
+
| Animation.Set_current frame ->
+
sep ();
+
add_kv_int buf 'c' frame)
+
cmd.animation;
+
(* Compose options *)
+
Option.iter
+
(fun (c : Compose.t) ->
+
sep ();
+
add_kv_int buf 'r' c.source_frame;
+
sep ();
+
add_kv_int buf 'c' c.dest_frame;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'w' v)
+
c.width;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'h' v)
+
c.height;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'x' v)
+
c.dest_x;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'y' v)
+
c.dest_y;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'X' v)
+
c.source_x;
+
Option.iter
+
(fun v ->
+
sep ();
+
add_kv_int buf 'Y' v)
+
c.source_y;
+
Option.iter
+
(fun comp ->
+
let v = Composition.to_int comp in
+
if v <> 0 then (
+
sep ();
+
add_kv_int buf 'C' v))
+
c.composition)
+
cmd.compose
+
+
let chunk_size = 4096
+
+
let write buf cmd ~data =
+
Buffer.add_string buf apc_start;
+
write_control_data buf cmd;
+
if String.length data > 0 then begin
+
let encoded = Base64.encode_string data in
+
let len = String.length encoded in
+
if len <= chunk_size then (
+
Buffer.add_char buf ';';
+
Buffer.add_string buf encoded;
+
Buffer.add_string buf apc_end)
+
else begin
+
(* Multiple chunks *)
+
let pos = ref 0 in
+
let first = ref true in
+
while !pos < len do
+
let remaining = len - !pos in
+
let this_chunk = min chunk_size remaining in
+
let is_last = !pos + this_chunk >= len in
+
if !first then (
+
(* First chunk *)
+
first := false;
+
add_comma buf;
+
add_kv_int buf 'm' 1;
+
Buffer.add_char buf ';';
+
Buffer.add_substring buf encoded !pos this_chunk;
+
Buffer.add_string buf apc_end)
+
else (
+
(* Continuation chunk *)
+
Buffer.add_string buf apc_start;
+
add_kv_int buf 'm' (if is_last then 0 else 1);
+
Buffer.add_char buf ';';
+
Buffer.add_substring buf encoded !pos this_chunk;
+
Buffer.add_string buf apc_end);
+
pos := !pos + this_chunk
+
done
+
end
+
end
+
else Buffer.add_string buf apc_end
+
+
let to_string cmd ~data =
+
let buf = Buffer.create 1024 in
+
write buf cmd ~data;
+
Buffer.contents buf
+
end
+
+
module Response = struct
+
type t = {
+
message : string;
+
image_id : int option;
+
image_number : int option;
+
placement_id : int option;
+
}
+
+
let is_ok t = t.message = "OK"
+
let message t = t.message
+
+
let error_code t =
+
if is_ok t then None
+
else
+
match String.index_opt t.message ':' with
+
| Some i -> Some (String.sub t.message 0 i)
+
| None -> Some t.message
+
+
let image_id t = t.image_id
+
let image_number t = t.image_number
+
let placement_id t = t.placement_id
+
+
let parse s =
+
(* Format: <ESC>_G<keys>;message<ESC>\ *)
+
let esc = '\027' in
+
let len = String.length s in
+
if len < 5 then None
+
else if s.[0] <> esc || s.[1] <> '_' || s.[2] <> 'G' then None
+
else
+
(* Find the semicolon and end *)
+
match String.index_from_opt s 3 ';' with
+
| None -> None
+
| Some semi_pos -> (
+
(* Find the APC terminator *)
+
let rec find_end pos =
+
if pos + 1 < len && s.[pos] = esc && s.[pos + 1] = '\\' then
+
Some pos
+
else if pos + 1 < len then find_end (pos + 1)
+
else None
+
in
+
match find_end (semi_pos + 1) with
+
| None -> None
+
| Some end_pos ->
+
let keys_str = String.sub s 3 (semi_pos - 3) in
+
let message =
+
String.sub s (semi_pos + 1) (end_pos - semi_pos - 1)
+
in
+
(* Parse keys *)
+
let image_id = ref None in
+
let image_number = ref None in
+
let placement_id = ref None in
+
let parts = String.split_on_char ',' keys_str in
+
List.iter
+
(fun part ->
+
if String.length part >= 3 && part.[1] = '=' then
+
let key = part.[0] in
+
let value = String.sub part 2 (String.length part - 2) in
+
match key with
+
| 'i' -> image_id := int_of_string_opt value
+
| 'I' -> image_number := int_of_string_opt value
+
| 'p' -> placement_id := int_of_string_opt value
+
| _ -> ())
+
parts;
+
Some
+
{
+
message;
+
image_id = !image_id;
+
image_number = !image_number;
+
placement_id = !placement_id;
+
})
+
end
+
+
module Unicode_placeholder = struct
+
let placeholder_char = Uchar.of_int 0x10EEEE
+
+
(* Row/column diacritics from the protocol spec *)
+
let diacritics =
+
[|
+
0x0305; 0x030D; 0x030E; 0x0310; 0x0312; 0x033D; 0x033E; 0x033F;
+
0x0346; 0x034A; 0x034B; 0x034C; 0x0350; 0x0351; 0x0352; 0x0357;
+
0x035B; 0x0363; 0x0364; 0x0365; 0x0366; 0x0367; 0x0368; 0x0369;
+
0x036A; 0x036B; 0x036C; 0x036D; 0x036E; 0x036F; 0x0483; 0x0484;
+
0x0485; 0x0486; 0x0487; 0x0592; 0x0593; 0x0594; 0x0595; 0x0597;
+
0x0598; 0x0599; 0x059C; 0x059D; 0x059E; 0x059F; 0x05A0; 0x05A1;
+
0x05A8; 0x05A9; 0x05AB; 0x05AC; 0x05AF; 0x05C4; 0x0610; 0x0611;
+
0x0612; 0x0613; 0x0614; 0x0615; 0x0616; 0x0617; 0x0657; 0x0658;
+
0x0659; 0x065A; 0x065B; 0x065D; 0x065E; 0x06D6; 0x06D7; 0x06D8;
+
0x06D9; 0x06DA; 0x06DB; 0x06DC; 0x06DF; 0x06E0; 0x06E1; 0x06E2;
+
0x06E4; 0x06E7; 0x06E8; 0x06EB; 0x06EC; 0x0730; 0x0732; 0x0733;
+
0x0735; 0x0736; 0x073A; 0x073D; 0x073F; 0x0740; 0x0741; 0x0743;
+
0x0745; 0x0747; 0x0749; 0x074A; 0x07EB; 0x07EC; 0x07ED; 0x07EE;
+
0x07EF; 0x07F0; 0x07F1; 0x07F3; 0x0816; 0x0817; 0x0818; 0x0819;
+
0x081B; 0x081C; 0x081D; 0x081E; 0x081F; 0x0820; 0x0821; 0x0822;
+
0x0823; 0x0825; 0x0826; 0x0827; 0x0829; 0x082A; 0x082B; 0x082C;
+
0x082D; 0x0951; 0x0953; 0x0954; 0x0F82; 0x0F83; 0x0F86; 0x0F87;
+
0x135D; 0x135E; 0x135F; 0x17DD; 0x193A; 0x1A17; 0x1A75; 0x1A76;
+
0x1A77; 0x1A78; 0x1A79; 0x1A7A; 0x1A7B; 0x1A7C; 0x1B6B; 0x1B6D;
+
0x1B6E; 0x1B6F; 0x1B70; 0x1B71; 0x1B72; 0x1B73; 0x1CD0; 0x1CD1;
+
0x1CD2; 0x1CDA; 0x1CDB; 0x1CE0; 0x1DC0; 0x1DC1; 0x1DC3; 0x1DC4;
+
0x1DC5; 0x1DC6; 0x1DC7; 0x1DC8; 0x1DC9; 0x1DCB; 0x1DCC; 0x1DD1;
+
0x1DD2; 0x1DD3; 0x1DD4; 0x1DD5; 0x1DD6; 0x1DD7; 0x1DD8; 0x1DD9;
+
0x1DDA; 0x1DDB; 0x1DDC; 0x1DDD; 0x1DDE; 0x1DDF; 0x1DE0; 0x1DE1;
+
0x1DE2; 0x1DE3; 0x1DE4; 0x1DE5; 0x1DE6; 0x1DFE; 0x20D0; 0x20D1;
+
0x20D4; 0x20D5; 0x20D6; 0x20D7; 0x20DB; 0x20DC; 0x20E1; 0x20E7;
+
0x20E9; 0x20F0; 0xA66F; 0xA67C; 0xA67D; 0xA6F0; 0xA6F1; 0xA8E0;
+
0xA8E1; 0xA8E2; 0xA8E3; 0xA8E4; 0xA8E5; 0xA8E6; 0xA8E7; 0xA8E8;
+
0xA8E9; 0xA8EA; 0xA8EB; 0xA8EC; 0xA8ED; 0xA8EE; 0xA8EF; 0xA8F0;
+
0xA8F1; 0xAAB0; 0xAAB2; 0xAAB3; 0xAAB7; 0xAAB8; 0xAABE; 0xAABF;
+
0xAAC1; 0xFE20; 0xFE21; 0xFE22; 0xFE23; 0xFE24; 0xFE25; 0xFE26;
+
0x10A0F; 0x10A38; 0x1D185; 0x1D186; 0x1D187; 0x1D188; 0x1D189;
+
0x1D1AA; 0x1D1AB; 0x1D1AC; 0x1D1AD; 0x1D242; 0x1D243; 0x1D244;
+
|]
+
+
let row_diacritic n =
+
if n >= 0 && n < Array.length diacritics then
+
Uchar.of_int diacritics.(n)
+
else Uchar.of_int diacritics.(0)
+
+
let column_diacritic = row_diacritic
+
let id_high_byte_diacritic = row_diacritic
+
+
let add_uchar buf u =
+
let b = Bytes.create 4 in
+
let len = Uchar.utf_8_byte_length u in
+
let _ = Uchar.unsafe_to_char u in
+
(* Encode UTF-8 manually *)
+
let code = Uchar.to_int u in
+
if code < 0x80 then (
+
Bytes.set b 0 (Char.chr code);
+
Buffer.add_subbytes buf b 0 1)
+
else if code < 0x800 then (
+
Bytes.set b 0 (Char.chr (0xC0 lor (code lsr 6)));
+
Bytes.set b 1 (Char.chr (0x80 lor (code land 0x3F)));
+
Buffer.add_subbytes buf b 0 2)
+
else if code < 0x10000 then (
+
Bytes.set b 0 (Char.chr (0xE0 lor (code lsr 12)));
+
Bytes.set b 1 (Char.chr (0x80 lor ((code lsr 6) land 0x3F)));
+
Bytes.set b 2 (Char.chr (0x80 lor (code land 0x3F)));
+
Buffer.add_subbytes buf b 0 3)
+
else (
+
Bytes.set b 0 (Char.chr (0xF0 lor (code lsr 18)));
+
Bytes.set b 1 (Char.chr (0x80 lor ((code lsr 12) land 0x3F)));
+
Bytes.set b 2 (Char.chr (0x80 lor ((code lsr 6) land 0x3F)));
+
Bytes.set b 3 (Char.chr (0x80 lor (code land 0x3F)));
+
Buffer.add_subbytes buf b 0 len)
+
+
let write buf ~image_id ?placement_id ~rows ~cols () =
+
(* Set foreground color using 24-bit mode *)
+
let r = (image_id lsr 16) land 0xFF in
+
let g = (image_id lsr 8) land 0xFF in
+
let b = image_id land 0xFF in
+
Buffer.add_string buf (Printf.sprintf "\027[38;2;%d;%d;%dm" r g b);
+
(* Optionally set underline color for placement ID *)
+
(match placement_id with
+
| Some pid ->
+
let pr = (pid lsr 16) land 0xFF in
+
let pg = (pid lsr 8) land 0xFF in
+
let pb = pid land 0xFF in
+
Buffer.add_string buf (Printf.sprintf "\027[58;2;%d;%d;%dm" pr pg pb)
+
| None -> ());
+
(* High byte diacritic if needed *)
+
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 placeholder 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);
+
Option.iter (add_uchar buf) high_diac
+
done;
+
if row < rows - 1 then Buffer.add_string buf "\n\r"
+
done;
+
(* Reset colors *)
+
Buffer.add_string buf "\027[39m";
+
match placement_id with Some _ -> Buffer.add_string buf "\027[59m" | None -> ()
+
end
+
+
module Detect = struct
+
let make_query () =
+
(* Send a 1x1 transparent pixel query *)
+
let cmd =
+
Command.query ~format:Format.Rgb24 ~transmission:Transmission.Direct
+
~width:1 ~height:1 ()
+
in
+
let data = "\x00\x00\x00" in
+
let query = Command.to_string cmd ~data in
+
(* Add DA1 query to detect non-supporting terminals *)
+
query ^ "\027[c"
+
+
let supports_graphics response ~da1_received =
+
match response with
+
| Some r -> Response.is_ok r
+
| None -> not da1_received
+
end
+520
stack/kitty_graphics/lib/kitty_graphics.mli
···
+
(** Kitty Terminal Graphics Protocol
+
+
This library implements the Kitty terminal graphics protocol, allowing
+
OCaml programs to display images in terminals that support the protocol
+
(Kitty, WezTerm, Konsole, Ghostty, etc.).
+
+
The protocol uses APC (Application Programming Command) escape sequences
+
to transmit and display pixel graphics. Images can be transmitted as raw
+
RGB/RGBA data or PNG, and displayed at specific positions with various
+
placement options.
+
+
{2 Basic Usage}
+
+
{[
+
(* Display a PNG image *)
+
let png_data = read_file "image.png" in
+
let cmd = Kitty_graphics.Command.transmit_and_display
+
~format:Kitty_graphics.Format.Png
+
()
+
in
+
let buf = Buffer.create 1024 in
+
Kitty_graphics.Command.write buf cmd ~data:png_data;
+
print_string (Buffer.contents buf)
+
]}
+
+
{2 Protocol Reference}
+
+
See {{:https://sw.kovidgoyal.net/kitty/graphics-protocol/} Kitty Graphics Protocol}
+
for the full specification. *)
+
+
(** {1 Core Types} *)
+
+
(** Image data formats. *)
+
module Format : sig
+
type t =
+
| Rgba32 (** 32-bit RGBA, 4 bytes per pixel *)
+
| Rgb24 (** 24-bit RGB, 3 bytes per pixel *)
+
| Png (** PNG encoded data *)
+
+
val to_int : t -> int
+
(** Convert to protocol integer value (32, 24, or 100). *)
+
end
+
+
(** Transmission methods for image data. *)
+
module Transmission : sig
+
type t =
+
| Direct (** Data transmitted inline in the escape sequence *)
+
| File (** Data read from a file path *)
+
| Tempfile (** Data read from a temp file, deleted after reading *)
+
+
val to_char : t -> char
+
(** Convert to protocol character ('d', 'f', or 't'). *)
+
end
+
+
(** Compression options for transmitted data. *)
+
module Compression : sig
+
type t =
+
| None (** No compression *)
+
| Zlib (** RFC 1950 zlib compression *)
+
+
val to_char : t -> char option
+
(** Convert to protocol character (None or Some 'z'). *)
+
end
+
+
(** Response suppression modes. *)
+
module Quiet : sig
+
type t =
+
| Noisy (** Terminal sends all responses (default) *)
+
| Errors_only (** Terminal only sends error responses *)
+
| Silent (** Terminal sends no responses *)
+
+
val to_int : t -> int
+
(** Convert to protocol integer (0, 1, or 2). *)
+
end
+
+
(** Cursor movement policy after displaying an image. *)
+
module Cursor : sig
+
type t =
+
| Move (** Move cursor after image (default) *)
+
| Static (** Keep cursor in place *)
+
+
val to_int : t -> int
+
(** Convert to protocol integer (0 or 1). *)
+
end
+
+
(** Composition modes for blending. *)
+
module Composition : sig
+
type t =
+
| Alpha_blend (** Full alpha blending (default) *)
+
| Overwrite (** Simple pixel replacement *)
+
+
val to_int : t -> int
+
(** Convert to protocol integer (0 or 1). *)
+
end
+
+
(** {1 Delete Operations} *)
+
+
(** Specifies what to delete when using delete commands. *)
+
module Delete : sig
+
(** Delete target specification.
+
+
Each variant has two forms: one that only removes placements (keeping
+
image data for potential reuse) and one that also frees the image data. *)
+
type t =
+
| All_visible
+
(** Delete all visible placements. *)
+
| All_visible_and_free
+
(** Delete all visible placements and free their image data. *)
+
| By_id of { image_id : int; placement_id : int option }
+
(** Delete placements for a specific image ID, optionally filtered
+
by placement ID. *)
+
| By_id_and_free of { image_id : int; placement_id : int option }
+
(** Delete and free by image ID. *)
+
| By_number of { image_number : int; placement_id : int option }
+
(** Delete by image number (newest with that number). *)
+
| By_number_and_free of { image_number : int; placement_id : int option }
+
(** Delete and free by image number. *)
+
| At_cursor
+
(** Delete placements intersecting cursor position. *)
+
| At_cursor_and_free
+
(** Delete and free at cursor position. *)
+
| At_cell of { x : int; y : int }
+
(** Delete placements intersecting a specific cell (1-based). *)
+
| At_cell_and_free of { x : int; y : int }
+
(** Delete and free at specific cell. *)
+
| At_cell_z of { x : int; y : int; z : int }
+
(** Delete at cell with specific z-index. *)
+
| At_cell_z_and_free of { x : int; y : int; z : int }
+
(** Delete and free at cell with z-index. *)
+
| By_column of int
+
(** Delete all placements intersecting a column (1-based). *)
+
| By_column_and_free of int
+
(** Delete and free by column. *)
+
| By_row of int
+
(** Delete all placements intersecting a row (1-based). *)
+
| By_row_and_free of int
+
(** Delete and free by row. *)
+
| By_z_index of int
+
(** Delete all placements with a specific z-index. *)
+
| By_z_index_and_free of int
+
(** Delete and free by z-index. *)
+
| By_id_range of { min_id : int; max_id : int }
+
(** Delete images with IDs in range [min_id, max_id]. *)
+
| By_id_range_and_free of { min_id : int; max_id : int }
+
(** Delete and free by ID range. *)
+
| Frames
+
(** Delete animation frames. *)
+
| Frames_and_free
+
(** Delete animation frames and free if no frames remain. *)
+
end
+
+
(** {1 Placement Options} *)
+
+
(** Image placement configuration.
+
+
Controls how an image is positioned and scaled when displayed. *)
+
module Placement : sig
+
type t
+
(** Placement configuration. *)
+
+
val make :
+
?source_x:int ->
+
?source_y:int ->
+
?source_width:int ->
+
?source_height:int ->
+
?cell_x_offset:int ->
+
?cell_y_offset:int ->
+
?columns:int ->
+
?rows:int ->
+
?z_index:int ->
+
?placement_id:int ->
+
?cursor:Cursor.t ->
+
?unicode_placeholder:bool ->
+
unit ->
+
t
+
(** Create a placement configuration.
+
+
@param source_x Left edge of source rectangle in pixels (default 0)
+
@param source_y Top edge of source rectangle in pixels (default 0)
+
@param source_width Width of source rectangle (default: full width)
+
@param source_height Height of source rectangle (default: full height)
+
@param cell_x_offset X offset within the first cell in pixels
+
@param cell_y_offset Y offset within the first cell in pixels
+
@param columns Number of columns to display over (scales image)
+
@param rows Number of rows to display over (scales image)
+
@param z_index Stacking order (negative = under text)
+
@param placement_id Unique ID for this placement
+
@param cursor Cursor movement policy after display
+
@param unicode_placeholder Create virtual placement for Unicode mode *)
+
+
val empty : t
+
(** Empty placement with all defaults. *)
+
end
+
+
(** {1 Animation} *)
+
+
(** Animation frame specification. *)
+
module Frame : sig
+
type t
+
(** Animation frame configuration. *)
+
+
val make :
+
?x:int ->
+
?y:int ->
+
?base_frame:int ->
+
?edit_frame:int ->
+
?gap_ms:int ->
+
?composition:Composition.t ->
+
?background_color:int32 ->
+
unit ->
+
t
+
(** Create a frame specification.
+
+
@param x Left edge where frame data is placed (pixels)
+
@param y Top edge where frame data is placed (pixels)
+
@param base_frame 1-based frame number to use as background canvas
+
@param edit_frame 1-based frame number to edit (0 = new frame)
+
@param gap_ms Delay before next frame in milliseconds
+
@param composition How to blend pixels onto the canvas
+
@param background_color 32-bit RGBA background when no base frame *)
+
+
val empty : t
+
(** Empty frame spec with defaults. *)
+
end
+
+
(** Animation control operations. *)
+
module Animation : sig
+
type state =
+
| Stop (** Stop the animation *)
+
| Loading (** Run but wait for new frames at end *)
+
| Run (** Run normally, loop at end *)
+
+
type t
+
(** Animation control configuration. *)
+
+
val set_state : ?loops:int -> state -> t
+
(** Set animation state.
+
+
@param loops Number of loops: 0 = ignored, 1 = infinite, n = n-1 loops *)
+
+
val set_gap : frame:int -> gap_ms:int -> t
+
(** Set the gap (delay) for a specific frame.
+
+
@param frame 1-based frame number
+
@param gap_ms Delay in milliseconds (negative = gapless) *)
+
+
val set_current_frame : int -> t
+
(** Make a specific frame (1-based) the current displayed frame. *)
+
end
+
+
(** Frame composition for combining frame regions. *)
+
module Compose : sig
+
type t
+
(** Composition operation. *)
+
+
val make :
+
source_frame:int ->
+
dest_frame:int ->
+
?width:int ->
+
?height:int ->
+
?source_x:int ->
+
?source_y:int ->
+
?dest_x:int ->
+
?dest_y:int ->
+
?composition:Composition.t ->
+
unit ->
+
t
+
(** Compose a rectangle from one frame onto another.
+
+
@param source_frame 1-based source frame number
+
@param dest_frame 1-based destination frame number
+
@param width Rectangle width in pixels (default: full width)
+
@param height Rectangle height in pixels (default: full height)
+
@param source_x Left edge of source rectangle
+
@param source_y Top edge of source rectangle
+
@param dest_x Left edge of destination rectangle
+
@param dest_y Top edge of destination rectangle
+
@param composition Blend mode *)
+
end
+
+
(** {1 Commands} *)
+
+
(** Graphics command builder.
+
+
This is the main API for constructing graphics protocol commands.
+
Commands are built using the various constructors, then written to
+
a buffer with {!write}. *)
+
module Command : sig
+
type t
+
(** A graphics protocol command. *)
+
+
(** {2 Image Transmission} *)
+
+
val transmit :
+
?image_id:int ->
+
?image_number:int ->
+
?format:Format.t ->
+
?transmission:Transmission.t ->
+
?compression:Compression.t ->
+
?width:int ->
+
?height:int ->
+
?size:int ->
+
?offset:int ->
+
?quiet:Quiet.t ->
+
unit ->
+
t
+
(** Transmit image data without displaying.
+
+
@param image_id Unique ID for the image (1-4294967295)
+
@param image_number Image number (terminal assigns ID)
+
@param format Pixel format of the data
+
@param transmission How data is transmitted
+
@param compression Compression applied to data
+
@param width Image width in pixels (required for RGB/RGBA)
+
@param height Image height in pixels (required for RGB/RGBA)
+
@param size Number of bytes to read (for file transmission)
+
@param offset Byte offset to start reading (for file transmission)
+
@param quiet Response suppression mode *)
+
+
val transmit_and_display :
+
?image_id:int ->
+
?image_number:int ->
+
?format:Format.t ->
+
?transmission:Transmission.t ->
+
?compression:Compression.t ->
+
?width:int ->
+
?height:int ->
+
?size:int ->
+
?offset:int ->
+
?quiet:Quiet.t ->
+
?placement:Placement.t ->
+
unit ->
+
t
+
(** Transmit image data and display it immediately.
+
+
This is the most common operation for displaying images.
+
See {!transmit} for transmission parameters and {!Placement}
+
for display options. *)
+
+
val query :
+
?format:Format.t ->
+
?transmission:Transmission.t ->
+
?width:int ->
+
?height:int ->
+
?quiet:Quiet.t ->
+
unit ->
+
t
+
(** Query terminal support without storing the image.
+
+
Send a small test image to check if the terminal supports
+
the graphics protocol. The terminal responds with OK or
+
an error without storing the image. *)
+
+
(** {2 Display} *)
+
+
val display :
+
?image_id:int ->
+
?image_number:int ->
+
?placement:Placement.t ->
+
?quiet:Quiet.t ->
+
unit ->
+
t
+
(** Display a previously transmitted image.
+
+
@param image_id ID of a previously transmitted image
+
@param image_number Number of the image to display
+
@param placement Display placement options
+
@param quiet Response suppression *)
+
+
(** {2 Deletion} *)
+
+
val delete : ?quiet:Quiet.t -> Delete.t -> t
+
(** Delete images or placements.
+
+
See {!Delete} for the various deletion modes. *)
+
+
(** {2 Animation} *)
+
+
val frame :
+
?image_id:int ->
+
?image_number:int ->
+
?format:Format.t ->
+
?transmission:Transmission.t ->
+
?compression:Compression.t ->
+
?width:int ->
+
?height:int ->
+
?quiet:Quiet.t ->
+
frame:Frame.t ->
+
unit ->
+
t
+
(** Transmit animation frame data.
+
+
Similar to {!transmit} but adds frame-specific parameters. *)
+
+
val animate :
+
?image_id:int ->
+
?image_number:int ->
+
?quiet:Quiet.t ->
+
Animation.t ->
+
t
+
(** Control animation playback. *)
+
+
val compose :
+
?image_id:int ->
+
?image_number:int ->
+
?quiet:Quiet.t ->
+
Compose.t ->
+
t
+
(** Compose animation frames. *)
+
+
(** {2 Output} *)
+
+
val write : Buffer.t -> t -> data:string -> unit
+
(** Write the command to a buffer.
+
+
@param data The payload data (image bytes, file path, etc.).
+
For {!display}, {!delete}, {!animate}, pass empty string. *)
+
+
val to_string : t -> data:string -> string
+
(** Convert command to a string. *)
+
end
+
+
(** {1 Response Parsing} *)
+
+
(** Terminal response parsing.
+
+
When the terminal processes a graphics command, it may send back
+
a response indicating success or failure. *)
+
module Response : sig
+
type t
+
(** A parsed terminal response. *)
+
+
val parse : string -> t option
+
(** Parse a response from terminal output.
+
+
Expects the format: [<ESC>_G...;message<ESC>\]
+
Returns [None] if the string is not a valid graphics response. *)
+
+
val is_ok : t -> bool
+
(** Check if the response indicates success. *)
+
+
val message : t -> string
+
(** Get the response message ("OK" or error description). *)
+
+
val error_code : t -> string option
+
(** Extract the error code if this is an error response.
+
+
Error codes include: ENOENT, EINVAL, ENOSPC, EBADPNG, etc. *)
+
+
val image_id : t -> int option
+
(** Get the image ID from the response, if present. *)
+
+
val image_number : t -> int option
+
(** Get the image number from the response, if present. *)
+
+
val placement_id : t -> int option
+
(** Get the placement ID from the response, if present. *)
+
end
+
+
(** {1 Unicode Placeholders} *)
+
+
(** Unicode placeholder generation for tmux/vim compatibility.
+
+
Unicode placeholders allow images to work with applications that
+
don't understand the graphics protocol but support Unicode and
+
foreground colors. The image is transmitted with a virtual placement,
+
then placeholder characters are written to the terminal. *)
+
module Unicode_placeholder : sig
+
val placeholder_char : Uchar.t
+
(** The Unicode placeholder character U+10EEEE. *)
+
+
val write :
+
Buffer.t ->
+
image_id:int ->
+
?placement_id:int ->
+
rows:int ->
+
cols:int ->
+
unit ->
+
unit
+
(** Write placeholder characters to a buffer.
+
+
The image ID is encoded in the foreground color (24-bit mode).
+
Row and column positions are encoded using combining diacritics.
+
+
@param image_id The image ID (should have non-zero bytes for 24-bit)
+
@param placement_id Optional placement ID (encoded in underline color)
+
@param rows Number of rows to fill
+
@param cols Number of columns per row *)
+
+
val row_diacritic : int -> Uchar.t
+
(** Get the combining diacritic for a row number (0-based). *)
+
+
val column_diacritic : int -> Uchar.t
+
(** Get the combining diacritic for a column number (0-based). *)
+
+
val id_high_byte_diacritic : int -> Uchar.t
+
(** Get the diacritic for the high byte of a 32-bit image ID. *)
+
end
+
+
(** {1 Terminal Detection} *)
+
+
(** Helpers for detecting terminal graphics support. *)
+
module Detect : sig
+
val make_query : unit -> string
+
(** Generate a query command to test graphics support.
+
+
Send this to stdout and read the terminal's response.
+
Follow with a DA1 query ([<ESC>[c]) to detect terminals
+
that don't support graphics (they'll answer DA1 but not
+
the graphics query). *)
+
+
val supports_graphics : Response.t option -> da1_received:bool -> bool
+
(** Determine if graphics are supported based on query results.
+
+
@param response The parsed graphics response, if any
+
@param da1_received Whether a DA1 response was received
+
+
Returns [true] if a graphics OK response was received,
+
or [false] if only DA1 was received (no graphics support). *)
+
end
+21 -26
stack/river/lib/feed.ml
···
let src = Logs.Src.create "river" ~doc:"River RSS/Atom aggregator"
module Log = (val Logs.src_log src : Logs.LOG)
-
type feed_content =
-
| Atom of Syndic.Atom.feed
-
| Rss2 of Syndic.Rss2.channel
-
| Json of Jsonfeed.t
+
type feed_content = River_jsonfeed.t
type t = {
source : Source.t;
title : string;
content : feed_content;
+
original_format : string; (* "Atom", "RSS2", or "JSONFeed" *)
}
-
-
let string_of_feed = function
-
| Atom _ -> "Atom"
-
| Rss2 _ -> "Rss2"
-
| Json _ -> "JSONFeed"
let classify_feed ~xmlbase (body : string) =
Log.debug (fun m -> m "Attempting to parse feed (%d bytes)" (String.length body));
···
match Jsonfeed.of_string body with
| Ok jsonfeed ->
Log.debug (fun m -> m "Successfully parsed as JSONFeed");
-
Json jsonfeed
+
(* Wrap plain JSONFeed with River_jsonfeed (no extensions needed) *)
+
let river_jsonfeed = { River_jsonfeed.feed = jsonfeed; extension = None } in
+
(river_jsonfeed, "JSONFeed")
| Error err ->
let err_str = Jsont.Error.to_string err in
Log.debug (fun m -> m "Not a JSONFeed: %s" err_str);
···
) else (
(* Try XML formats *)
try
-
let feed = Atom (Syndic.Atom.parse ~xmlbase (Xmlm.make_input (`String (0, body)))) in
-
Log.debug (fun m -> m "Successfully parsed as Atom feed");
-
feed
+
let atom_feed = Syndic.Atom.parse ~xmlbase (Xmlm.make_input (`String (0, body))) in
+
Log.debug (fun m -> m "Successfully parsed as Atom feed, converting to JSONFeed");
+
(* Convert Atom to JSONFeed with extensions *)
+
let river_jsonfeed = River_jsonfeed.of_atom atom_feed in
+
(river_jsonfeed, "Atom")
with
| Syndic.Atom.Error.Error (pos, msg) -> (
Log.debug (fun m -> m "Not an Atom feed: %s at position (%d, %d)"
msg (fst pos) (snd pos));
try
-
let feed = Rss2 (Syndic.Rss2.parse ~xmlbase (Xmlm.make_input (`String (0, body)))) in
-
Log.debug (fun m -> m "Successfully parsed as RSS2 feed");
-
feed
+
let rss2_channel = Syndic.Rss2.parse ~xmlbase (Xmlm.make_input (`String (0, body))) in
+
Log.debug (fun m -> m "Successfully parsed as RSS2 feed, converting to JSONFeed");
+
(* Convert RSS2 to JSONFeed *)
+
let river_jsonfeed = River_jsonfeed.of_rss2 rss2_channel in
+
(river_jsonfeed, "RSS2")
with Syndic.Rss2.Error.Error (pos, msg) ->
Log.err (fun m -> m "Failed to parse as RSS2: %s at position (%d, %d)"
msg (fst pos) (snd pos));
···
failwith (Printf.sprintf "HTTP %d: %s" status truncated_msg)
in
-
let content =
+
let (content, original_format) =
try classify_feed ~xmlbase response
with Failure msg ->
Log.err (fun m -> m "Failed to parse feed '%s' (%s): %s"
(Source.name source) (Source.url source) msg);
raise (Failure msg)
-
in
-
let title =
-
match content with
-
| Atom atom -> Text_extract.string_of_text_construct atom.Syndic.Atom.title
-
| Rss2 ch -> ch.Syndic.Rss2.title
-
| Json jsonfeed -> Jsonfeed.title jsonfeed
in
-
Log.info (fun m -> m "Successfully fetched %s feed '%s' (title: '%s')"
-
(string_of_feed content) (Source.name source) title);
+
let title = Jsonfeed.title content.River_jsonfeed.feed in
-
{ source; title; content }
+
Log.info (fun m -> m "Successfully fetched %s feed '%s' (title: '%s'), converted to JSONFeed"
+
original_format (Source.name source) title);
+
+
{ source; title; content; original_format }
let source t = t.source
let content t = t.content
+6 -6
stack/river/lib/feed.mli
···
(** Feed fetching and parsing. *)
-
type feed_content =
-
| Atom of Syndic.Atom.feed
-
| Rss2 of Syndic.Rss2.channel
-
| Json of Jsonfeed.t
-
(** The underlying feed content, which can be Atom, RSS2, or JSONFeed format. *)
+
type feed_content = River_jsonfeed.t
+
(** The underlying feed content, stored in JSONFeed format with extensions.
+
+
All feed formats (Atom, RSS2, JSONFeed) are converted to JSONFeed upon
+
fetching. Atom-specific metadata is preserved using extensions. *)
type t
-
(** An Atom, RSS2, or JSON Feed. *)
+
(** A feed, stored natively in JSONFeed format. *)
val fetch : Session.t -> Source.t -> t
(** [fetch session source] fetches and parses a feed from the given source.
+7 -6
stack/river/lib/format.ml
···
module Rss2 = struct
let of_feed feed =
-
match Feed.content feed with
-
| Feed.Rss2 ch -> Some ch
-
| _ -> None
+
(* Feed content is now always JSONFeed - cannot extract RSS2 directly *)
+
(* This function is kept for backwards compatibility but always returns None *)
+
let _ = feed in
+
None
end
module Jsonfeed = struct
···
| Error err -> Error (Jsont.Error.to_string err)
let of_feed feed =
-
match Feed.content feed with
-
| Feed.Json jf -> Some jf
-
| _ -> None
+
(* Feed content is now always River_jsonfeed.t - extract the inner Jsonfeed.t *)
+
let jsonfeed_content = Feed.content feed in
+
Some jsonfeed_content.River_jsonfeed.feed
end
module Html = struct
+22 -36
stack/river/lib/post.ml
···
if is_valid_author_name author.name then trimmed
else raise Not_found (* Try feed-level author *)
with Not_found -> (
-
match Feed.content feed with
-
| Feed.Atom atom_feed -> (
-
(* Try feed-level authors *)
-
match atom_feed.Syndic.Atom.authors with
-
| author :: _ when is_valid_author_name author.name ->
-
String.trim author.name
-
| _ ->
-
(* Use feed title *)
-
Text_extract.string_of_text_construct atom_feed.Syndic.Atom.title)
-
| Feed.Rss2 _ | Feed.Json _ ->
-
(* For RSS2 and JSONFeed, use the source name *)
-
Source.name (Feed.source feed))
+
(* Feed content is now JSONFeed - try feed-level authors *)
+
let jsonfeed_content = Feed.content feed in
+
match Jsonfeed.authors jsonfeed_content.River_jsonfeed.feed with
+
| Some (first :: _) ->
+
let name = Jsonfeed.Author.name first |> Option.value ~default:"" in
+
if is_valid_author_name name then name
+
else Feed.title feed
+
| _ ->
+
(* Use feed title as fallback *)
+
Feed.title feed)
in
(* Extract tags from Atom categories *)
let tags =
···
(name, "")
| _ ->
(* Fall back to feed-level authors or feed title *)
-
(match Feed.content feed with
-
| Feed.Json jsonfeed ->
-
(match Jsonfeed.authors jsonfeed with
-
| Some (first :: _) ->
-
let name = Jsonfeed.Author.name first |> Option.value ~default:(Feed.title feed) in
-
(name, "")
-
| _ -> (Feed.title feed, ""))
+
let jsonfeed_content = Feed.content feed in
+
(match Jsonfeed.authors jsonfeed_content.River_jsonfeed.feed with
+
| Some (first :: _) ->
+
let name = Jsonfeed.Author.name first |> Option.value ~default:(Feed.title feed) in
+
(name, "")
| _ -> (Feed.title feed, ""))
in
···
}
let posts_of_feed c =
-
match Feed.content c with
-
| Feed.Atom f ->
-
let posts = List.map (post_of_atom ~feed:c) f.Syndic.Atom.entries in
-
Log.debug (fun m -> m "Extracted %d posts from Atom feed '%s'"
-
(List.length posts) (Source.name (Feed.source c)));
-
posts
-
| Feed.Rss2 ch ->
-
let posts = List.map (post_of_rss2 ~feed:c) ch.Syndic.Rss2.items in
-
Log.debug (fun m -> m "Extracted %d posts from RSS2 feed '%s'"
-
(List.length posts) (Source.name (Feed.source c)));
-
posts
-
| Feed.Json jsonfeed ->
-
let items = Jsonfeed.items jsonfeed in
-
let posts = List.map (post_of_jsonfeed_item ~feed:c) items in
-
Log.debug (fun m -> m "Extracted %d posts from JSONFeed '%s'"
-
(List.length posts) (Source.name (Feed.source c)));
-
posts
+
(* Feed content is now always JSONFeed *)
+
let jsonfeed_content = Feed.content c in
+
let items = Jsonfeed.items jsonfeed_content.River_jsonfeed.feed in
+
let posts = List.map (post_of_jsonfeed_item ~feed:c) items in
+
Log.debug (fun m -> m "Extracted %d posts from feed '%s' (converted to JSONFeed)"
+
(List.length posts) (Source.name (Feed.source c)));
+
posts
let get_posts ?n ?(ofs = 0) planet_feeds =
Log.info (fun m -> m "Processing %d feeds for posts" (List.length planet_feeds));
+1
stack/river/lib/river.ml
···
module Feed = Feed
module Post = Post
module Format = Format
+
module River_jsonfeed = River_jsonfeed
module Category = Category
module User = User
module Quality = Quality
+122
stack/river/lib/river.mli
···
end
end
+
(** {1 JSONFeed with Atom Extensions} *)
+
+
module River_jsonfeed : sig
+
(** JSONFeed with Atom extension support for River.
+
+
This module provides conversion between Atom feeds and JSONFeed format,
+
with custom extensions to preserve Atom-specific metadata that doesn't
+
have direct JSONFeed equivalents.
+
+
The extensions follow the JSONFeed specification for custom fields:
+
- Prefixed with underscore + letter: [_atom]
+
- Contains [about] field with documentation URL
+
- Feed readers can safely ignore unknown extensions
+
+
See: https://www.jsonfeed.org/mappingrssandatom/ *)
+
+
(** {2 Extension Types} *)
+
+
type category = {
+
term : string; (** Category term (required in Atom) *)
+
scheme : string option; (** Category scheme/domain *)
+
label : string option; (** Human-readable label *)
+
}
+
+
type contributor = {
+
contributor_name : string;
+
contributor_uri : string option;
+
contributor_email : string option;
+
}
+
+
type generator = {
+
generator_name : string; (** Generator name *)
+
generator_uri : string option; (** Generator URI *)
+
generator_version : string option; (** Generator version *)
+
}
+
+
type source = {
+
source_id : string; (** Source feed ID *)
+
source_title : string; (** Source feed title *)
+
source_updated : Ptime.t; (** Source feed update time *)
+
}
+
+
type content_type =
+
| Text (** Plain text *)
+
| Html (** HTML content *)
+
| Xhtml (** XHTML content *)
+
+
type feed_extension = {
+
feed_subtitle : string option;
+
feed_id : string;
+
feed_categories : category list;
+
feed_contributors : contributor list;
+
feed_generator : generator option;
+
feed_rights : string option;
+
feed_logo : string option;
+
}
+
+
type item_extension = {
+
item_id : string;
+
item_published : Ptime.t option;
+
item_contributors : contributor list;
+
item_source : source option;
+
item_rights : string option;
+
item_categories : category list;
+
item_content_type : content_type option;
+
}
+
+
type t = {
+
feed : Jsonfeed.t;
+
extension : feed_extension option;
+
}
+
+
type item = {
+
item : Jsonfeed.Item.t;
+
extension : item_extension option;
+
}
+
+
(** {2 Conversion from Atom} *)
+
+
val of_atom : Syndic.Atom.feed -> t
+
(** [of_atom feed] converts an Atom feed to JSONFeed with extensions.
+
+
All Atom metadata is preserved using extensions. *)
+
+
val item_of_atom : Syndic.Atom.entry -> item
+
(** [item_of_atom entry] converts an Atom entry to JSONFeed item with extensions. *)
+
+
(** {2 Conversion from RSS} *)
+
+
val of_rss2 : Syndic.Rss2.channel -> t
+
(** [of_rss2 channel] converts an RSS2 channel to JSONFeed. *)
+
+
val item_of_rss2 : Syndic.Rss2.item -> item
+
(** [item_of_rss2 item] converts an RSS2 item to JSONFeed item. *)
+
+
(** {2 Conversion to Atom} *)
+
+
val to_atom : t -> Syndic.Atom.feed
+
(** [to_atom t] converts JSONFeed with extensions back to Atom feed.
+
+
All original Atom metadata is restored from extensions. *)
+
+
val item_to_atom : item -> Syndic.Atom.entry
+
(** [item_to_atom item] converts JSONFeed item with extensions back to Atom entry. *)
+
+
(** {2 Serialization} *)
+
+
val to_string : ?minify:bool -> t -> (string, string) result
+
(** [to_string ?minify t] serializes to JSON string with extensions. *)
+
+
val of_string : string -> (t, string) result
+
(** [of_string s] parses JSON string with extensions. *)
+
+
(** {2 Utilities} *)
+
+
val of_posts : title:string -> Post.t list -> t
+
(** [of_posts ~title posts] creates JSONFeed from Post list with Atom extensions. *)
+
+
val to_posts : feed:Feed.t -> t -> Post.t list
+
(** [to_posts ~feed t] extracts posts from extended JSONFeed. *)
+
end
+
(** {1 Category Management} *)
module Category : sig
+93 -15
stack/river/lib/state.ml
···
(** Get the sync state file path *)
let sync_state_file state = Eio.Path.(Xdge.state_dir state.xdg / "sync_state.json")
-
(** Get the path to a user's Atom feed file *)
+
(** Get the path to a user's JSONFeed file *)
let user_feed_file state username =
+
Eio.Path.(user_feeds_dir state / (username ^ ".json"))
+
+
(** Get the path to a user's old Atom feed file (for migration) *)
+
let user_feed_file_legacy state username =
Eio.Path.(user_feeds_dir state / (username ^ ".xml"))
(** Ensure all necessary directories exist *)
···
Log.err (fun m -> m "Error getting all users: %s" (Printexc.to_string e));
[]
-
(** Load existing Atom entries for a user *)
-
let load_existing_posts state username =
-
let file = Paths.user_feed_file state username in
+
(** Migrate legacy Atom XML feed to JSONFeed format *)
+
let migrate_legacy_feed state username =
+
let legacy_file = Paths.user_feed_file_legacy state username in
try
-
let content = Eio.Path.load file in
+
let content = Eio.Path.load legacy_file in
+
Log.info (fun m -> m "Migrating legacy Atom feed for %s to JSONFeed" username);
(* Parse existing Atom feed *)
let input = Xmlm.make_input (`String (0, content)) in
-
let feed = Syndic.Atom.parse input in
-
feed.Syndic.Atom.entries
+
let atom_feed = Syndic.Atom.parse input in
+
(* Convert to JSONFeed with extensions *)
+
let jsonfeed = River_jsonfeed.of_atom atom_feed in
+
(* Save as JSONFeed *)
+
let json_file = Paths.user_feed_file state username in
+
(match River_jsonfeed.to_string ~minify:false jsonfeed with
+
| Ok json ->
+
Eio.Path.save ~create:(`Or_truncate 0o644) json_file json;
+
Log.info (fun m -> m "Successfully migrated %s from Atom to JSONFeed" username);
+
(* Rename legacy file to .xml.backup *)
+
let backup_file = Eio.Path.(Paths.user_feeds_dir state / (username ^ ".xml.backup")) in
+
(try
+
Eio.Path.save ~create:(`Or_truncate 0o644) backup_file content;
+
Log.info (fun m -> m "Backed up legacy Atom file to %s.xml.backup" username)
+
with e ->
+
Log.warn (fun m -> m "Failed to backup legacy file: %s" (Printexc.to_string e)));
+
Some jsonfeed
+
| Error err ->
+
Log.err (fun m -> m "Failed to serialize JSONFeed during migration: %s" err);
+
None)
with
-
| Eio.Io (Eio.Fs.E (Not_found _), _) -> []
+
| Eio.Io (Eio.Fs.E (Not_found _), _) -> None
| e ->
-
Log.err (fun m -> m "Error loading existing posts for %s: %s"
+
Log.err (fun m -> m "Error migrating legacy feed for %s: %s"
username (Printexc.to_string e));
-
[]
+
None
-
(** Save Atom entries for a user *)
-
let save_atom_feed state username entries =
+
(** Load existing JSONFeed for a user (with legacy migration support) *)
+
let load_existing_feed state username =
let file = Paths.user_feed_file state username in
-
let feed = Format.Atom.feed_of_entries ~title:username entries in
-
let xml = Format.Atom.to_string feed in
-
Eio.Path.save ~create:(`Or_truncate 0o644) file xml
+
try
+
let content = Eio.Path.load file in
+
(* Parse JSONFeed *)
+
match River_jsonfeed.of_string content with
+
| Ok jsonfeed -> Some jsonfeed
+
| Error err ->
+
Log.err (fun m -> m "Failed to parse JSONFeed for %s: %s" username err);
+
(* Try migration from legacy Atom *)
+
migrate_legacy_feed state username
+
with
+
| Eio.Io (Eio.Fs.E (Not_found _), _) ->
+
(* JSON file not found, try legacy migration *)
+
migrate_legacy_feed state username
+
| e ->
+
Log.err (fun m -> m "Error loading feed for %s: %s"
+
username (Printexc.to_string e));
+
None
+
+
(** Load existing posts as Atom entries for a user (for backwards compatibility) *)
+
let load_existing_posts state username =
+
match load_existing_feed state username with
+
| None -> []
+
| Some jsonfeed ->
+
(* Convert JSONFeed back to Atom for backwards compatibility *)
+
let atom_feed = River_jsonfeed.to_atom jsonfeed in
+
atom_feed.Syndic.Atom.entries
+
+
(** Save JSONFeed for a user *)
+
let save_jsonfeed state username jsonfeed =
+
let file = Paths.user_feed_file state username in
+
match River_jsonfeed.to_string ~minify:false jsonfeed with
+
| Ok json -> Eio.Path.save ~create:(`Or_truncate 0o644) file json
+
| Error err -> failwith ("Failed to serialize JSONFeed: " ^ err)
+
+
(** Save Atom entries for a user (converts to JSONFeed first) *)
+
let save_atom_feed state username entries =
+
(* Convert Atom entries to JSONFeed with extensions *)
+
let items_with_ext = List.map River_jsonfeed.item_of_atom entries in
+
let items = List.map (fun i -> i.River_jsonfeed.item) items_with_ext in
+
+
(* Create feed extension *)
+
let feed_ext = {
+
River_jsonfeed.feed_subtitle = None;
+
feed_id = "urn:river:user:" ^ username;
+
feed_categories = [];
+
feed_contributors = [];
+
feed_generator = Some {
+
River_jsonfeed.generator_name = "River Feed Aggregator";
+
generator_uri = None;
+
generator_version = Some "1.0";
+
};
+
feed_rights = None;
+
feed_logo = None;
+
} in
+
+
let jsonfeed_inner = Jsonfeed.create ~title:username ~items () in
+
let jsonfeed = { River_jsonfeed.feed = jsonfeed_inner; extension = Some feed_ext } in
+
save_jsonfeed state username jsonfeed
end
module Sync = struct