Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/plans/pi-adoption-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
89 changes: 89 additions & 0 deletions internal/tui/kitty/kitty.go
Original file line number Diff line number Diff line change
@@ -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=<supported>`, `v=<version>`, `a=<abort>`. 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()
}
101 changes: 101 additions & 0 deletions internal/tui/kitty/kitty_test.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading