detached a81472c

fix(tui): stabilize inline image scrolling

commit
a81472cc65837107ce8cd8f154c5bff041dea54e
author
patriceckhart · · 2026-04-26 09:20 UTC
parent
487b097
signature
unsigned
refs
tag: v0.1.17
5 files · +177 -42
A internal/agent/modes/image_scroll_test.go +37 -0
@@ -0,0 +1,37 @@
1+package modes
2+
3+import "testing"
4+
5+func TestSnapViewportStartToImageBlock(t *testing.T) {
6+ chat := []string{
7+ "before",
8+ " \x1b_Ga=T,f=100,c=60;AAAA\x1b\\",
9+ "",
10+ "",
11+ " image - image/png - 100x100 - 1 KB",
12+ "after",
13+ }
14+
15+ if got := snapViewportStartToImageBlock(chat, 2); got != 1 {
16+ t.Fatalf("start in first reserved row snapped to %d, want 1", got)
17+ }
18+ if got := snapViewportStartToImageBlock(chat, 3); got != 1 {
19+ t.Fatalf("start in middle reserved row snapped to %d, want 1", got)
20+ }
21+ if got := snapViewportStartToImageBlock(chat, 1); got != 1 {
22+ t.Fatalf("start on image row snapped to %d, want 1", got)
23+ }
24+ if got := snapViewportStartToImageBlock(chat, 4); got != 4 {
25+ t.Fatalf("start on metadata row snapped to %d, want 4", got)
26+ }
27+ if got := snapViewportStartToImageBlock(chat, 5); got != 5 {
28+ t.Fatalf("start after image snapped to %d, want 5", got)
29+ }
30+}
31+
32+func TestSnapViewportStartToImageBlockNoopsOnPlainBlank(t *testing.T) {
33+ chat := []string{"before", "", "after"}
34+ if got := snapViewportStartToImageBlock(chat, 1); got != 1 {
35+ t.Fatalf("plain blank snapped to %d, want 1", got)
36+ }
37+}
@@ -0,0 +1,37 @@
1+package modes
2+
3+import "testing"
4+
5+func TestSnapViewportStartToImageBlock(t *testing.T) {
6+ chat := []string{
7+ "before",
8+ " \x1b_Ga=T,f=100,c=60;AAAA\x1b\\",
9+ "",
10+ "",
11+ " image - image/png - 100x100 - 1 KB",
12+ "after",
13+ }
14+
15+ if got := snapViewportStartToImageBlock(chat, 2); got != 1 {
16+ t.Fatalf("start in first reserved row snapped to %d, want 1", got)
17+ }
18+ if got := snapViewportStartToImageBlock(chat, 3); got != 1 {
19+ t.Fatalf("start in middle reserved row snapped to %d, want 1", got)
20+ }
21+ if got := snapViewportStartToImageBlock(chat, 1); got != 1 {
22+ t.Fatalf("start on image row snapped to %d, want 1", got)
23+ }
24+ if got := snapViewportStartToImageBlock(chat, 4); got != 4 {
25+ t.Fatalf("start on metadata row snapped to %d, want 4", got)
26+ }
27+ if got := snapViewportStartToImageBlock(chat, 5); got != 5 {
28+ t.Fatalf("start after image snapped to %d, want 5", got)
29+ }
30+}
31+
32+func TestSnapViewportStartToImageBlockNoopsOnPlainBlank(t *testing.T) {
33+ chat := []string{"before", "", "after"}
34+ if got := snapViewportStartToImageBlock(chat, 1); got != 1 {
35+ t.Fatalf("plain blank snapped to %d, want 1", got)
36+ }
37+}
M internal/agent/modes/interactive.go +24 -19
@@ -834,7 +834,15 @@ func (i *Interactive) redraw() {
834834 if start < 0 {
835835 start = 0
836836 }
837- start = alignSliceStartToImageBlock(chat, start, end)
837+ start = snapViewportStartToImageBlock(chat, start)
838+ end = start + chatRows
839+ if end > len(chat) {
840+ end = len(chat)
841+ start = end - chatRows
842+ if start < 0 {
843+ start = 0
844+ }
845+ }
838846 visibleChat = chat[start:end]
839847 }
840848 visibleChat = clipBottomClippedImages(visibleChat)
@@ -895,29 +903,26 @@ func (i *Interactive) redraw() {
895903 i.rend.Draw(frame, cursorRow, cursorCol)
896904 }
897905
898-func alignSliceStartToImageBlock(chat []string, start, end int) int {
899- if start <= 0 || start >= len(chat) || start >= end {
900- return start
901- }
902- // If the slice already starts on an image row, keep it.
903- if strings.Contains(chat[start], "\x1b]1337;File=") || strings.Contains(chat[start], "\x1b_G") {
906+func hasImageEscape(line string) bool {
907+ return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
908+}
909+
910+// snapViewportStartToImageBlock treats inline images as atomic blocks for
911+// scrolling. Terminal image protocols draw from a single escape row into a
912+// separate graphics layer; the following blank rows are only zot's reserved
913+// footprint. If the viewport starts on one of those blank rows, there is no
914+// correct partial-image state to render. Snap back to the escape row instead
915+// so the image is either shown from its beginning or skipped entirely.
916+func snapViewportStartToImageBlock(chat []string, start int) int {
917+ if start <= 0 || start >= len(chat) {
904918 return start
905919 }
906- // When the viewport begins inside an image block, the image escape row
907- // sits just above a run of blank reservation rows, followed by an
908- // "image - ..." info line. In that case, snap the slice start up to the
909- // escape row so the image actually renders instead of showing only the
910- // reserved blank area and metadata.
911- j := start
912- for j < end && strings.TrimSpace(chat[j]) == "" {
913- j++
914- }
915- if j >= end || !strings.Contains(chat[j], "image - ") {
920+ if hasImageEscape(chat[start]) || strings.TrimSpace(chat[start]) != "" {
916921 return start
917922 }
918923 for k := start - 1; k >= 0; k-- {
919924 line := chat[k]
920- if strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") {
925+ if hasImageEscape(line) {
921926 return k
922927 }
923928 if strings.TrimSpace(line) != "" {
@@ -960,7 +965,7 @@ func clipBottomClippedImages(lines []string) []string {
960965 }
961966 out := append([]string(nil), lines...)
962967 for i, line := range out {
963- if !strings.Contains(line, "\x1b]1337;File=") && !strings.Contains(line, "\x1b_G") {
968+ if !hasImageEscape(line) {
964969 continue
965970 }
966971 // Image blocks render as: image escape, zero or more blank
@@ -834,7 +834,15 @@ func (i *Interactive) redraw() {
834 if start < 0 {834 if start < 0 {
835 start = 0835 start = 0
836 }836 }
837- start = alignSliceStartToImageBlock(chat, start, end)837+ start = snapViewportStartToImageBlock(chat, start)
838+ end = start + chatRows
839+ if end > len(chat) {
840+ end = len(chat)
841+ start = end - chatRows
842+ if start < 0 {
843+ start = 0
844+ }
845+ }
838 visibleChat = chat[start:end]846 visibleChat = chat[start:end]
839 }847 }
840 visibleChat = clipBottomClippedImages(visibleChat)848 visibleChat = clipBottomClippedImages(visibleChat)
@@ -895,29 +903,26 @@ func (i *Interactive) redraw() {
895 i.rend.Draw(frame, cursorRow, cursorCol)903 i.rend.Draw(frame, cursorRow, cursorCol)
896 }904 }
897 905
898-func alignSliceStartToImageBlock(chat []string, start, end int) int {906+func hasImageEscape(line string) bool {
899- if start <= 0 || start >= len(chat) || start >= end {907+ return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
900- return start908+}
901- }909+
902- // If the slice already starts on an image row, keep it.910+// snapViewportStartToImageBlock treats inline images as atomic blocks for
903- if strings.Contains(chat[start], "\x1b]1337;File=") || strings.Contains(chat[start], "\x1b_G") {911+// scrolling. Terminal image protocols draw from a single escape row into a
912+// separate graphics layer; the following blank rows are only zot's reserved
913+// footprint. If the viewport starts on one of those blank rows, there is no
914+// correct partial-image state to render. Snap back to the escape row instead
915+// so the image is either shown from its beginning or skipped entirely.
916+func snapViewportStartToImageBlock(chat []string, start int) int {
917+ if start <= 0 || start >= len(chat) {
904 return start918 return start
905 }919 }
906- // When the viewport begins inside an image block, the image escape row920+ if hasImageEscape(chat[start]) || strings.TrimSpace(chat[start]) != "" {
907- // sits just above a run of blank reservation rows, followed by an
908- // "image - ..." info line. In that case, snap the slice start up to the
909- // escape row so the image actually renders instead of showing only the
910- // reserved blank area and metadata.
911- j := start
912- for j < end && strings.TrimSpace(chat[j]) == "" {
913- j++
914- }
915- if j >= end || !strings.Contains(chat[j], "image - ") {
916 return start921 return start
917 }922 }
918 for k := start - 1; k >= 0; k-- {923 for k := start - 1; k >= 0; k-- {
919 line := chat[k]924 line := chat[k]
920- if strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") {925+ if hasImageEscape(line) {
921 return k926 return k
922 }927 }
923 if strings.TrimSpace(line) != "" {928 if strings.TrimSpace(line) != "" {
@@ -960,7 +965,7 @@ func clipBottomClippedImages(lines []string) []string {
960 }965 }
961 out := append([]string(nil), lines...)966 out := append([]string(nil), lines...)
962 for i, line := range out {967 for i, line := range out {
963- if !strings.Contains(line, "\x1b]1337;File=") && !strings.Contains(line, "\x1b_G") {968+ if !hasImageEscape(line) {
964 continue969 continue
965 }970 }
966 // Image blocks render as: image escape, zero or more blank971 // Image blocks render as: image escape, zero or more blank
M internal/tui/image.go +44 -13
@@ -8,7 +8,9 @@ import (
88 _ "image/gif"
99 _ "image/jpeg"
1010 _ "image/png"
11+ "math"
1112 "os"
13+ "strconv"
1214 "strings"
1315 )
1416
@@ -29,13 +31,14 @@ const (
2931 // Kitty-graphics support, we use it. The ZOT_INLINE_IMAGES env var
3032 // overrides the default:
3133 //
32-// ZOT_INLINE_IMAGES=off -> force text fallback
33-// ZOT_INLINE_IMAGES=iterm -> force iTerm2 protocol
34-// ZOT_INLINE_IMAGES=kitty -> force Kitty protocol
35-// ZOT_INLINE_IMAGES=auto -> explicit auto-detect (same as default)
34+// ZOT_INLINE_IMAGES=off -> force text fallback
35+// ZOT_INLINE_IMAGES=placeholder -> force text fallback (alias for off)
36+// ZOT_INLINE_IMAGES=iterm -> force iTerm2 protocol
37+// ZOT_INLINE_IMAGES=kitty -> force Kitty protocol
38+// ZOT_INLINE_IMAGES=auto -> explicit auto-detect (same as default)
3639 func DetectImageProtocol() ImageProtocol {
3740 switch strings.ToLower(os.Getenv("ZOT_INLINE_IMAGES")) {
38- case "off", "none", "false", "0":
41+ case "off", "none", "false", "0", "placeholder", "text":
3942 return ImageProtocolNone
4043 case "iterm", "iterm2":
4144 return ImageProtocolITerm2
@@ -52,6 +55,14 @@ func detectImageProtocolAuto() ImageProtocol {
5255 term := os.Getenv("TERM")
5356 kittyWindow := os.Getenv("KITTY_WINDOW_ID")
5457
58+ // VS Code's integrated terminal exposes several protocol-ish env
59+ // combinations depending on the underlying shell/pty, but its image
60+ // layer is inconsistent enough that auto-enable is more annoying than
61+ // useful. Users can still force a protocol via ZOT_INLINE_IMAGES.
62+ if strings.EqualFold(termProgram, "vscode") {
63+ return ImageProtocolNone
64+ }
65+
5566 if kittyWindow != "" || strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") {
5667 return ImageProtocolKitty
5768 }
@@ -128,8 +139,10 @@ func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string {
128139 hdr := "a=T,f=100"
129140 if maxCellsWide > 0 && maxCellsHigh > 0 {
130141 if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 {
131- // rows that the native width would produce at maxCellsWide
132- nativeRows := int(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio)
142+ // rows that the native width would produce at maxCellsWide.
143+ // Round up so we don't under-reserve and let following text
144+ // paint over the image on terminals whose cell metrics differ.
145+ nativeRows := int(math.Ceil(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio()))
133146 if nativeRows > maxCellsHigh {
134147 hdr += fmt.Sprintf(",r=%d", maxCellsHigh)
135148 } else {
@@ -172,11 +185,27 @@ func ImageDimensions(data []byte) (int, int) {
172185 return cfg.Width, cfg.Height
173186 }
174187
175-// CellAspectRatio approximates how many pixel-rows one terminal row
176-// occupies. Typical monospace cells are ~2x tall as wide; we use 2.0
177-// as a safe default. Used to compute the rendered row count when
178-// scaling an image to fit a cell width.
179-const CellAspectRatio = 2.0
188+// defaultCellAspectRatio approximates how many pixel-rows one terminal
189+// row occupies. Typical monospace cells are ~2x tall as wide; we use 2.0
190+// as a safe default. Used to compute the rendered row count when scaling
191+// an image to fit a cell width.
192+const defaultCellAspectRatio = 2.0
193+
194+// CellAspectRatio returns the pixel-height / pixel-width ratio for one
195+// terminal cell. ZOT_CELL_ASPECT lets users tune inline-image row
196+// reservation for terminals/fonts where the default causes overlap or
197+// excessive blank space. Values outside a sane range are ignored.
198+func CellAspectRatio() float64 {
199+ v := strings.TrimSpace(os.Getenv("ZOT_CELL_ASPECT"))
200+ if v == "" {
201+ return defaultCellAspectRatio
202+ }
203+ f, err := strconv.ParseFloat(v, 64)
204+ if err != nil || f < 0.5 || f > 5 {
205+ return defaultCellAspectRatio
206+ }
207+ return f
208+}
180209
181210 // RowsForInlineImage returns the number of terminal rows an image
182211 // rendered at cellsWide columns will occupy, preserving aspect ratio.
@@ -189,8 +218,10 @@ func RowsForInlineImage(data []byte, cellsWide, maxRows int) int {
189218 // pixels per cell (horizontal)
190219 // scaleX = imageWidthPx / cellsWide
191220 // rendered height in cells = imageHeightPx / (scaleX * CellAspectRatio)
221+ // Round up: under-reserving by even one row is much worse than one
222+ // extra blank line, because following text can overwrite the image.
192223 scaleX := float64(w) / float64(cellsWide)
193- rows := int(float64(h) / (scaleX * CellAspectRatio))
224+ rows := int(math.Ceil(float64(h) / (scaleX * CellAspectRatio())))
194225 if rows < 1 {
195226 rows = 1
196227 }
@@ -8,7 +8,9 @@ import (
8 _ "image/gif"8 _ "image/gif"
9 _ "image/jpeg"9 _ "image/jpeg"
10 _ "image/png"10 _ "image/png"
11+ "math"
11 "os"12 "os"
13+ "strconv"
12 "strings"14 "strings"
13 )15 )
14 16
@@ -29,13 +31,14 @@ const (
29 // Kitty-graphics support, we use it. The ZOT_INLINE_IMAGES env var31 // Kitty-graphics support, we use it. The ZOT_INLINE_IMAGES env var
30 // overrides the default:32 // overrides the default:
31 //33 //
32-// ZOT_INLINE_IMAGES=off -> force text fallback34+// ZOT_INLINE_IMAGES=off -> force text fallback
33-// ZOT_INLINE_IMAGES=iterm -> force iTerm2 protocol35+// ZOT_INLINE_IMAGES=placeholder -> force text fallback (alias for off)
34-// ZOT_INLINE_IMAGES=kitty -> force Kitty protocol36+// ZOT_INLINE_IMAGES=iterm -> force iTerm2 protocol
35-// ZOT_INLINE_IMAGES=auto -> explicit auto-detect (same as default)37+// ZOT_INLINE_IMAGES=kitty -> force Kitty protocol
38+// ZOT_INLINE_IMAGES=auto -> explicit auto-detect (same as default)
36 func DetectImageProtocol() ImageProtocol {39 func DetectImageProtocol() ImageProtocol {
37 switch strings.ToLower(os.Getenv("ZOT_INLINE_IMAGES")) {40 switch strings.ToLower(os.Getenv("ZOT_INLINE_IMAGES")) {
38- case "off", "none", "false", "0":41+ case "off", "none", "false", "0", "placeholder", "text":
39 return ImageProtocolNone42 return ImageProtocolNone
40 case "iterm", "iterm2":43 case "iterm", "iterm2":
41 return ImageProtocolITerm244 return ImageProtocolITerm2
@@ -52,6 +55,14 @@ func detectImageProtocolAuto() ImageProtocol {
52 term := os.Getenv("TERM")55 term := os.Getenv("TERM")
53 kittyWindow := os.Getenv("KITTY_WINDOW_ID")56 kittyWindow := os.Getenv("KITTY_WINDOW_ID")
54 57
58+ // VS Code's integrated terminal exposes several protocol-ish env
59+ // combinations depending on the underlying shell/pty, but its image
60+ // layer is inconsistent enough that auto-enable is more annoying than
61+ // useful. Users can still force a protocol via ZOT_INLINE_IMAGES.
62+ if strings.EqualFold(termProgram, "vscode") {
63+ return ImageProtocolNone
64+ }
65+
55 if kittyWindow != "" || strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") {66 if kittyWindow != "" || strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") {
56 return ImageProtocolKitty67 return ImageProtocolKitty
57 }68 }
@@ -128,8 +139,10 @@ func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string {
128 hdr := "a=T,f=100"139 hdr := "a=T,f=100"
129 if maxCellsWide > 0 && maxCellsHigh > 0 {140 if maxCellsWide > 0 && maxCellsHigh > 0 {
130 if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 {141 if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 {
131- // rows that the native width would produce at maxCellsWide142+ // rows that the native width would produce at maxCellsWide.
132- nativeRows := int(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio)143+ // Round up so we don't under-reserve and let following text
144+ // paint over the image on terminals whose cell metrics differ.
145+ nativeRows := int(math.Ceil(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio()))
133 if nativeRows > maxCellsHigh {146 if nativeRows > maxCellsHigh {
134 hdr += fmt.Sprintf(",r=%d", maxCellsHigh)147 hdr += fmt.Sprintf(",r=%d", maxCellsHigh)
135 } else {148 } else {
@@ -172,11 +185,27 @@ func ImageDimensions(data []byte) (int, int) {
172 return cfg.Width, cfg.Height185 return cfg.Width, cfg.Height
173 }186 }
174 187
175-// CellAspectRatio approximates how many pixel-rows one terminal row188+// defaultCellAspectRatio approximates how many pixel-rows one terminal
176-// occupies. Typical monospace cells are ~2x tall as wide; we use 2.0189+// row occupies. Typical monospace cells are ~2x tall as wide; we use 2.0
177-// as a safe default. Used to compute the rendered row count when190+// as a safe default. Used to compute the rendered row count when scaling
178-// scaling an image to fit a cell width.191+// an image to fit a cell width.
179-const CellAspectRatio = 2.0192+const defaultCellAspectRatio = 2.0
193+
194+// CellAspectRatio returns the pixel-height / pixel-width ratio for one
195+// terminal cell. ZOT_CELL_ASPECT lets users tune inline-image row
196+// reservation for terminals/fonts where the default causes overlap or
197+// excessive blank space. Values outside a sane range are ignored.
198+func CellAspectRatio() float64 {
199+ v := strings.TrimSpace(os.Getenv("ZOT_CELL_ASPECT"))
200+ if v == "" {
201+ return defaultCellAspectRatio
202+ }
203+ f, err := strconv.ParseFloat(v, 64)
204+ if err != nil || f < 0.5 || f > 5 {
205+ return defaultCellAspectRatio
206+ }
207+ return f
208+}
180 209
181 // RowsForInlineImage returns the number of terminal rows an image210 // RowsForInlineImage returns the number of terminal rows an image
182 // rendered at cellsWide columns will occupy, preserving aspect ratio.211 // rendered at cellsWide columns will occupy, preserving aspect ratio.
@@ -189,8 +218,10 @@ func RowsForInlineImage(data []byte, cellsWide, maxRows int) int {
189 // pixels per cell (horizontal)218 // pixels per cell (horizontal)
190 // scaleX = imageWidthPx / cellsWide219 // scaleX = imageWidthPx / cellsWide
191 // rendered height in cells = imageHeightPx / (scaleX * CellAspectRatio)220 // rendered height in cells = imageHeightPx / (scaleX * CellAspectRatio)
221+ // Round up: under-reserving by even one row is much worse than one
222+ // extra blank line, because following text can overwrite the image.
192 scaleX := float64(w) / float64(cellsWide)223 scaleX := float64(w) / float64(cellsWide)
193- rows := int(float64(h) / (scaleX * CellAspectRatio))224+ rows := int(math.Ceil(float64(h) / (scaleX * CellAspectRatio())))
194 if rows < 1 {225 if rows < 1 {
195 rows = 1226 rows = 1
196 }227 }
A internal/tui/image_test.go +66 -0
@@ -0,0 +1,66 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "image"
6+ "image/color"
7+ "image/png"
8+ "testing"
9+)
10+
11+func testPNG(t *testing.T, w, h int) []byte {
12+ t.Helper()
13+ img := image.NewRGBA(image.Rect(0, 0, w, h))
14+ for y := 0; y < h; y++ {
15+ for x := 0; x < w; x++ {
16+ img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 128, A: 255})
17+ }
18+ }
19+ var buf bytes.Buffer
20+ if err := png.Encode(&buf, img); err != nil {
21+ t.Fatal(err)
22+ }
23+ return buf.Bytes()
24+}
25+
26+func TestRowsForInlineImageRoundsUp(t *testing.T) {
27+ t.Setenv("ZOT_CELL_ASPECT", "")
28+ data := testPNG(t, 100, 51)
29+ got := RowsForInlineImage(data, 10, 0)
30+ // 51px high at 10 cells wide with a 2.0 cell aspect is 2.55 rows.
31+ // Rounding down to 2 lets following text overlap; we need 3.
32+ if got != 3 {
33+ t.Fatalf("RowsForInlineImage = %d, want 3", got)
34+ }
35+}
36+
37+func TestRowsForInlineImageCellAspectOverride(t *testing.T) {
38+ data := testPNG(t, 100, 100)
39+ t.Setenv("ZOT_CELL_ASPECT", "1")
40+ if got := RowsForInlineImage(data, 10, 0); got != 10 {
41+ t.Fatalf("aspect=1 rows = %d, want 10", got)
42+ }
43+ t.Setenv("ZOT_CELL_ASPECT", "4")
44+ if got := RowsForInlineImage(data, 10, 0); got != 3 {
45+ t.Fatalf("aspect=4 rows = %d, want 3", got)
46+ }
47+}
48+
49+func TestDetectImageProtocolPlaceholderAndVSCode(t *testing.T) {
50+ t.Setenv("ZOT_INLINE_IMAGES", "placeholder")
51+ if got := DetectImageProtocol(); got != ImageProtocolNone {
52+ t.Fatalf("placeholder protocol = %v, want none", got)
53+ }
54+
55+ t.Setenv("ZOT_INLINE_IMAGES", "")
56+ t.Setenv("TERM_PROGRAM", "vscode")
57+ t.Setenv("KITTY_WINDOW_ID", "1")
58+ if got := DetectImageProtocol(); got != ImageProtocolNone {
59+ t.Fatalf("vscode auto protocol = %v, want none", got)
60+ }
61+
62+ t.Setenv("ZOT_INLINE_IMAGES", "kitty")
63+ if got := DetectImageProtocol(); got != ImageProtocolKitty {
64+ t.Fatalf("forced kitty protocol = %v, want kitty", got)
65+ }
66+}
@@ -0,0 +1,66 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "image"
6+ "image/color"
7+ "image/png"
8+ "testing"
9+)
10+
11+func testPNG(t *testing.T, w, h int) []byte {
12+ t.Helper()
13+ img := image.NewRGBA(image.Rect(0, 0, w, h))
14+ for y := 0; y < h; y++ {
15+ for x := 0; x < w; x++ {
16+ img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 128, A: 255})
17+ }
18+ }
19+ var buf bytes.Buffer
20+ if err := png.Encode(&buf, img); err != nil {
21+ t.Fatal(err)
22+ }
23+ return buf.Bytes()
24+}
25+
26+func TestRowsForInlineImageRoundsUp(t *testing.T) {
27+ t.Setenv("ZOT_CELL_ASPECT", "")
28+ data := testPNG(t, 100, 51)
29+ got := RowsForInlineImage(data, 10, 0)
30+ // 51px high at 10 cells wide with a 2.0 cell aspect is 2.55 rows.
31+ // Rounding down to 2 lets following text overlap; we need 3.
32+ if got != 3 {
33+ t.Fatalf("RowsForInlineImage = %d, want 3", got)
34+ }
35+}
36+
37+func TestRowsForInlineImageCellAspectOverride(t *testing.T) {
38+ data := testPNG(t, 100, 100)
39+ t.Setenv("ZOT_CELL_ASPECT", "1")
40+ if got := RowsForInlineImage(data, 10, 0); got != 10 {
41+ t.Fatalf("aspect=1 rows = %d, want 10", got)
42+ }
43+ t.Setenv("ZOT_CELL_ASPECT", "4")
44+ if got := RowsForInlineImage(data, 10, 0); got != 3 {
45+ t.Fatalf("aspect=4 rows = %d, want 3", got)
46+ }
47+}
48+
49+func TestDetectImageProtocolPlaceholderAndVSCode(t *testing.T) {
50+ t.Setenv("ZOT_INLINE_IMAGES", "placeholder")
51+ if got := DetectImageProtocol(); got != ImageProtocolNone {
52+ t.Fatalf("placeholder protocol = %v, want none", got)
53+ }
54+
55+ t.Setenv("ZOT_INLINE_IMAGES", "")
56+ t.Setenv("TERM_PROGRAM", "vscode")
57+ t.Setenv("KITTY_WINDOW_ID", "1")
58+ if got := DetectImageProtocol(); got != ImageProtocolNone {
59+ t.Fatalf("vscode auto protocol = %v, want none", got)
60+ }
61+
62+ t.Setenv("ZOT_INLINE_IMAGES", "kitty")
63+ if got := DetectImageProtocol(); got != ImageProtocolKitty {
64+ t.Fatalf("forced kitty protocol = %v, want kitty", got)
65+ }
66+}
M internal/tui/view.go +6 -10
@@ -882,21 +882,17 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
882882 if rows < 1 {
883883 rows = 1
884884 }
885+ // Reserve the image footprint first, then place the escape
886+ // in the top-left of that blank rectangle. Keeping the
887+ // metadata above the image made some terminals blend text and
888+ // graphics layers during full-screen repaints; metadata below
889+ // the reserved rectangle is more stable and easier to read.
885890 out := make([]string, 0, rows+3)
886891 out = append(out, " "+seq)
887- // Reserve blank rows matching the image's on-screen
888- // height, plus one extra. RowsForInlineImage estimates
889- // the height from a fixed cell aspect ratio; real
890- // terminals differ enough that the image often spills
891- // one row past the estimate and repaints over the info
892- // line below. The extra row absorbs that spillover.
893- for i := 1; i < rows+1; i++ {
892+ for i := 1; i < rows; i++ {
894893 out = append(out, "")
895894 }
896895 out = append(out, v.Theme.FG256(v.Theme.Muted, info))
897- // Trailing blank so the closing rule / next block has
898- // some breathing room under the info line instead of
899- // sitting flush against it.
900896 out = append(out, "")
901897 return out
902898 }
@@ -882,21 +882,17 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
882 if rows < 1 {882 if rows < 1 {
883 rows = 1883 rows = 1
884 }884 }
885+ // Reserve the image footprint first, then place the escape
886+ // in the top-left of that blank rectangle. Keeping the
887+ // metadata above the image made some terminals blend text and
888+ // graphics layers during full-screen repaints; metadata below
889+ // the reserved rectangle is more stable and easier to read.
885 out := make([]string, 0, rows+3)890 out := make([]string, 0, rows+3)
886 out = append(out, " "+seq)891 out = append(out, " "+seq)
887- // Reserve blank rows matching the image's on-screen892+ for i := 1; i < rows; i++ {
888- // height, plus one extra. RowsForInlineImage estimates
889- // the height from a fixed cell aspect ratio; real
890- // terminals differ enough that the image often spills
891- // one row past the estimate and repaints over the info
892- // line below. The extra row absorbs that spillover.
893- for i := 1; i < rows+1; i++ {
894 out = append(out, "")893 out = append(out, "")
895 }894 }
896 out = append(out, v.Theme.FG256(v.Theme.Muted, info))895 out = append(out, v.Theme.FG256(v.Theme.Muted, info))
897- // Trailing blank so the closing rule / next block has
898- // some breathing room under the info line instead of
899- // sitting flush against it.
900 out = append(out, "")896 out = append(out, "")
901 return out897 return out
902 }898 }

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