From dfcad577acab163524f6543618e6f23ab40e3013 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 22 Aug 2026 13:27:11 +0530 Subject: [PATCH] feat(pi): add Kitty graphics library --- docs/plans/pi-adoption-plan.md | 6 +- internal/tui/kitty/kitty.go | 89 +++++++++++++++++++++++++++ internal/tui/kitty/kitty_test.go | 101 +++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 internal/tui/kitty/kitty.go create mode 100644 internal/tui/kitty/kitty_test.go diff --git a/docs/plans/pi-adoption-plan.md b/docs/plans/pi-adoption-plan.md index d2913ebd..69155096 100644 --- a/docs/plans/pi-adoption-plan.md +++ b/docs/plans/pi-adoption-plan.md @@ -334,8 +334,10 @@ updating the Hawk pointer. ### Milestone 5: Kitty graphics (P2) -- [ ] Deferred: requires the differential renderer integration and image - handling; tracked after the render-path follow-up. +- [x] Add the Kitty graphics library (`internal/tui/kitty`): capability-response + parsing and chunked frame encoding with round-trip tests. +- [ ] Render-loop integration (wiring encoded frames into the terminal + renderer) remains a follow-up. ## Deliberately Deferred diff --git a/internal/tui/kitty/kitty.go b/internal/tui/kitty/kitty.go new file mode 100644 index 00000000..b0dd2dda --- /dev/null +++ b/internal/tui/kitty/kitty.go @@ -0,0 +1,89 @@ +// Package kitty implements the Kitty graphics protocol for terminal images. +// +// It provides: +// - capability response parsing (whether the terminal supports Kitty +// graphics), and +// - chunked frame encoding that emits the APC graphics transmission sequence. +// +// The package is self-contained and unit-testable; wiring the encoded frames +// into a terminal render loop (e.g. the differential renderer in internal/tui/ +// diff) is the documented follow-up. +package kitty + +import ( + "encoding/base64" + "fmt" + "strings" +) + +// Format identifies the raster encoding used in a frame transmission. +type Format int + +const ( + // FormatRGB is a raw 24-bit RGB raster. + FormatRGB Format = 24 + // FormatRGBA is a raw 32-bit RGBA raster. + FormatRGBA Format = 32 + // FormatPNG is a PNG-compressed image. + FormatPNG Format = 100 +) + +// DefaultChunkSize bounds the base64 payload per transmission chunk, keeping +// individual writes small enough for terminals to buffer without truncation. +const DefaultChunkSize = 4096 + +// responseMarker is the APC wrapper that terminates a capability query/response. +const ( + apcStart = "\x1b_G" + apcEnd = "\x1b\\" +) + +// ParseCapabilityResponse reports whether a terminal's capability response +// advertises Kitty graphics support. The terminal replies to the query +// `\x1b_Gi=31,s=1,v=1,m=0` with a `\x1b_Gi=31;...` response; support is +// indicated by `OK` plus `s=1` (transmission) and `v=1` (version 1). +func ParseCapabilityResponse(data string) bool { + if !strings.Contains(data, "i=31") { + return false + } + if !strings.Contains(data, "OK") { + return false + } + // Kitty responds with `s=`, `v=`, `a=`. A + // supported terminal reports s=1 (and usually v=1); a 1 is a positive + // capability flag regardless of the version number. + return strings.Contains(data, "s=1") +} + +// EncodeFrame renders one image frame as a chunked Kitty graphics transmission +// sequence. data is the raster bytes (raw RGB/RGBA or PNG depending on format), +// width/height its dimensions. The returned string is ready to write to the +// terminal. +func EncodeFrame(f Format, width, height int, data []byte) string { + payload := base64.StdEncoding.EncodeToString(data) + var b strings.Builder + first := true + for len(payload) > 0 { + chunk := payload + if len(chunk) > DefaultChunkSize { + chunk = chunk[:DefaultChunkSize] + } + payload = payload[len(chunk):] + + // m=1 signals more chunks follow; m=0 marks the final chunk. + m := 0 + if len(payload) > 0 { + m = 1 + } + b.WriteString(apcStart) + if first { + fmt.Fprintf(&b, "a=T,f=%d,s=%d,v=%d,m=%d;", int(f), width, height, m) + first = false + } else { + fmt.Fprintf(&b, "m=%d;", m) + } + b.WriteString(chunk) + b.WriteString(apcEnd) + } + return b.String() +} diff --git a/internal/tui/kitty/kitty_test.go b/internal/tui/kitty/kitty_test.go new file mode 100644 index 00000000..1db1e553 --- /dev/null +++ b/internal/tui/kitty/kitty_test.go @@ -0,0 +1,101 @@ +package kitty + +import ( + "encoding/base64" + "strings" + "testing" +) + +func TestParseCapabilityResponseSupported(t *testing.T) { + if !ParseCapabilityResponse("\x1b_Gi=31;OK,s=1,v=1,m=0") { + t.Fatal("supported response must be recognized") + } +} + +func TestParseCapabilityResponseUnsupported(t *testing.T) { + cases := []string{ + "\x1b_Gi=31;NO", + "\x1b_Gi=31;OK,s=0,v=1,m=0", + "no kitty response", + "", + } + for _, c := range cases { + if ParseCapabilityResponse(c) { + t.Fatalf("response %q must not be recognized as supported", c) + } + } +} + +func TestEncodeFrameSingleChunk(t *testing.T) { + // A tiny payload fits in one chunk. + data := []byte{1, 2, 3, 4} + out := EncodeFrame(FormatRGBA, 2, 2, data) + if !strings.HasPrefix(out, apcStart) || !strings.HasSuffix(out, apcEnd) { + t.Fatal("frame must be APC-wrapped") + } + if !strings.Contains(out, "a=T") { + t.Fatal("frame must use transmit action") + } + if !strings.Contains(out, "f=32") || !strings.Contains(out, "s=2") || !strings.Contains(out, "v=2") { + t.Fatalf("frame must carry format/size metadata, got %q", out) + } + if !strings.Contains(out, "m=0;") { + t.Fatal("final chunk must set m=0") + } +} + +func TestEncodeFrameChunks(t *testing.T) { + // A payload larger than DefaultChunkSize forces multiple chunks. + data := make([]byte, DefaultChunkSize*2+100) + out := EncodeFrame(FormatRGB, 10, 10, data) + if strings.Count(out, apcStart) < 2 { + t.Fatalf("large frame must be split into multiple chunks, got %d APC starts", strings.Count(out, apcStart)) + } + if !strings.Contains(out, "m=1;") { + t.Fatal("intermediate chunks must set m=1") + } + if !strings.Contains(out, "m=0;") { + t.Fatal("final chunk must set m=0") + } +} + +func TestEncodeFrameRoundTripPayload(t *testing.T) { + // The concatenated base64 chunks must decode back to the original bytes. + data := []byte("hello kitty graphics payload") + out := EncodeFrame(FormatPNG, 1, 1, data) + encoded := extractBase64(out) + if encoded == "" { + t.Fatal("no payload found") + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatal(err) + } + if string(decoded) != string(data) { + t.Fatalf("round-trip mismatch: got %q want %q", decoded, data) + } +} + +// extractBase64 strips the APC wrappers and metadata controls from an encoded +// frame, returning the concatenated base64 payload. +func extractBase64(frame string) string { + var out strings.Builder + rest := frame + for len(rest) > 0 { + start := strings.Index(rest, apcStart) + if start < 0 { + break + } + rest = rest[start+len(apcStart):] + end := strings.Index(rest, apcEnd) + if end < 0 { + break + } + chunk := rest[:end] + rest = rest[end+len(apcEnd):] + if semi := strings.Index(chunk, ";"); semi >= 0 { + out.WriteString(chunk[semi+1:]) + } + } + return out.String() +}