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 { | |||
| 985 | 985 | if start <= 0 || start >= len(chat) { | |
| 986 | 986 | return start | |
| 987 | 987 | } | |
| 988 | - | if hasImageEscape(chat[start]) || strings.TrimSpace(chat[start]) != "" { | |
| 988 | + | if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) { | |
| 989 | 989 | return start | |
| 990 | 990 | } | |
| 991 | 991 | for k := start - 1; k >= 0; k-- { | |
| @@ -993,7 +993,7 @@ func snapViewportStartToImageBlock(chat []string, start int) int { | |||
| 993 | 993 | if hasImageEscape(line) { | |
| 994 | 994 | return k | |
| 995 | 995 | } | |
| 996 | - | if strings.TrimSpace(line) != "" { | |
| 996 | + | if !isBoxBlankLine(line) { | |
| 997 | 997 | break | |
| 998 | 998 | } | |
| 999 | 999 | } | |
| @@ -1041,13 +1041,18 @@ func clipBottomClippedImages(lines []string) []string { | |||
| 1041 | 1041 | // then one trailing blank. If the info line isn't visible in | |
| 1042 | 1042 | // the current chat slice, the image would paint down into the | |
| 1043 | 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 | 1049 | foundInfo := false | |
| 1045 | 1050 | for j := i + 1; j < len(out); j++ { | |
| 1046 | 1051 | if strings.Contains(out[j], "image - ") { | |
| 1047 | 1052 | foundInfo = true | |
| 1048 | 1053 | break | |
| 1049 | 1054 | } | |
| 1050 | - | if strings.TrimSpace(out[j]) != "" { | |
| 1055 | + | if !isBoxBlankLine(out[j]) { | |
| 1051 | 1056 | break | |
| 1052 | 1057 | } | |
| 1053 | 1058 | } | |
| @@ -1058,6 +1063,49 @@ func clipBottomClippedImages(lines []string) []string { | |||
| 1058 | 1063 | 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 | 1109 | // truncateLine shortens s so it fits within n display cells, with an | |
| 1062 | 1110 | // ellipsis if trimmed. Used by the "sliding in" chips so a pasted | |
| 1063 | 1111 | // 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 start | 986 | return start | ||
| 987 | } | 987 | } | ||
| 988 | - | if hasImageEscape(chat[start]) || strings.TrimSpace(chat[start]) != "" { | 988 | + | if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) { |
| 989 | return start | 989 | 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 k | 994 | return k | ||
| 995 | } | 995 | } | ||
| 996 | - | if strings.TrimSpace(line) != "" { | 996 | + | if !isBoxBlankLine(line) { |
| 997 | break | 997 | 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 in | 1041 | // then one trailing blank. If the info line isn't visible in | ||
| 1042 | // the current chat slice, the image would paint down into the | 1042 | // 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 := false | 1049 | 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 = true | 1052 | foundInfo = true | ||
| 1048 | break | 1053 | break | ||
| 1049 | } | 1054 | } | ||
| 1050 | - | if strings.TrimSpace(out[j]) != "" { | 1055 | + | if !isBoxBlankLine(out[j]) { |
| 1051 | break | 1056 | break | ||
| 1052 | } | 1057 | } | ||
| 1053 | } | 1058 | } | ||
| @@ -1058,6 +1063,49 @@ func clipBottomClippedImages(lines []string) []string { | |||||
| 1058 | return out | 1063 | 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 an | 1109 | // truncateLine shortens s so it fits within n display cells, with an | ||
| 1062 | // ellipsis if trimmed. Used by the "sliding in" chips so a pasted | 1110 | // 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 { | |||
| 1149 | 1149 | } | |
| 1150 | 1150 | // Reserve the image footprint first, then place the escape | |
| 1151 | 1151 | // 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. | |
| 1155 | 1157 | out := make([]string, 0, rows+3) | |
| 1156 | 1158 | out = append(out, " "+seq) | |
| 1157 | 1159 | for i := 1; i < rows; i++ { | |
| 1158 | 1160 | out = append(out, "") | |
| 1159 | 1161 | } | |
| 1162 | + | out = append(out, "") | |
| 1160 | 1163 | out = append(out, v.Theme.FG256(v.Theme.Muted, info)) | |
| 1161 | 1164 | return out | |
| 1162 | 1165 | } | |
| @@ -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 escape | 1150 | // Reserve the image footprint first, then place the escape | ||
| 1151 | // in the top-left of that blank rectangle. Keeping the | 1151 | // in the top-left of that blank rectangle. Keeping the | ||
| 1152 | - | // metadata above the image made some terminals blend text and | 1152 | + | // metadata above the image made some terminals blend text |
| 1153 | - | // graphics layers during full-screen repaints; metadata below | 1153 | + | // 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 out | 1164 | return out | ||
| 1162 | } | 1165 | } | ||