tui: keep cursor pinned during inline image render
iTerm's OSC 1337 and kitty/ghostty's APC G escapes both advance the cursor by the image's actual rendered cell count, which depends on the terminal's font cell aspect ratio. Without knowing that ratio, zot's box renderer couldn't reliably place the closing │ at the box's right column: spaces-based padding either overshot (cutting the bar off the screen) or undershot (drawing the bar mid-image). Use the protocols' "don't move cursor" flags to keep the terminal cursor at the start of the image after rendering: - iTerm2: doNotMoveCursor=1 - kitty/ghostty: C=1 With cursor pinned, the row's logical advance is just the leading indent spaces, and toolBoxSideWithImage can pad to the right edge column without caring how the image was scaled. The image's graphics layer paints over the padding spaces visually. Re-enable inline images in iTerm.app since the new approach removes the alignment hack that previously misbehaved there.
- commit
- 9e03b3dc1a0ba987a0b0069c2adcfa3be572c917
- author
- patriceckhart · · 2026-05-03 08:54 UTC
- parent
- 42173ed
- signature
- unsigned
2 files · +132 -38
M internal/tui/image.go +43 -10
| @@ -113,7 +113,13 @@ func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string { | |||
| 113 | 113 | if maxCellsHigh > 0 { | |
| 114 | 114 | fmt.Fprintf(&sb, ";height=%d", maxCellsHigh) | |
| 115 | 115 | } | |
| 116 | - | sb.WriteString(";preserveAspectRatio=1:") | |
| 116 | + | sb.WriteString(";preserveAspectRatio=1") | |
| 117 | + | // doNotMoveCursor=1 keeps the cursor at the start of the image | |
| 118 | + | // after rendering. We need that so the caller can pad with spaces | |
| 119 | + | // and draw the box's closing │ at a fixed terminal column without | |
| 120 | + | // having to know how many cells the image actually occupied (the | |
| 121 | + | // rendered width depends on the terminal's cell aspect ratio). | |
| 122 | + | sb.WriteString(";doNotMoveCursor=1:") | |
| 117 | 123 | sb.WriteString(b64) | |
| 118 | 124 | sb.WriteString("\x07") | |
| 119 | 125 | return sb.String() | |
| @@ -136,7 +142,14 @@ func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string { | |||
| 136 | 142 | ||
| 137 | 143 | // Pick the most constraining dimension and use only it. Kitty | |
| 138 | 144 | // preserves aspect ratio when exactly one of c/r is provided. | |
| 139 | - | hdr := "a=T,f=100" | |
| 145 | + | // | |
| 146 | + | // C=1 tells kitty/ghostty to not move the cursor after rendering | |
| 147 | + | // the image. We need that so the caller can pad with spaces and | |
| 148 | + | // draw the box's closing │ at a fixed terminal column without | |
| 149 | + | // having to know how many cells the image actually occupied (the | |
| 150 | + | // rendered width depends on the terminal's cell aspect ratio, | |
| 151 | + | // which differs across fonts/terminals). | |
| 152 | + | hdr := "a=T,f=100,C=1" | |
| 140 | 153 | if maxCellsWide > 0 && maxCellsHigh > 0 { | |
| 141 | 154 | if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 { | |
| 142 | 155 | // rows that the native width would produce at maxCellsWide. | |
| @@ -211,22 +224,42 @@ func CellAspectRatio() float64 { | |||
| 211 | 224 | // rendered at cellsWide columns will occupy, preserving aspect ratio. | |
| 212 | 225 | // Clamped to maxRows. Returns 0 if the image cannot be decoded. | |
| 213 | 226 | func RowsForInlineImage(data []byte, cellsWide, maxRows int) int { | |
| 227 | + | rows, _ := InlineImageFootprint(data, cellsWide, maxRows) | |
| 228 | + | return rows | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // InlineImageFootprint returns the rendered (rows, cells) footprint | |
| 232 | + | // of an image at the requested cellsWide / maxRows budget, | |
| 233 | + | // preserving aspect ratio. When the image's natural height exceeds | |
| 234 | + | // maxRows, the rendered width shrinks below cellsWide so the | |
| 235 | + | // aspect ratio is preserved within the row clamp; callers wrapping | |
| 236 | + | // the image in a frame need that actual width to place a closing | |
| 237 | + | // border at the right column. | |
| 238 | + | func InlineImageFootprint(data []byte, cellsWide, maxRows int) (int, int) { | |
| 214 | 239 | w, h := ImageDimensions(data) | |
| 215 | 240 | if w <= 0 || h <= 0 || cellsWide <= 0 { | |
| 216 | - | return 0 | |
| 241 | + | return 0, 0 | |
| 217 | 242 | } | |
| 218 | - | // pixels per cell (horizontal) | |
| 219 | - | // scaleX = imageWidthPx / cellsWide | |
| 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. | |
| 223 | 243 | scaleX := float64(w) / float64(cellsWide) | |
| 224 | - | rows := int(math.Ceil(float64(h) / (scaleX * CellAspectRatio()))) | |
| 244 | + | rowsF := float64(h) / (scaleX * CellAspectRatio()) | |
| 245 | + | rows := int(math.Ceil(rowsF)) | |
| 225 | 246 | if rows < 1 { | |
| 226 | 247 | rows = 1 | |
| 227 | 248 | } | |
| 249 | + | actualCells := cellsWide | |
| 228 | 250 | if maxRows > 0 && rows > maxRows { | |
| 229 | 251 | rows = maxRows | |
| 252 | + | // Image is constrained by height; recompute the width so | |
| 253 | + | // aspect ratio is preserved. cellsHigh = maxRows; cellsWide | |
| 254 | + | // = pxW * maxRows * CellAspectRatio / pxH. | |
| 255 | + | newCells := int(math.Floor(float64(w)*float64(maxRows)*CellAspectRatio()/float64(h) + 0.5)) | |
| 256 | + | if newCells < 1 { | |
| 257 | + | newCells = 1 | |
| 258 | + | } | |
| 259 | + | if newCells > cellsWide { | |
| 260 | + | newCells = cellsWide | |
| 261 | + | } | |
| 262 | + | actualCells = newCells | |
| 230 | 263 | } | |
| 231 | - | return rows | |
| 264 | + | return rows, actualCells | |
| 232 | 265 | } | |
| @@ -113,7 +113,13 @@ func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string { | |||||
| 113 | if maxCellsHigh > 0 { | 113 | if maxCellsHigh > 0 { | ||
| 114 | fmt.Fprintf(&sb, ";height=%d", maxCellsHigh) | 114 | fmt.Fprintf(&sb, ";height=%d", maxCellsHigh) | ||
| 115 | } | 115 | } | ||
| 116 | - | sb.WriteString(";preserveAspectRatio=1:") | 116 | + | sb.WriteString(";preserveAspectRatio=1") |
| 117 | + | // doNotMoveCursor=1 keeps the cursor at the start of the image | |||
| 118 | + | // after rendering. We need that so the caller can pad with spaces | |||
| 119 | + | // and draw the box's closing │ at a fixed terminal column without | |||
| 120 | + | // having to know how many cells the image actually occupied (the | |||
| 121 | + | // rendered width depends on the terminal's cell aspect ratio). | |||
| 122 | + | sb.WriteString(";doNotMoveCursor=1:") | |||
| 117 | sb.WriteString(b64) | 123 | sb.WriteString(b64) | ||
| 118 | sb.WriteString("\x07") | 124 | sb.WriteString("\x07") | ||
| 119 | return sb.String() | 125 | return sb.String() | ||
| @@ -136,7 +142,14 @@ func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string { | |||||
| 136 | 142 | ||||
| 137 | // Pick the most constraining dimension and use only it. Kitty | 143 | // Pick the most constraining dimension and use only it. Kitty | ||
| 138 | // preserves aspect ratio when exactly one of c/r is provided. | 144 | // preserves aspect ratio when exactly one of c/r is provided. | ||
| 139 | - | hdr := "a=T,f=100" | 145 | + | // |
| 146 | + | // C=1 tells kitty/ghostty to not move the cursor after rendering | |||
| 147 | + | // the image. We need that so the caller can pad with spaces and | |||
| 148 | + | // draw the box's closing │ at a fixed terminal column without | |||
| 149 | + | // having to know how many cells the image actually occupied (the | |||
| 150 | + | // rendered width depends on the terminal's cell aspect ratio, | |||
| 151 | + | // which differs across fonts/terminals). | |||
| 152 | + | hdr := "a=T,f=100,C=1" | |||
| 140 | if maxCellsWide > 0 && maxCellsHigh > 0 { | 153 | if maxCellsWide > 0 && maxCellsHigh > 0 { | ||
| 141 | if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 { | 154 | if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 { | ||
| 142 | // rows that the native width would produce at maxCellsWide. | 155 | // rows that the native width would produce at maxCellsWide. | ||
| @@ -211,22 +224,42 @@ func CellAspectRatio() float64 { | |||||
| 211 | // rendered at cellsWide columns will occupy, preserving aspect ratio. | 224 | // rendered at cellsWide columns will occupy, preserving aspect ratio. | ||
| 212 | // Clamped to maxRows. Returns 0 if the image cannot be decoded. | 225 | // Clamped to maxRows. Returns 0 if the image cannot be decoded. | ||
| 213 | func RowsForInlineImage(data []byte, cellsWide, maxRows int) int { | 226 | func RowsForInlineImage(data []byte, cellsWide, maxRows int) int { | ||
| 227 | + | rows, _ := InlineImageFootprint(data, cellsWide, maxRows) | |||
| 228 | + | return rows | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // InlineImageFootprint returns the rendered (rows, cells) footprint | |||
| 232 | + | // of an image at the requested cellsWide / maxRows budget, | |||
| 233 | + | // preserving aspect ratio. When the image's natural height exceeds | |||
| 234 | + | // maxRows, the rendered width shrinks below cellsWide so the | |||
| 235 | + | // aspect ratio is preserved within the row clamp; callers wrapping | |||
| 236 | + | // the image in a frame need that actual width to place a closing | |||
| 237 | + | // border at the right column. | |||
| 238 | + | func InlineImageFootprint(data []byte, cellsWide, maxRows int) (int, int) { | |||
| 214 | w, h := ImageDimensions(data) | 239 | w, h := ImageDimensions(data) | ||
| 215 | if w <= 0 || h <= 0 || cellsWide <= 0 { | 240 | if w <= 0 || h <= 0 || cellsWide <= 0 { | ||
| 216 | - | return 0 | 241 | + | return 0, 0 |
| 217 | } | 242 | } | ||
| 218 | - | // pixels per cell (horizontal) | |||
| 219 | - | // scaleX = imageWidthPx / cellsWide | |||
| 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. | |||
| 223 | scaleX := float64(w) / float64(cellsWide) | 243 | scaleX := float64(w) / float64(cellsWide) | ||
| 224 | - | rows := int(math.Ceil(float64(h) / (scaleX * CellAspectRatio()))) | 244 | + | rowsF := float64(h) / (scaleX * CellAspectRatio()) |
| 245 | + | rows := int(math.Ceil(rowsF)) | |||
| 225 | if rows < 1 { | 246 | if rows < 1 { | ||
| 226 | rows = 1 | 247 | rows = 1 | ||
| 227 | } | 248 | } | ||
| 249 | + | actualCells := cellsWide | |||
| 228 | if maxRows > 0 && rows > maxRows { | 250 | if maxRows > 0 && rows > maxRows { | ||
| 229 | rows = maxRows | 251 | rows = maxRows | ||
| 252 | + | // Image is constrained by height; recompute the width so | |||
| 253 | + | // aspect ratio is preserved. cellsHigh = maxRows; cellsWide | |||
| 254 | + | // = pxW * maxRows * CellAspectRatio / pxH. | |||
| 255 | + | newCells := int(math.Floor(float64(w)*float64(maxRows)*CellAspectRatio()/float64(h) + 0.5)) | |||
| 256 | + | if newCells < 1 { | |||
| 257 | + | newCells = 1 | |||
| 258 | + | } | |||
| 259 | + | if newCells > cellsWide { | |||
| 260 | + | newCells = cellsWide | |||
| 261 | + | } | |||
| 262 | + | actualCells = newCells | |||
| 230 | } | 263 | } | ||
| 231 | - | return rows | 264 | + | return rows, actualCells |
| 232 | } | 265 | } | ||
M internal/tui/view.go +89 -28
| @@ -572,13 +572,15 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 572 | 572 | // reservation rows beneath it, and the gap row | |
| 573 | 573 | // before the metadata caption) are tagged with the | |
| 574 | 574 | // imageFootprintSentinel by renderImageBlock. Strip | |
| 575 | - | // the tag, then wrap the row in the usual │ … │ box | |
| 576 | - | // edges so the box frame stays continuous around | |
| 577 | - | // the image. | |
| 578 | - | if strings.HasPrefix(line, imageFootprintSentinel) { | |
| 579 | - | line = line[len(imageFootprintSentinel):] | |
| 575 | + | // the tag, parse the optional width hint, then wrap | |
| 576 | + | // the row in the usual │ … │ box edges so the | |
| 577 | + | // frame stays continuous around the image. | |
| 578 | + | imgCells, stripped := parseImageFootprint(line) | |
| 579 | + | if hasImageEscapeLine(stripped) { | |
| 580 | + | lines = append(lines, toolBoxSideWithImage(v.Theme, stripped, imgCells, width)) | |
| 581 | + | } else { | |
| 582 | + | lines = append(lines, toolBoxSide(v.Theme, stripped, width)) | |
| 580 | 583 | } | |
| 581 | - | lines = append(lines, toolBoxSide(v.Theme, line, width)) | |
| 582 | 584 | } | |
| 583 | 585 | lines = append(lines, toolBoxSide(v.Theme, "", width)) | |
| 584 | 586 | lines = append(lines, toolBoxBottom(v.Theme, width)) | |
| @@ -646,10 +648,12 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 646 | 648 | } | |
| 647 | 649 | body := toolResultBlock(v.Theme, tc.Result, width, color) | |
| 648 | 650 | for _, l := range v.collapseToolBody(body, false) { | |
| 649 | - | if strings.HasPrefix(l, imageFootprintSentinel) { | |
| 650 | - | l = l[len(imageFootprintSentinel):] | |
| 651 | + | imgCells, stripped := parseImageFootprint(l) | |
| 652 | + | if hasImageEscapeLine(stripped) { | |
| 653 | + | lines = append(lines, toolBoxSideWithImage(v.Theme, stripped, imgCells, width)) | |
| 654 | + | continue | |
| 651 | 655 | } | |
| 652 | - | lines = append(lines, toolBoxSide(v.Theme, l, width)) | |
| 656 | + | lines = append(lines, toolBoxSide(v.Theme, stripped, width)) | |
| 653 | 657 | } | |
| 654 | 658 | lines = append(lines, toolBoxSide(v.Theme, "", width)) | |
| 655 | 659 | lines = append(lines, toolBoxBottom(v.Theme, width)) | |
| @@ -698,10 +702,12 @@ func (v *View) wrapLiveBody(body []string, width int) []string { | |||
| 698 | 702 | body = v.collapseToolBody(body, false) | |
| 699 | 703 | out := make([]string, 0, len(body)) | |
| 700 | 704 | for _, l := range body { | |
| 701 | - | if strings.HasPrefix(l, imageFootprintSentinel) { | |
| 702 | - | l = l[len(imageFootprintSentinel):] | |
| 705 | + | imgCells, stripped := parseImageFootprint(l) | |
| 706 | + | if hasImageEscapeLine(stripped) { | |
| 707 | + | out = append(out, toolBoxSideWithImage(v.Theme, stripped, imgCells, width)) | |
| 708 | + | continue | |
| 703 | 709 | } | |
| 704 | - | out = append(out, toolBoxSide(v.Theme, l, width)) | |
| 710 | + | out = append(out, toolBoxSide(v.Theme, stripped, width)) | |
| 705 | 711 | } | |
| 706 | 712 | return out | |
| 707 | 713 | } | |
| @@ -802,12 +808,36 @@ func hasImageEscapeLine(s string) bool { | |||
| 802 | 808 | // reserved footprint — the escape row plus the blank rows below it | |
| 803 | 809 | // plus the gap row before the metadata caption. Any consumer that | |
| 804 | 810 | // wraps content in box edges (│ … │) detects the sentinel, strips | |
| 805 | - | // it, and emits the row un-bordered so the box's vertical edges | |
| 806 | - | // don't show through next to the image's graphics rectangle. Uses a | |
| 807 | - | // non-printing C0 control byte so it can never appear in normal | |
| 808 | - | // text or in an ANSI escape sequence body. | |
| 811 | + | // it, and emits the row — the image graphics rectangle paints over | |
| 812 | + | // whatever was drawn there. Uses a non-printing C0 control byte so | |
| 813 | + | // it can never appear in normal text or in an ANSI escape sequence | |
| 814 | + | // body. | |
| 815 | + | // | |
| 816 | + | // The escape row carries an extra width hint between two sentinels: | |
| 817 | + | // "\x1e<cells>\x1e<spaces+escape>". Wrappers parse the cells count | |
| 818 | + | // to know how wide the image is in terminal cells and how many cells | |
| 819 | + | // of trailing padding are needed before the closing box edge. | |
| 809 | 820 | const imageFootprintSentinel = "\x1e" | |
| 810 | 821 | ||
| 822 | + | // parseImageFootprint splits a footprint-tagged row into (cellsWide, | |
| 823 | + | // rest). cellsWide is 0 when the row carries no width hint (the | |
| 824 | + | // reservation blanks and gap row don't, only the escape row does). | |
| 825 | + | func parseImageFootprint(s string) (int, string) { | |
| 826 | + | if !strings.HasPrefix(s, imageFootprintSentinel) { | |
| 827 | + | return 0, s | |
| 828 | + | } | |
| 829 | + | rest := s[len(imageFootprintSentinel):] | |
| 830 | + | end := strings.Index(rest, imageFootprintSentinel) | |
| 831 | + | if end < 0 { | |
| 832 | + | return 0, rest | |
| 833 | + | } | |
| 834 | + | w, err := strconv.Atoi(rest[:end]) | |
| 835 | + | if err != nil { | |
| 836 | + | return 0, rest | |
| 837 | + | } | |
| 838 | + | return w, rest[end+len(imageFootprintSentinel):] | |
| 839 | + | } | |
| 840 | + | ||
| 811 | 841 | // toolBoxBodyTrimLeft is the number of leading literal spaces stripped | |
| 812 | 842 | // from each body line as it enters toolBoxSide. Body renderers | |
| 813 | 843 | // (renderToolText, renderRawFile, the diff/bash helpers, ...) all | |
| @@ -837,16 +867,6 @@ func toolBoxSide(th Theme, line string, width int) string { | |||
| 837 | 867 | inner := w - 2 - 2*toolBoxInnerPad // available between the two pads | |
| 838 | 868 | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | |
| 839 | 869 | ||
| 840 | - | // Inline-image escapes (iTerm OSC 1337, Kitty APC G) carry | |
| 841 | - | // thousands of bytes of base64 payload that visibleWidth and | |
| 842 | - | // stripANSI don't recognise as ANSI. Measuring or truncating | |
| 843 | - | // such a row destroys the escape and the image disappears. | |
| 844 | - | // Pass the row through with edges + a fixed inner padding | |
| 845 | - | // based on the visible prefix only. | |
| 846 | - | if hasImageEscapeLine(line) { | |
| 847 | - | return margin + left + line + right + margin | |
| 848 | - | } | |
| 849 | - | ||
| 850 | 870 | cur := visibleWidth(line) | |
| 851 | 871 | if cur > inner { | |
| 852 | 872 | // Last-resort truncation. Strips ANSI styling at the cut | |
| @@ -865,6 +885,40 @@ func toolBoxSide(th Theme, line string, width int) string { | |||
| 865 | 885 | return margin + left + line + strings.Repeat(" ", pad) + right + margin | |
| 866 | 886 | } | |
| 867 | 887 | ||
| 888 | + | // toolBoxSideWithImage wraps a row that carries an inline-image | |
| 889 | + | // escape sequence. The image protocols are configured to not move | |
| 890 | + | // the cursor after rendering (iTerm: doNotMoveCursor=1, kitty/ | |
| 891 | + | // ghostty: C=1), so the only visible cell advance on the row is | |
| 892 | + | // from the leading indent spaces before the escape. We pad from | |
| 893 | + | // there to the right edge column and draw the closing │ there. | |
| 894 | + | // imgCells is unused (kept in the signature for compatibility with | |
| 895 | + | // the parseImageFootprint width hint). | |
| 896 | + | func toolBoxSideWithImage(th Theme, line string, imgCells, width int) string { | |
| 897 | + | _ = imgCells | |
| 898 | + | w := width - 2*toolBoxOuterMargin | |
| 899 | + | if w < 12 { | |
| 900 | + | w = 12 | |
| 901 | + | } | |
| 902 | + | margin := strings.Repeat(" ", toolBoxOuterMargin) | |
| 903 | + | left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad) | |
| 904 | + | right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│") | |
| 905 | + | inner := w - 2 - 2*toolBoxInnerPad | |
| 906 | + | ||
| 907 | + | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | |
| 908 | + | ||
| 909 | + | // Count leading spaces (visible cells consumed before the escape). | |
| 910 | + | leading := 0 | |
| 911 | + | for leading < len(line) && line[leading] == ' ' { | |
| 912 | + | leading++ | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | pad := inner - leading | |
| 916 | + | if pad < 0 { | |
| 917 | + | pad = 0 | |
| 918 | + | } | |
| 919 | + | return margin + left + line + strings.Repeat(" ", pad) + right + margin | |
| 920 | + | } | |
| 921 | + | ||
| 868 | 922 | // trimLeadingSpaces removes up to n literal space characters from the | |
| 869 | 923 | // start of s without touching anything else. ANSI CSI escapes that | |
| 870 | 924 | // appear before the spaces are skipped over (so a coloured indent | |
| @@ -1175,10 +1229,13 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |||
| 1175 | 1229 | } | |
| 1176 | 1230 | const maxRows = 20 | |
| 1177 | 1231 | if seq := RenderInlineImageScaled(v.ImageProto, b.Data, b.MimeType, cells, maxRows); seq != "" { | |
| 1178 | - | rows := RowsForInlineImage(b.Data, cells, maxRows) | |
| 1232 | + | rows, actualCells := InlineImageFootprint(b.Data, cells, maxRows) | |
| 1179 | 1233 | if rows < 1 { | |
| 1180 | 1234 | rows = 1 | |
| 1181 | 1235 | } | |
| 1236 | + | if actualCells < 1 { | |
| 1237 | + | actualCells = cells | |
| 1238 | + | } | |
| 1182 | 1239 | // Reserve the image footprint first, then place the escape | |
| 1183 | 1240 | // in the top-left of that blank rectangle. Keeping the | |
| 1184 | 1241 | // metadata above the image made some terminals blend text | |
| @@ -1195,8 +1252,12 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |||
| 1195 | 1252 | // 4 leading cells push the escape past the box's left | |
| 1196 | 1253 | // edge plus a small interior gutter so the image rectangle | |
| 1197 | 1254 | // sits visibly inside the frame instead of kissing the │. | |
| 1255 | + | // The escape row carries a width hint after the sentinel | |
| 1256 | + | // ("\x1e<cells>\x1e\u2026") so toolBoxSide knows how many | |
| 1257 | + | // cells the image occupies and can pad to the right edge. | |
| 1258 | + | widthHint := fmt.Sprintf("%s%d%s", imageFootprintSentinel, actualCells, imageFootprintSentinel) | |
| 1198 | 1259 | out := make([]string, 0, rows+3) | |
| 1199 | - | out = append(out, imageFootprintSentinel+" "+seq) | |
| 1260 | + | out = append(out, widthHint+" "+seq) | |
| 1200 | 1261 | for i := 1; i < rows; i++ { | |
| 1201 | 1262 | out = append(out, imageFootprintSentinel) | |
| 1202 | 1263 | } | |
| @@ -572,13 +572,15 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 572 | // reservation rows beneath it, and the gap row | 572 | // reservation rows beneath it, and the gap row | ||
| 573 | // before the metadata caption) are tagged with the | 573 | // before the metadata caption) are tagged with the | ||
| 574 | // imageFootprintSentinel by renderImageBlock. Strip | 574 | // imageFootprintSentinel by renderImageBlock. Strip | ||
| 575 | - | // the tag, then wrap the row in the usual │ … │ box | 575 | + | // the tag, parse the optional width hint, then wrap |
| 576 | - | // edges so the box frame stays continuous around | 576 | + | // the row in the usual │ … │ box edges so the |
| 577 | - | // the image. | 577 | + | // frame stays continuous around the image. |
| 578 | - | if strings.HasPrefix(line, imageFootprintSentinel) { | 578 | + | imgCells, stripped := parseImageFootprint(line) |
| 579 | - | line = line[len(imageFootprintSentinel):] | 579 | + | if hasImageEscapeLine(stripped) { |
| 580 | + | lines = append(lines, toolBoxSideWithImage(v.Theme, stripped, imgCells, width)) | |||
| 581 | + | } else { | |||
| 582 | + | lines = append(lines, toolBoxSide(v.Theme, stripped, width)) | |||
| 580 | } | 583 | } | ||
| 581 | - | lines = append(lines, toolBoxSide(v.Theme, line, width)) | |||
| 582 | } | 584 | } | ||
| 583 | lines = append(lines, toolBoxSide(v.Theme, "", width)) | 585 | lines = append(lines, toolBoxSide(v.Theme, "", width)) | ||
| 584 | lines = append(lines, toolBoxBottom(v.Theme, width)) | 586 | lines = append(lines, toolBoxBottom(v.Theme, width)) | ||
| @@ -646,10 +648,12 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||||
| 646 | } | 648 | } | ||
| 647 | body := toolResultBlock(v.Theme, tc.Result, width, color) | 649 | body := toolResultBlock(v.Theme, tc.Result, width, color) | ||
| 648 | for _, l := range v.collapseToolBody(body, false) { | 650 | for _, l := range v.collapseToolBody(body, false) { | ||
| 649 | - | if strings.HasPrefix(l, imageFootprintSentinel) { | 651 | + | imgCells, stripped := parseImageFootprint(l) |
| 650 | - | l = l[len(imageFootprintSentinel):] | 652 | + | if hasImageEscapeLine(stripped) { |
| 653 | + | lines = append(lines, toolBoxSideWithImage(v.Theme, stripped, imgCells, width)) | |||
| 654 | + | continue | |||
| 651 | } | 655 | } | ||
| 652 | - | lines = append(lines, toolBoxSide(v.Theme, l, width)) | 656 | + | lines = append(lines, toolBoxSide(v.Theme, stripped, width)) |
| 653 | } | 657 | } | ||
| 654 | lines = append(lines, toolBoxSide(v.Theme, "", width)) | 658 | lines = append(lines, toolBoxSide(v.Theme, "", width)) | ||
| 655 | lines = append(lines, toolBoxBottom(v.Theme, width)) | 659 | lines = append(lines, toolBoxBottom(v.Theme, width)) | ||
| @@ -698,10 +702,12 @@ func (v *View) wrapLiveBody(body []string, width int) []string { | |||||
| 698 | body = v.collapseToolBody(body, false) | 702 | body = v.collapseToolBody(body, false) | ||
| 699 | out := make([]string, 0, len(body)) | 703 | out := make([]string, 0, len(body)) | ||
| 700 | for _, l := range body { | 704 | for _, l := range body { | ||
| 701 | - | if strings.HasPrefix(l, imageFootprintSentinel) { | 705 | + | imgCells, stripped := parseImageFootprint(l) |
| 702 | - | l = l[len(imageFootprintSentinel):] | 706 | + | if hasImageEscapeLine(stripped) { |
| 707 | + | out = append(out, toolBoxSideWithImage(v.Theme, stripped, imgCells, width)) | |||
| 708 | + | continue | |||
| 703 | } | 709 | } | ||
| 704 | - | out = append(out, toolBoxSide(v.Theme, l, width)) | 710 | + | out = append(out, toolBoxSide(v.Theme, stripped, width)) |
| 705 | } | 711 | } | ||
| 706 | return out | 712 | return out | ||
| 707 | } | 713 | } | ||
| @@ -802,12 +808,36 @@ func hasImageEscapeLine(s string) bool { | |||||
| 802 | // reserved footprint — the escape row plus the blank rows below it | 808 | // reserved footprint — the escape row plus the blank rows below it | ||
| 803 | // plus the gap row before the metadata caption. Any consumer that | 809 | // plus the gap row before the metadata caption. Any consumer that | ||
| 804 | // wraps content in box edges (│ … │) detects the sentinel, strips | 810 | // wraps content in box edges (│ … │) detects the sentinel, strips | ||
| 805 | - | // it, and emits the row un-bordered so the box's vertical edges | 811 | + | // it, and emits the row — the image graphics rectangle paints over |
| 806 | - | // don't show through next to the image's graphics rectangle. Uses a | 812 | + | // whatever was drawn there. Uses a non-printing C0 control byte so |
| 807 | - | // non-printing C0 control byte so it can never appear in normal | 813 | + | // it can never appear in normal text or in an ANSI escape sequence |
| 808 | - | // text or in an ANSI escape sequence body. | 814 | + | // body. |
| 815 | + | // | |||
| 816 | + | // The escape row carries an extra width hint between two sentinels: | |||
| 817 | + | // "\x1e<cells>\x1e<spaces+escape>". Wrappers parse the cells count | |||
| 818 | + | // to know how wide the image is in terminal cells and how many cells | |||
| 819 | + | // of trailing padding are needed before the closing box edge. | |||
| 809 | const imageFootprintSentinel = "\x1e" | 820 | const imageFootprintSentinel = "\x1e" | ||
| 810 | 821 | ||||
| 822 | + | // parseImageFootprint splits a footprint-tagged row into (cellsWide, | |||
| 823 | + | // rest). cellsWide is 0 when the row carries no width hint (the | |||
| 824 | + | // reservation blanks and gap row don't, only the escape row does). | |||
| 825 | + | func parseImageFootprint(s string) (int, string) { | |||
| 826 | + | if !strings.HasPrefix(s, imageFootprintSentinel) { | |||
| 827 | + | return 0, s | |||
| 828 | + | } | |||
| 829 | + | rest := s[len(imageFootprintSentinel):] | |||
| 830 | + | end := strings.Index(rest, imageFootprintSentinel) | |||
| 831 | + | if end < 0 { | |||
| 832 | + | return 0, rest | |||
| 833 | + | } | |||
| 834 | + | w, err := strconv.Atoi(rest[:end]) | |||
| 835 | + | if err != nil { | |||
| 836 | + | return 0, rest | |||
| 837 | + | } | |||
| 838 | + | return w, rest[end+len(imageFootprintSentinel):] | |||
| 839 | + | } | |||
| 840 | + | ||||
| 811 | // toolBoxBodyTrimLeft is the number of leading literal spaces stripped | 841 | // toolBoxBodyTrimLeft is the number of leading literal spaces stripped | ||
| 812 | // from each body line as it enters toolBoxSide. Body renderers | 842 | // from each body line as it enters toolBoxSide. Body renderers | ||
| 813 | // (renderToolText, renderRawFile, the diff/bash helpers, ...) all | 843 | // (renderToolText, renderRawFile, the diff/bash helpers, ...) all | ||
| @@ -837,16 +867,6 @@ func toolBoxSide(th Theme, line string, width int) string { | |||||
| 837 | inner := w - 2 - 2*toolBoxInnerPad // available between the two pads | 867 | inner := w - 2 - 2*toolBoxInnerPad // available between the two pads | ||
| 838 | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | 868 | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | ||
| 839 | 869 | ||||
| 840 | - | // Inline-image escapes (iTerm OSC 1337, Kitty APC G) carry | |||
| 841 | - | // thousands of bytes of base64 payload that visibleWidth and | |||
| 842 | - | // stripANSI don't recognise as ANSI. Measuring or truncating | |||
| 843 | - | // such a row destroys the escape and the image disappears. | |||
| 844 | - | // Pass the row through with edges + a fixed inner padding | |||
| 845 | - | // based on the visible prefix only. | |||
| 846 | - | if hasImageEscapeLine(line) { | |||
| 847 | - | return margin + left + line + right + margin | |||
| 848 | - | } | |||
| 849 | - | ||||
| 850 | cur := visibleWidth(line) | 870 | cur := visibleWidth(line) | ||
| 851 | if cur > inner { | 871 | if cur > inner { | ||
| 852 | // Last-resort truncation. Strips ANSI styling at the cut | 872 | // Last-resort truncation. Strips ANSI styling at the cut | ||
| @@ -865,6 +885,40 @@ func toolBoxSide(th Theme, line string, width int) string { | |||||
| 865 | return margin + left + line + strings.Repeat(" ", pad) + right + margin | 885 | return margin + left + line + strings.Repeat(" ", pad) + right + margin | ||
| 866 | } | 886 | } | ||
| 867 | 887 | ||||
| 888 | + | // toolBoxSideWithImage wraps a row that carries an inline-image | |||
| 889 | + | // escape sequence. The image protocols are configured to not move | |||
| 890 | + | // the cursor after rendering (iTerm: doNotMoveCursor=1, kitty/ | |||
| 891 | + | // ghostty: C=1), so the only visible cell advance on the row is | |||
| 892 | + | // from the leading indent spaces before the escape. We pad from | |||
| 893 | + | // there to the right edge column and draw the closing │ there. | |||
| 894 | + | // imgCells is unused (kept in the signature for compatibility with | |||
| 895 | + | // the parseImageFootprint width hint). | |||
| 896 | + | func toolBoxSideWithImage(th Theme, line string, imgCells, width int) string { | |||
| 897 | + | _ = imgCells | |||
| 898 | + | w := width - 2*toolBoxOuterMargin | |||
| 899 | + | if w < 12 { | |||
| 900 | + | w = 12 | |||
| 901 | + | } | |||
| 902 | + | margin := strings.Repeat(" ", toolBoxOuterMargin) | |||
| 903 | + | left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad) | |||
| 904 | + | right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│") | |||
| 905 | + | inner := w - 2 - 2*toolBoxInnerPad | |||
| 906 | + | ||||
| 907 | + | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | |||
| 908 | + | ||||
| 909 | + | // Count leading spaces (visible cells consumed before the escape). | |||
| 910 | + | leading := 0 | |||
| 911 | + | for leading < len(line) && line[leading] == ' ' { | |||
| 912 | + | leading++ | |||
| 913 | + | } | |||
| 914 | + | ||||
| 915 | + | pad := inner - leading | |||
| 916 | + | if pad < 0 { | |||
| 917 | + | pad = 0 | |||
| 918 | + | } | |||
| 919 | + | return margin + left + line + strings.Repeat(" ", pad) + right + margin | |||
| 920 | + | } | |||
| 921 | + | ||||
| 868 | // trimLeadingSpaces removes up to n literal space characters from the | 922 | // trimLeadingSpaces removes up to n literal space characters from the | ||
| 869 | // start of s without touching anything else. ANSI CSI escapes that | 923 | // start of s without touching anything else. ANSI CSI escapes that | ||
| 870 | // appear before the spaces are skipped over (so a coloured indent | 924 | // appear before the spaces are skipped over (so a coloured indent | ||
| @@ -1175,10 +1229,13 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |||||
| 1175 | } | 1229 | } | ||
| 1176 | const maxRows = 20 | 1230 | const maxRows = 20 | ||
| 1177 | if seq := RenderInlineImageScaled(v.ImageProto, b.Data, b.MimeType, cells, maxRows); seq != "" { | 1231 | if seq := RenderInlineImageScaled(v.ImageProto, b.Data, b.MimeType, cells, maxRows); seq != "" { | ||
| 1178 | - | rows := RowsForInlineImage(b.Data, cells, maxRows) | 1232 | + | rows, actualCells := InlineImageFootprint(b.Data, cells, maxRows) |
| 1179 | if rows < 1 { | 1233 | if rows < 1 { | ||
| 1180 | rows = 1 | 1234 | rows = 1 | ||
| 1181 | } | 1235 | } | ||
| 1236 | + | if actualCells < 1 { | |||
| 1237 | + | actualCells = cells | |||
| 1238 | + | } | |||
| 1182 | // Reserve the image footprint first, then place the escape | 1239 | // Reserve the image footprint first, then place the escape | ||
| 1183 | // in the top-left of that blank rectangle. Keeping the | 1240 | // in the top-left of that blank rectangle. Keeping the | ||
| 1184 | // metadata above the image made some terminals blend text | 1241 | // metadata above the image made some terminals blend text | ||
| @@ -1195,8 +1252,12 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |||||
| 1195 | // 4 leading cells push the escape past the box's left | 1252 | // 4 leading cells push the escape past the box's left | ||
| 1196 | // edge plus a small interior gutter so the image rectangle | 1253 | // edge plus a small interior gutter so the image rectangle | ||
| 1197 | // sits visibly inside the frame instead of kissing the │. | 1254 | // sits visibly inside the frame instead of kissing the │. | ||
| 1255 | + | // The escape row carries a width hint after the sentinel | |||
| 1256 | + | // ("\x1e<cells>\x1e\u2026") so toolBoxSide knows how many | |||
| 1257 | + | // cells the image occupies and can pad to the right edge. | |||
| 1258 | + | widthHint := fmt.Sprintf("%s%d%s", imageFootprintSentinel, actualCells, imageFootprintSentinel) | |||
| 1198 | out := make([]string, 0, rows+3) | 1259 | out := make([]string, 0, rows+3) | ||
| 1199 | - | out = append(out, imageFootprintSentinel+" "+seq) | 1260 | + | out = append(out, widthHint+" "+seq) |
| 1200 | for i := 1; i < rows; i++ { | 1261 | for i := 1; i < rows; i++ { | ||
| 1201 | out = append(out, imageFootprintSentinel) | 1262 | out = append(out, imageFootprintSentinel) | ||
| 1202 | } | 1263 | } | ||