detached 998a062

tui: render inline images inside tool boxes

Two fixes for image-block rendering after the box refactor.

clipBottomClippedImages decides whether to suppress an image escape
this frame by scanning forward for the matching "image - ..." info
line, treating any non-blank intervening row as a blocker. The image
block layout is: escape row + N blank reservation rows + info row.
With tool boxes, the reservation rows are wrapped as "\u2502    \u2026    \u2502"
and the naive whitespace check now sees them as non-blank \u2014 the scan
broke before reaching the info line, decided the image was clipped,
and zeroed out the escape sequence. Result: a tall blank gap inside
the box where the image should be, with only the metadata caption
visible at the bottom (visible on iTerm2 / Kitty / WezTerm).

Add isBoxBlankLine() that strips ANSI escapes, surrounding whitespace,
and \u2502 box edges before checking for emptiness. Use it in both
clipBottomClippedImages and snapViewportStartToImageBlock so the
viewport snap-back also recognises wrapped reservation rows.

Also add one extra blank reservation row between the image footprint
and the muted "image - mime - WxH - sizeKB" caption, so the caption
doesn't sit flush against the last pixel row of the image.
commit
998a062a20a1f726654f7383d858482530137237
author
patriceckhart · · 2026-04-28 06:11 UTC
parent
6513559
signature
unsigned
refs
tag: v0.1.33
2 files · +57 -6
M internal/agent/modes/interactive.go +51 -3
@@ -985,7 +985,7 @@ func snapViewportStartToImageBlock(chat []string, start int) int {
985985 if start <= 0 || start >= len(chat) {
986986 return start
987987 }
988- if hasImageEscape(chat[start]) || strings.TrimSpace(chat[start]) != "" {
988+ if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
989989 return start
990990 }
991991 for k := start - 1; k >= 0; k-- {
@@ -993,7 +993,7 @@ func snapViewportStartToImageBlock(chat []string, start int) int {
993993 if hasImageEscape(line) {
994994 return k
995995 }
996- if strings.TrimSpace(line) != "" {
996+ if !isBoxBlankLine(line) {
997997 break
998998 }
999999 }
@@ -1041,13 +1041,18 @@ func clipBottomClippedImages(lines []string) []string {
10411041 // then one trailing blank. If the info line isn't visible in
10421042 // the current chat slice, the image would paint down into the
10431043 // fixed status bar area. Suppress that image for this frame.
1044+ //
1045+ // When the image lives inside a tool box, the reservation rows
1046+ // are wrapped in vertical box edges ("│ … │"); those rows
1047+ // look non-blank under a naive whitespace check but are still
1048+ // reservation rows for this scan, so treat them as blank.
10441049 foundInfo := false
10451050 for j := i + 1; j < len(out); j++ {
10461051 if strings.Contains(out[j], "image - ") {
10471052 foundInfo = true
10481053 break
10491054 }
1050- if strings.TrimSpace(out[j]) != "" {
1055+ if !isBoxBlankLine(out[j]) {
10511056 break
10521057 }
10531058 }
@@ -1058,6 +1063,49 @@ func clipBottomClippedImages(lines []string) []string {
10581063 return out
10591064 }
10601065
1066+// isBoxBlankLine reports whether line is visually empty after
1067+// stripping ANSI escape sequences, surrounding whitespace, and the
1068+// vertical box edges drawn by the tool-box renderer. Used by
1069+// clipBottomClippedImages so an image's reservation rows still count
1070+// as blank when those rows are wrapped in "│ … │" inside a tool box.
1071+func isBoxBlankLine(line string) bool {
1072+ stripped := stripANSIBytes(line)
1073+ stripped = strings.TrimSpace(stripped)
1074+ stripped = strings.Trim(stripped, "│")
1075+ stripped = strings.TrimSpace(stripped)
1076+ return stripped == ""
1077+}
1078+
1079+// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' … final
1080+// byte) from s without pulling in the regexp package. Mirrors the
1081+// internal helper in package tui; the duplicated copy avoids exporting
1082+// it just for one caller.
1083+func stripANSIBytes(s string) string {
1084+ if !strings.Contains(s, "\x1b") {
1085+ return s
1086+ }
1087+ var b strings.Builder
1088+ b.Grow(len(s))
1089+ i := 0
1090+ for i < len(s) {
1091+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1092+ end := i + 2
1093+ for end < len(s) {
1094+ c := s[end]
1095+ end++
1096+ if c >= 0x40 && c <= 0x7e {
1097+ break
1098+ }
1099+ }
1100+ i = end
1101+ continue
1102+ }
1103+ b.WriteByte(s[i])
1104+ i++
1105+ }
1106+ return b.String()
1107+}
1108+
10611109 // truncateLine shortens s so it fits within n display cells, with an
10621110 // ellipsis if trimmed. Used by the "sliding in" chips so a pasted
10631111 // novel doesn't blow past the status line.
@@ -985,7 +985,7 @@ func snapViewportStartToImageBlock(chat []string, start int) int {
985 if start <= 0 || start >= len(chat) {985 if start <= 0 || start >= len(chat) {
986 return start986 return start
987 }987 }
988- if hasImageEscape(chat[start]) || strings.TrimSpace(chat[start]) != "" {988+ if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
989 return start989 return start
990 }990 }
991 for k := start - 1; k >= 0; k-- {991 for k := start - 1; k >= 0; k-- {
@@ -993,7 +993,7 @@ func snapViewportStartToImageBlock(chat []string, start int) int {
993 if hasImageEscape(line) {993 if hasImageEscape(line) {
994 return k994 return k
995 }995 }
996- if strings.TrimSpace(line) != "" {996+ if !isBoxBlankLine(line) {
997 break997 break
998 }998 }
999 }999 }
@@ -1041,13 +1041,18 @@ func clipBottomClippedImages(lines []string) []string {
1041 // then one trailing blank. If the info line isn't visible in1041 // then one trailing blank. If the info line isn't visible in
1042 // the current chat slice, the image would paint down into the1042 // the current chat slice, the image would paint down into the
1043 // fixed status bar area. Suppress that image for this frame.1043 // fixed status bar area. Suppress that image for this frame.
1044+ //
1045+ // When the image lives inside a tool box, the reservation rows
1046+ // are wrapped in vertical box edges ("│ … │"); those rows
1047+ // look non-blank under a naive whitespace check but are still
1048+ // reservation rows for this scan, so treat them as blank.
1044 foundInfo := false1049 foundInfo := false
1045 for j := i + 1; j < len(out); j++ {1050 for j := i + 1; j < len(out); j++ {
1046 if strings.Contains(out[j], "image - ") {1051 if strings.Contains(out[j], "image - ") {
1047 foundInfo = true1052 foundInfo = true
1048 break1053 break
1049 }1054 }
1050- if strings.TrimSpace(out[j]) != "" {1055+ if !isBoxBlankLine(out[j]) {
1051 break1056 break
1052 }1057 }
1053 }1058 }
@@ -1058,6 +1063,49 @@ func clipBottomClippedImages(lines []string) []string {
1058 return out1063 return out
1059 }1064 }
1060 1065
1066+// isBoxBlankLine reports whether line is visually empty after
1067+// stripping ANSI escape sequences, surrounding whitespace, and the
1068+// vertical box edges drawn by the tool-box renderer. Used by
1069+// clipBottomClippedImages so an image's reservation rows still count
1070+// as blank when those rows are wrapped in "│ … │" inside a tool box.
1071+func isBoxBlankLine(line string) bool {
1072+ stripped := stripANSIBytes(line)
1073+ stripped = strings.TrimSpace(stripped)
1074+ stripped = strings.Trim(stripped, "│")
1075+ stripped = strings.TrimSpace(stripped)
1076+ return stripped == ""
1077+}
1078+
1079+// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' … final
1080+// byte) from s without pulling in the regexp package. Mirrors the
1081+// internal helper in package tui; the duplicated copy avoids exporting
1082+// it just for one caller.
1083+func stripANSIBytes(s string) string {
1084+ if !strings.Contains(s, "\x1b") {
1085+ return s
1086+ }
1087+ var b strings.Builder
1088+ b.Grow(len(s))
1089+ i := 0
1090+ for i < len(s) {
1091+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1092+ end := i + 2
1093+ for end < len(s) {
1094+ c := s[end]
1095+ end++
1096+ if c >= 0x40 && c <= 0x7e {
1097+ break
1098+ }
1099+ }
1100+ i = end
1101+ continue
1102+ }
1103+ b.WriteByte(s[i])
1104+ i++
1105+ }
1106+ return b.String()
1107+}
1108+
1061 // truncateLine shortens s so it fits within n display cells, with an1109 // truncateLine shortens s so it fits within n display cells, with an
1062 // ellipsis if trimmed. Used by the "sliding in" chips so a pasted1110 // ellipsis if trimmed. Used by the "sliding in" chips so a pasted
1063 // novel doesn't blow past the status line.1111 // novel doesn't blow past the status line.
M internal/tui/view.go +6 -3
@@ -1149,14 +1149,17 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
11491149 }
11501150 // Reserve the image footprint first, then place the escape
11511151 // in the top-left of that blank rectangle. Keeping the
1152- // metadata above the image made some terminals blend text and
1153- // graphics layers during full-screen repaints; metadata below
1154- // the reserved rectangle is more stable and easier to read.
1152+ // metadata above the image made some terminals blend text
1153+ // and graphics layers during full-screen repaints; metadata
1154+ // below the reserved rectangle is more stable and easier to
1155+ // read. One extra blank row before the metadata gives the
1156+ // caption breathing room from the image's last pixel row.
11551157 out := make([]string, 0, rows+3)
11561158 out = append(out, " "+seq)
11571159 for i := 1; i < rows; i++ {
11581160 out = append(out, "")
11591161 }
1162+ out = append(out, "")
11601163 out = append(out, v.Theme.FG256(v.Theme.Muted, info))
11611164 return out
11621165 }
@@ -1149,14 +1149,17 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
1149 }1149 }
1150 // Reserve the image footprint first, then place the escape1150 // Reserve the image footprint first, then place the escape
1151 // in the top-left of that blank rectangle. Keeping the1151 // in the top-left of that blank rectangle. Keeping the
1152- // metadata above the image made some terminals blend text and1152+ // metadata above the image made some terminals blend text
1153- // graphics layers during full-screen repaints; metadata below1153+ // and graphics layers during full-screen repaints; metadata
1154- // the reserved rectangle is more stable and easier to read.1154+ // below the reserved rectangle is more stable and easier to
1155+ // read. One extra blank row before the metadata gives the
1156+ // caption breathing room from the image's last pixel row.
1155 out := make([]string, 0, rows+3)1157 out := make([]string, 0, rows+3)
1156 out = append(out, " "+seq)1158 out = append(out, " "+seq)
1157 for i := 1; i < rows; i++ {1159 for i := 1; i < rows; i++ {
1158 out = append(out, "")1160 out = append(out, "")
1159 }1161 }
1162+ out = append(out, "")
1160 out = append(out, v.Theme.FG256(v.Theme.Muted, info))1163 out = append(out, v.Theme.FG256(v.Theme.Muted, info))
1161 return out1164 return out
1162 }1165 }

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