detached 1a24a20

fix: correct image MIME by content and re-encode for kitty

read tool and Anthropic builder derived an image's media type from its
file extension, so a .png file containing JPEG bytes was declared
image/png. Anthropic sniffs the real bytes and 400s the whole request
on a mismatch, breaking the session and making it impossible to resume.

- read tool now sniffs the real format from magic bytes (sniffImageMIME)
- Anthropic request builder reconciles declared MIME against the actual
  bytes on every outbound image, repairing already-persisted sessions on
  continue/resume
- kitty renderer re-encodes non-PNG images to PNG (f=100 is PNG-only),
  fixing empty image boxes for JPEG/GIF screenshots
commit
1a24a204b48f74e266247474adc42863929daeae
author
patriceckhart · · 2026-06-20 16:18 UTC
parent
0250ce1
signature
unsigned
refs
tag: v0.2.41
6 files · +216 -1
M packages/agent/tools/read.go +31 -0
@@ -2,6 +2,7 @@
22 package tools
33
44 import (
5+ "bytes"
56 "context"
67 "encoding/json"
78 "fmt"
@@ -69,6 +70,16 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
6970 if err != nil {
7071 return core.ToolResult{}, err
7172 }
73+ // The extension is only a hint. Files are routinely mislabeled
74+ // (a .png that is actually JPEG bytes, a renamed download, an
75+ // editor that re-encoded on save). Anthropic and other providers
76+ // sniff the real bytes and reject the whole request when the
77+ // declared media type disagrees, which would break the session.
78+ // Always derive the MIME from the actual content; fall back to
79+ // the extension-based guess only when the bytes are unrecognized.
80+ if sniffed := sniffImageMIME(data); sniffed != "" {
81+ mime = sniffed
82+ }
7283 return core.ToolResult{
7384 Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}},
7485 }, nil
@@ -180,6 +191,26 @@ func imageMIME(path string) string {
180191 return ""
181192 }
182193
194+// sniffImageMIME inspects the leading bytes of an image file and
195+// returns the real media type, independent of the file's extension.
196+// Providers validate the declared media type against the actual bytes
197+// and 400 the whole request on a mismatch, so the extension can never
198+// be trusted. Returns "" when the format is not one zot ships images
199+// for, leaving the caller's extension-based guess in place.
200+func sniffImageMIME(data []byte) string {
201+ switch {
202+ case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}):
203+ return "image/png"
204+ case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
205+ return "image/jpeg"
206+ case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))):
207+ return "image/gif"
208+ case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
209+ return "image/webp"
210+ }
211+ return ""
212+}
213+
183214 // looksBinary returns true if the buffer contains a NUL byte in its first 8 KiB.
184215 func looksBinary(b []byte) bool {
185216 n := len(b)
@@ -2,6 +2,7 @@
2 package tools2 package tools
3 3
4 import (4 import (
5+ "bytes"
5 "context"6 "context"
6 "encoding/json"7 "encoding/json"
7 "fmt"8 "fmt"
@@ -69,6 +70,16 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
69 if err != nil {70 if err != nil {
70 return core.ToolResult{}, err71 return core.ToolResult{}, err
71 }72 }
73+ // The extension is only a hint. Files are routinely mislabeled
74+ // (a .png that is actually JPEG bytes, a renamed download, an
75+ // editor that re-encoded on save). Anthropic and other providers
76+ // sniff the real bytes and reject the whole request when the
77+ // declared media type disagrees, which would break the session.
78+ // Always derive the MIME from the actual content; fall back to
79+ // the extension-based guess only when the bytes are unrecognized.
80+ if sniffed := sniffImageMIME(data); sniffed != "" {
81+ mime = sniffed
82+ }
72 return core.ToolResult{83 return core.ToolResult{
73 Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}},84 Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}},
74 }, nil85 }, nil
@@ -180,6 +191,26 @@ func imageMIME(path string) string {
180 return ""191 return ""
181 }192 }
182 193
194+// sniffImageMIME inspects the leading bytes of an image file and
195+// returns the real media type, independent of the file's extension.
196+// Providers validate the declared media type against the actual bytes
197+// and 400 the whole request on a mismatch, so the extension can never
198+// be trusted. Returns "" when the format is not one zot ships images
199+// for, leaving the caller's extension-based guess in place.
200+func sniffImageMIME(data []byte) string {
201+ switch {
202+ case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}):
203+ return "image/png"
204+ case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
205+ return "image/jpeg"
206+ case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))):
207+ return "image/gif"
208+ case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
209+ return "image/webp"
210+ }
211+ return ""
212+}
213+
183 // looksBinary returns true if the buffer contains a NUL byte in its first 8 KiB.214 // looksBinary returns true if the buffer contains a NUL byte in its first 8 KiB.
184 func looksBinary(b []byte) bool {215 func looksBinary(b []byte) bool {
185 n := len(b)216 n := len(b)
M packages/agent/tools/tools_test.go +24 -0
@@ -38,6 +38,30 @@ func TestReadText(t *testing.T) {
3838 }
3939 }
4040
41+func TestReadImageMimeFromContentNotExtension(t *testing.T) {
42+ // A file named .png whose bytes are actually JPEG. The MIME must be
43+ // sniffed from the content (image/jpeg), not the extension, or
44+ // providers that validate the declared media type reject the request.
45+ dir := t.TempDir()
46+ p := filepath.Join(dir, "shot.png")
47+ jpegBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
48+ if err := os.WriteFile(p, jpegBytes, 0o644); err != nil {
49+ t.Fatal(err)
50+ }
51+ tool := &ReadTool{CWD: dir}
52+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "shot.png"}), nil)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ img, ok := res.Content[0].(provider.ImageBlock)
57+ if !ok {
58+ t.Fatalf("expected ImageBlock, got %T", res.Content[0])
59+ }
60+ if img.MimeType != "image/jpeg" {
61+ t.Fatalf("mime from extension not corrected: got %s want image/jpeg", img.MimeType)
62+ }
63+}
64+
4165 func TestReadOffsetLimit(t *testing.T) {
4266 dir := t.TempDir()
4367 p := filepath.Join(dir, "a.txt")
@@ -38,6 +38,30 @@ func TestReadText(t *testing.T) {
38 }38 }
39 }39 }
40 40
41+func TestReadImageMimeFromContentNotExtension(t *testing.T) {
42+ // A file named .png whose bytes are actually JPEG. The MIME must be
43+ // sniffed from the content (image/jpeg), not the extension, or
44+ // providers that validate the declared media type reject the request.
45+ dir := t.TempDir()
46+ p := filepath.Join(dir, "shot.png")
47+ jpegBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
48+ if err := os.WriteFile(p, jpegBytes, 0o644); err != nil {
49+ t.Fatal(err)
50+ }
51+ tool := &ReadTool{CWD: dir}
52+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "shot.png"}), nil)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ img, ok := res.Content[0].(provider.ImageBlock)
57+ if !ok {
58+ t.Fatalf("expected ImageBlock, got %T", res.Content[0])
59+ }
60+ if img.MimeType != "image/jpeg" {
61+ t.Fatalf("mime from extension not corrected: got %s want image/jpeg", img.MimeType)
62+ }
63+}
64+
41 func TestReadOffsetLimit(t *testing.T) {65 func TestReadOffsetLimit(t *testing.T) {
42 dir := t.TempDir()66 dir := t.TempDir()
43 p := filepath.Join(dir, "a.txt")67 p := filepath.Join(dir, "a.txt")
M packages/provider/anthropic_image.go +31 -0
@@ -24,6 +24,24 @@ import (
2424 // kinds of screenshots / charts the model usually consumes.
2525 const anthMaxImageSide = 2000
2626
27+// anthSniffImageMIME returns the media type implied by an image's
28+// leading magic bytes, independent of any declared type or stdlib
29+// decoder registration. Returns "" when the signature is unrecognized,
30+// leaving the caller's declared MIME untouched.
31+func anthSniffImageMIME(data []byte) string {
32+ switch {
33+ case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}):
34+ return "image/png"
35+ case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
36+ return "image/jpeg"
37+ case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))):
38+ return "image/gif"
39+ case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
40+ return "image/webp"
41+ }
42+ return ""
43+}
44+
2745 // anthShrinkImageBytesIfTooBig returns data unchanged when the image
2846 // already fits within Anthropic's per-image dimension cap. When it
2947 // doesn't, the image is decoded, resampled with Catmull-Rom (a good
@@ -43,6 +61,19 @@ func anthShrinkImageBytesIfTooBig(data []byte, mime string) ([]byte, string) {
4361 if len(data) == 0 {
4462 return data, mime
4563 }
64+ // Reconcile the declared media type with the actual bytes before
65+ // anything else. Callers can mislabel images (a .png that is really
66+ // JPEG, an extension that hardcodes a type), and so can already-
67+ // persisted session transcripts created before this fix existed.
68+ // Anthropic rejects the whole request when the declared type
69+ // disagrees with the bytes it sniffs, which would make such a
70+ // session impossible to continue. Sniffing the magic bytes here
71+ // (independent of stdlib decoder registration, and resilient to a
72+ // DecodeConfig failure below) makes the mismatch impossible to ship
73+ // and lets a previously-broken session resume cleanly.
74+ if real := anthSniffImageMIME(data); real != "" {
75+ mime = real
76+ }
4677 cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
4778 if err != nil {
4879 return data, mime
@@ -24,6 +24,24 @@ import (
24 // kinds of screenshots / charts the model usually consumes.24 // kinds of screenshots / charts the model usually consumes.
25 const anthMaxImageSide = 200025 const anthMaxImageSide = 2000
26 26
27+// anthSniffImageMIME returns the media type implied by an image's
28+// leading magic bytes, independent of any declared type or stdlib
29+// decoder registration. Returns "" when the signature is unrecognized,
30+// leaving the caller's declared MIME untouched.
31+func anthSniffImageMIME(data []byte) string {
32+ switch {
33+ case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}):
34+ return "image/png"
35+ case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
36+ return "image/jpeg"
37+ case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))):
38+ return "image/gif"
39+ case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
40+ return "image/webp"
41+ }
42+ return ""
43+}
44+
27 // anthShrinkImageBytesIfTooBig returns data unchanged when the image45 // anthShrinkImageBytesIfTooBig returns data unchanged when the image
28 // already fits within Anthropic's per-image dimension cap. When it46 // already fits within Anthropic's per-image dimension cap. When it
29 // doesn't, the image is decoded, resampled with Catmull-Rom (a good47 // doesn't, the image is decoded, resampled with Catmull-Rom (a good
@@ -43,6 +61,19 @@ func anthShrinkImageBytesIfTooBig(data []byte, mime string) ([]byte, string) {
43 if len(data) == 0 {61 if len(data) == 0 {
44 return data, mime62 return data, mime
45 }63 }
64+ // Reconcile the declared media type with the actual bytes before
65+ // anything else. Callers can mislabel images (a .png that is really
66+ // JPEG, an extension that hardcodes a type), and so can already-
67+ // persisted session transcripts created before this fix existed.
68+ // Anthropic rejects the whole request when the declared type
69+ // disagrees with the bytes it sniffs, which would make such a
70+ // session impossible to continue. Sniffing the magic bytes here
71+ // (independent of stdlib decoder registration, and resilient to a
72+ // DecodeConfig failure below) makes the mismatch impossible to ship
73+ // and lets a previously-broken session resume cleanly.
74+ if real := anthSniffImageMIME(data); real != "" {
75+ mime = real
76+ }
46 cfg, format, err := image.DecodeConfig(bytes.NewReader(data))77 cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
47 if err != nil {78 if err != nil {
48 return data, mime79 return data, mime
M packages/provider/anthropic_image_test.go +36 -0
@@ -60,6 +60,42 @@ func TestAnthShrinkImage_PassesThroughWhenSmall(t *testing.T) {
6060 }
6161 }
6262
63+func TestAnthShrinkImage_CorrectsMislabeledMimeWhenSmall(t *testing.T) {
64+ // A JPEG that fits within the cap but is wrongly declared as PNG.
65+ // Anthropic 400s on the mismatch, so the builder must rewrite the
66+ // declared media type to match the bytes even without resizing.
67+ src := encodeJPEG(t, makeRect(800, 600))
68+ out, mime := anthShrinkImageBytesIfTooBig(src, "image/png")
69+ if !bytes.Equal(out, src) {
70+ t.Errorf("small image bytes were rewritten; expected pass-through")
71+ }
72+ if mime != "image/jpeg" {
73+ t.Errorf("mislabeled mime not corrected: got %s want image/jpeg", mime)
74+ }
75+}
76+
77+func TestAnthBuildToolResultContent_RepairsMislabeledImageOnResume(t *testing.T) {
78+ // Simulates continuing a session whose transcript already carries a
79+ // tool_result image with the wrong declared media type (.png name,
80+ // JPEG bytes). The outbound request builder must rewrite the media
81+ // type to match the bytes so Anthropic accepts the resumed request.
82+ jpegBytes := encodeJPEG(t, makeRect(64, 64))
83+ blocks := []Content{
84+ TextBlock{Text: "screenshot"},
85+ ImageBlock{MimeType: "image/png", Data: jpegBytes},
86+ }
87+ raw, err := anthBuildToolResultContent(blocks)
88+ if err != nil {
89+ t.Fatalf("build tool result: %v", err)
90+ }
91+ if !bytes.Contains(raw, []byte(`"media_type":"image/jpeg"`)) {
92+ t.Fatalf("media type not repaired in outbound request: %s", raw)
93+ }
94+ if bytes.Contains(raw, []byte(`"media_type":"image/png"`)) {
95+ t.Fatalf("stale image/png media type still present: %s", raw)
96+ }
97+}
98+
6399 func TestAnthShrinkImage_DownscalesWhenTooWide(t *testing.T) {
64100 src := encodePNG(t, makeRect(4000, 1000))
65101 out, mime := anthShrinkImageBytesIfTooBig(src, "image/png")
@@ -60,6 +60,42 @@ func TestAnthShrinkImage_PassesThroughWhenSmall(t *testing.T) {
60 }60 }
61 }61 }
62 62
63+func TestAnthShrinkImage_CorrectsMislabeledMimeWhenSmall(t *testing.T) {
64+ // A JPEG that fits within the cap but is wrongly declared as PNG.
65+ // Anthropic 400s on the mismatch, so the builder must rewrite the
66+ // declared media type to match the bytes even without resizing.
67+ src := encodeJPEG(t, makeRect(800, 600))
68+ out, mime := anthShrinkImageBytesIfTooBig(src, "image/png")
69+ if !bytes.Equal(out, src) {
70+ t.Errorf("small image bytes were rewritten; expected pass-through")
71+ }
72+ if mime != "image/jpeg" {
73+ t.Errorf("mislabeled mime not corrected: got %s want image/jpeg", mime)
74+ }
75+}
76+
77+func TestAnthBuildToolResultContent_RepairsMislabeledImageOnResume(t *testing.T) {
78+ // Simulates continuing a session whose transcript already carries a
79+ // tool_result image with the wrong declared media type (.png name,
80+ // JPEG bytes). The outbound request builder must rewrite the media
81+ // type to match the bytes so Anthropic accepts the resumed request.
82+ jpegBytes := encodeJPEG(t, makeRect(64, 64))
83+ blocks := []Content{
84+ TextBlock{Text: "screenshot"},
85+ ImageBlock{MimeType: "image/png", Data: jpegBytes},
86+ }
87+ raw, err := anthBuildToolResultContent(blocks)
88+ if err != nil {
89+ t.Fatalf("build tool result: %v", err)
90+ }
91+ if !bytes.Contains(raw, []byte(`"media_type":"image/jpeg"`)) {
92+ t.Fatalf("media type not repaired in outbound request: %s", raw)
93+ }
94+ if bytes.Contains(raw, []byte(`"media_type":"image/png"`)) {
95+ t.Fatalf("stale image/png media type still present: %s", raw)
96+ }
97+}
98+
63 func TestAnthShrinkImage_DownscalesWhenTooWide(t *testing.T) {99 func TestAnthShrinkImage_DownscalesWhenTooWide(t *testing.T) {
64 src := encodePNG(t, makeRect(4000, 1000))100 src := encodePNG(t, makeRect(4000, 1000))
65 out, mime := anthShrinkImageBytesIfTooBig(src, "image/png")101 out, mime := anthShrinkImageBytesIfTooBig(src, "image/png")
M packages/tui/image.go +28 -1
@@ -7,7 +7,7 @@ import (
77 "image"
88 _ "image/gif"
99 _ "image/jpeg"
10- _ "image/png"
10+ "image/png"
1111 "math"
1212 "os"
1313 "strconv"
@@ -134,6 +134,27 @@ func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string {
134134 return sb.String()
135135 }
136136
137+// kittyEnsurePNG returns data unchanged when it is already a PNG, and
138+// otherwise decodes and re-encodes it to PNG so the Kitty graphics
139+// protocol (f=100, PNG-only) can render it. On any decode/encode error
140+// the original bytes are returned untouched: the worst case is the
141+// pre-existing empty-box behaviour rather than a panic or a broken
142+// escape, and ImageDimensions still works off the original bytes.
143+func kittyEnsurePNG(data []byte) []byte {
144+ if len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}) {
145+ return data
146+ }
147+ img, _, err := image.Decode(bytes.NewReader(data))
148+ if err != nil {
149+ return data
150+ }
151+ var buf bytes.Buffer
152+ if err := png.Encode(&buf, img); err != nil {
153+ return data
154+ }
155+ return buf.Bytes()
156+}
157+
137158 // renderKitty builds a Kitty graphics protocol sequence. Supports chunked
138159 // data via the "m" continuation flag; chunk size is 4096 to stay under
139160 // terminal escape-buffer limits.
@@ -145,6 +166,12 @@ func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string {
145166 //
146167 // Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/
147168 func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string {
169+ // The Kitty graphics protocol with f=100 expects a PNG payload; it
170+ // has no JPEG/GIF decoder. Feeding it non-PNG bytes makes kitty/
171+ // ghostty reserve the cell rectangle but paint nothing, leaving an
172+ // empty box. Re-encode anything that is not already PNG to PNG so
173+ // the image actually renders regardless of its source format.
174+ data = kittyEnsurePNG(data)
148175 b64 := base64.StdEncoding.EncodeToString(data)
149176 const chunk = 4096
150177 var sb strings.Builder
@@ -7,7 +7,7 @@ import (
7 "image"7 "image"
8 _ "image/gif"8 _ "image/gif"
9 _ "image/jpeg"9 _ "image/jpeg"
10- _ "image/png"10+ "image/png"
11 "math"11 "math"
12 "os"12 "os"
13 "strconv"13 "strconv"
@@ -134,6 +134,27 @@ func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string {
134 return sb.String()134 return sb.String()
135 }135 }
136 136
137+// kittyEnsurePNG returns data unchanged when it is already a PNG, and
138+// otherwise decodes and re-encodes it to PNG so the Kitty graphics
139+// protocol (f=100, PNG-only) can render it. On any decode/encode error
140+// the original bytes are returned untouched: the worst case is the
141+// pre-existing empty-box behaviour rather than a panic or a broken
142+// escape, and ImageDimensions still works off the original bytes.
143+func kittyEnsurePNG(data []byte) []byte {
144+ if len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}) {
145+ return data
146+ }
147+ img, _, err := image.Decode(bytes.NewReader(data))
148+ if err != nil {
149+ return data
150+ }
151+ var buf bytes.Buffer
152+ if err := png.Encode(&buf, img); err != nil {
153+ return data
154+ }
155+ return buf.Bytes()
156+}
157+
137 // renderKitty builds a Kitty graphics protocol sequence. Supports chunked158 // renderKitty builds a Kitty graphics protocol sequence. Supports chunked
138 // data via the "m" continuation flag; chunk size is 4096 to stay under159 // data via the "m" continuation flag; chunk size is 4096 to stay under
139 // terminal escape-buffer limits.160 // terminal escape-buffer limits.
@@ -145,6 +166,12 @@ func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string {
145 //166 //
146 // Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/167 // Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/
147 func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string {168 func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string {
169+ // The Kitty graphics protocol with f=100 expects a PNG payload; it
170+ // has no JPEG/GIF decoder. Feeding it non-PNG bytes makes kitty/
171+ // ghostty reserve the cell rectangle but paint nothing, leaving an
172+ // empty box. Re-encode anything that is not already PNG to PNG so
173+ // the image actually renders regardless of its source format.
174+ data = kittyEnsurePNG(data)
148 b64 := base64.StdEncoding.EncodeToString(data)175 b64 := base64.StdEncoding.EncodeToString(data)
149 const chunk = 4096176 const chunk = 4096
150 var sb strings.Builder177 var sb strings.Builder
M packages/tui/image_test.go +66 -0
@@ -2,9 +2,13 @@ package tui
22
33 import (
44 "bytes"
5+ "encoding/base64"
56 "image"
67 "image/color"
8+ "image/jpeg"
79 "image/png"
10+ "regexp"
11+ "strings"
812 "testing"
913 )
1014
@@ -23,6 +27,68 @@ func testPNG(t *testing.T, w, h int) []byte {
2327 return buf.Bytes()
2428 }
2529
30+func testJPEG(t *testing.T, w, h int) []byte {
31+ t.Helper()
32+ img := image.NewRGBA(image.Rect(0, 0, w, h))
33+ for y := 0; y < h; y++ {
34+ for x := 0; x < w; x++ {
35+ img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 128, A: 255})
36+ }
37+ }
38+ var buf bytes.Buffer
39+ if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
40+ t.Fatal(err)
41+ }
42+ return buf.Bytes()
43+}
44+
45+var kittyPayloadRE = regexp.MustCompile(`\x1b_G[^;]*;([^\x1b]*)\x1b\\`)
46+
47+// kittyDecodePayload reassembles the base64 chunks from a kitty
48+// graphics escape sequence and returns the decoded raw bytes.
49+func kittyDecodePayload(t *testing.T, seq string) []byte {
50+ t.Helper()
51+ ms := kittyPayloadRE.FindAllStringSubmatch(seq, -1)
52+ if len(ms) == 0 {
53+ t.Fatalf("no kitty payload chunks found in sequence")
54+ }
55+ var b64 strings.Builder
56+ for _, m := range ms {
57+ b64.WriteString(m[1])
58+ }
59+ raw, err := base64.StdEncoding.DecodeString(b64.String())
60+ if err != nil {
61+ t.Fatalf("decode base64 payload: %v", err)
62+ }
63+ return raw
64+}
65+
66+func TestRenderKittyReencodesJPEGToPNG(t *testing.T) {
67+ // A JPEG handed to the kitty renderer must be re-encoded to PNG,
68+ // because kitty's f=100 path only decodes PNG. Otherwise the
69+ // terminal reserves the cell rectangle but paints nothing, which is
70+ // the empty-box symptom.
71+ jpg := testJPEG(t, 40, 30)
72+ seq := renderKitty(jpg, 20, 10)
73+ if seq == "" {
74+ t.Fatal("renderKitty returned empty sequence")
75+ }
76+ raw := kittyDecodePayload(t, seq)
77+ pngMagic := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
78+ if len(raw) < 8 || !bytes.Equal(raw[:8], pngMagic) {
79+ t.Fatalf("kitty payload is not PNG; got prefix %x", raw[:min(8, len(raw))])
80+ }
81+}
82+
83+func TestRenderKittyLeavesPNGUntouched(t *testing.T) {
84+ src := testPNG(t, 40, 30)
85+ seq := renderKitty(src, 20, 10)
86+ raw := kittyDecodePayload(t, seq)
87+ if !bytes.Equal(raw, src) {
88+ t.Fatal("PNG payload was needlessly re-encoded")
89+ }
90+}
91+
2692 func TestRowsForInlineImageRoundsUp(t *testing.T) {
2793 t.Setenv("ZOT_CELL_ASPECT", "")
2894 data := testPNG(t, 100, 51)
@@ -2,9 +2,13 @@ package tui
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "encoding/base64"
5 "image"6 "image"
6 "image/color"7 "image/color"
8+ "image/jpeg"
7 "image/png"9 "image/png"
10+ "regexp"
11+ "strings"
8 "testing"12 "testing"
9 )13 )
10 14
@@ -23,6 +27,68 @@ func testPNG(t *testing.T, w, h int) []byte {
23 return buf.Bytes()27 return buf.Bytes()
24 }28 }
25 29
30+func testJPEG(t *testing.T, w, h int) []byte {
31+ t.Helper()
32+ img := image.NewRGBA(image.Rect(0, 0, w, h))
33+ for y := 0; y < h; y++ {
34+ for x := 0; x < w; x++ {
35+ img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 128, A: 255})
36+ }
37+ }
38+ var buf bytes.Buffer
39+ if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
40+ t.Fatal(err)
41+ }
42+ return buf.Bytes()
43+}
44+
45+var kittyPayloadRE = regexp.MustCompile(`\x1b_G[^;]*;([^\x1b]*)\x1b\\`)
46+
47+// kittyDecodePayload reassembles the base64 chunks from a kitty
48+// graphics escape sequence and returns the decoded raw bytes.
49+func kittyDecodePayload(t *testing.T, seq string) []byte {
50+ t.Helper()
51+ ms := kittyPayloadRE.FindAllStringSubmatch(seq, -1)
52+ if len(ms) == 0 {
53+ t.Fatalf("no kitty payload chunks found in sequence")
54+ }
55+ var b64 strings.Builder
56+ for _, m := range ms {
57+ b64.WriteString(m[1])
58+ }
59+ raw, err := base64.StdEncoding.DecodeString(b64.String())
60+ if err != nil {
61+ t.Fatalf("decode base64 payload: %v", err)
62+ }
63+ return raw
64+}
65+
66+func TestRenderKittyReencodesJPEGToPNG(t *testing.T) {
67+ // A JPEG handed to the kitty renderer must be re-encoded to PNG,
68+ // because kitty's f=100 path only decodes PNG. Otherwise the
69+ // terminal reserves the cell rectangle but paints nothing, which is
70+ // the empty-box symptom.
71+ jpg := testJPEG(t, 40, 30)
72+ seq := renderKitty(jpg, 20, 10)
73+ if seq == "" {
74+ t.Fatal("renderKitty returned empty sequence")
75+ }
76+ raw := kittyDecodePayload(t, seq)
77+ pngMagic := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
78+ if len(raw) < 8 || !bytes.Equal(raw[:8], pngMagic) {
79+ t.Fatalf("kitty payload is not PNG; got prefix %x", raw[:min(8, len(raw))])
80+ }
81+}
82+
83+func TestRenderKittyLeavesPNGUntouched(t *testing.T) {
84+ src := testPNG(t, 40, 30)
85+ seq := renderKitty(src, 20, 10)
86+ raw := kittyDecodePayload(t, seq)
87+ if !bytes.Equal(raw, src) {
88+ t.Fatal("PNG payload was needlessly re-encoded")
89+ }
90+}
91+
26 func TestRowsForInlineImageRoundsUp(t *testing.T) {92 func TestRowsForInlineImageRoundsUp(t *testing.T) {
27 t.Setenv("ZOT_CELL_ASPECT", "")93 t.Setenv("ZOT_CELL_ASPECT", "")
28 data := testPNG(t, 100, 51)94 data := testPNG(t, 100, 51)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close