tweak(tui): image-block spacing + indent
Two small fixes to how inline-image tool results render.
1) Extra blank row under the image.
RowsForInlineImage estimates the on-screen height from a
fixed cell aspect ratio. Real terminals (iTerm2, Ghostty,
Kitty, WezTerm) don't all match that ratio exactly, so the
image often paints one row past the estimate and the info
line below it ("image - image/png - 612x904 - 99 KB") gets
overwritten by the image's bottom edge. Reserving one extra
blank row between the image and the info line absorbs that
spillover on every terminal I tried.
Same symmetry for the trailing blank: the info line no
longer sits flush against the tool-block closing rule or
the next content below.
2) Info line indented to 4 spaces.
Every other tool-output row (renderRawFile's gutter,
renderBashResult, diff rows, the "... N more lines, M total"
collapse footer) starts at column 4. The image info line
used to start at column 2, which looked off next to the
other rows. Bumped it to 4 so the metadata line aligns with
the file content above and below it.
Text-only fallback (terminals without inline image support)
also picks up the trailing blank + 4-space indent so the two\nrender paths behave consistently.- commit
- dad49e9b7a1d7046be7f6707ce606c456b869d80
- author
- patriceckhart · · 2026-04-21 17:12 UTC
- parent
- 112341c
- signature
- unsigned
- refs
- tag: v0.0.84
1 file · +21 -6
M internal/tui/view.go +21 -6
| @@ -841,7 +841,11 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt | |||
| 841 | 841 | func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |
| 842 | 842 | w, h := ImageDimensions(b.Data) | |
| 843 | 843 | kb := len(b.Data) / 1024 | |
| 844 | - | info := fmt.Sprintf(" image - %s - %dx%d - %d KB", b.MimeType, w, h, kb) | |
| 844 | + | // Four-space indent matches every other tool-output row | |
| 845 | + | // (renderRawFile, renderBashResult, diff rows, "... N more | |
| 846 | + | // lines" footers). Keeps the image's metadata line vertically | |
| 847 | + | // aligned with the file content above / below it. | |
| 848 | + | info := fmt.Sprintf(" image - %s - %dx%d - %d KB", b.MimeType, w, h, kb) | |
| 845 | 849 | ||
| 846 | 850 | if v.ImageProto != ImageProtocolNone { | |
| 847 | 851 | // Clamp rendered width so the image never overflows the chat | |
| @@ -860,18 +864,29 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |||
| 860 | 864 | if rows < 1 { | |
| 861 | 865 | rows = 1 | |
| 862 | 866 | } | |
| 863 | - | out := make([]string, 0, rows+1) | |
| 867 | + | out := make([]string, 0, rows+3) | |
| 864 | 868 | out = append(out, " "+seq) | |
| 865 | - | // Reserve blank rows matching the image's on-screen height. | |
| 866 | - | for i := 1; i < rows; i++ { | |
| 869 | + | // Reserve blank rows matching the image's on-screen | |
| 870 | + | // height, plus one extra. RowsForInlineImage estimates | |
| 871 | + | // the height from a fixed cell aspect ratio; real | |
| 872 | + | // terminals differ enough that the image often spills | |
| 873 | + | // one row past the estimate and repaints over the info | |
| 874 | + | // line below. The extra row absorbs that spillover. | |
| 875 | + | for i := 1; i < rows+1; i++ { | |
| 867 | 876 | out = append(out, "") | |
| 868 | 877 | } | |
| 869 | 878 | out = append(out, v.Theme.FG256(v.Theme.Muted, info)) | |
| 879 | + | // Trailing blank so the closing rule / next block has | |
| 880 | + | // some breathing room under the info line instead of | |
| 881 | + | // sitting flush against it. | |
| 882 | + | out = append(out, "") | |
| 870 | 883 | return out | |
| 871 | 884 | } | |
| 872 | 885 | } | |
| 873 | - | // Text fallback. | |
| 874 | - | return []string{v.Theme.FG256(v.Theme.Muted, info)} | |
| 886 | + | // Text fallback: same trailing blank for symmetry with the | |
| 887 | + | // inline path, so a text-only image placeholder also gets | |
| 888 | + | // a little space below before the next content. | |
| 889 | + | return []string{v.Theme.FG256(v.Theme.Muted, info), ""} | |
| 875 | 890 | } | |
| 876 | 891 | ||
| 877 | 892 | // looksLikeNumberedFile returns true when text matches the `read` | |
| @@ -841,7 +841,11 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt | |||||
| 841 | func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | 841 | func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | ||
| 842 | w, h := ImageDimensions(b.Data) | 842 | w, h := ImageDimensions(b.Data) | ||
| 843 | kb := len(b.Data) / 1024 | 843 | kb := len(b.Data) / 1024 | ||
| 844 | - | info := fmt.Sprintf(" image - %s - %dx%d - %d KB", b.MimeType, w, h, kb) | 844 | + | // Four-space indent matches every other tool-output row |
| 845 | + | // (renderRawFile, renderBashResult, diff rows, "... N more | |||
| 846 | + | // lines" footers). Keeps the image's metadata line vertically | |||
| 847 | + | // aligned with the file content above / below it. | |||
| 848 | + | info := fmt.Sprintf(" image - %s - %dx%d - %d KB", b.MimeType, w, h, kb) | |||
| 845 | 849 | ||||
| 846 | if v.ImageProto != ImageProtocolNone { | 850 | if v.ImageProto != ImageProtocolNone { | ||
| 847 | // Clamp rendered width so the image never overflows the chat | 851 | // Clamp rendered width so the image never overflows the chat | ||
| @@ -860,18 +864,29 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |||||
| 860 | if rows < 1 { | 864 | if rows < 1 { | ||
| 861 | rows = 1 | 865 | rows = 1 | ||
| 862 | } | 866 | } | ||
| 863 | - | out := make([]string, 0, rows+1) | 867 | + | out := make([]string, 0, rows+3) |
| 864 | out = append(out, " "+seq) | 868 | out = append(out, " "+seq) | ||
| 865 | - | // Reserve blank rows matching the image's on-screen height. | 869 | + | // Reserve blank rows matching the image's on-screen |
| 866 | - | for i := 1; i < rows; i++ { | 870 | + | // height, plus one extra. RowsForInlineImage estimates |
| 871 | + | // the height from a fixed cell aspect ratio; real | |||
| 872 | + | // terminals differ enough that the image often spills | |||
| 873 | + | // one row past the estimate and repaints over the info | |||
| 874 | + | // line below. The extra row absorbs that spillover. | |||
| 875 | + | for i := 1; i < rows+1; i++ { | |||
| 867 | out = append(out, "") | 876 | out = append(out, "") | ||
| 868 | } | 877 | } | ||
| 869 | out = append(out, v.Theme.FG256(v.Theme.Muted, info)) | 878 | out = append(out, v.Theme.FG256(v.Theme.Muted, info)) | ||
| 879 | + | // Trailing blank so the closing rule / next block has | |||
| 880 | + | // some breathing room under the info line instead of | |||
| 881 | + | // sitting flush against it. | |||
| 882 | + | out = append(out, "") | |||
| 870 | return out | 883 | return out | ||
| 871 | } | 884 | } | ||
| 872 | } | 885 | } | ||
| 873 | - | // Text fallback. | 886 | + | // Text fallback: same trailing blank for symmetry with the |
| 874 | - | return []string{v.Theme.FG256(v.Theme.Muted, info)} | 887 | + | // inline path, so a text-only image placeholder also gets |
| 888 | + | // a little space below before the next content. | |||
| 889 | + | return []string{v.Theme.FG256(v.Theme.Muted, info), ""} | |||
| 875 | } | 890 | } | ||
| 876 | 891 | ||||
| 877 | // looksLikeNumberedFile returns true when text matches the `read` | 892 | // looksLikeNumberedFile returns true when text matches the `read` | ||