detached 9e03b3d

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 {
113113 if maxCellsHigh > 0 {
114114 fmt.Fprintf(&sb, ";height=%d", maxCellsHigh)
115115 }
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:")
117123 sb.WriteString(b64)
118124 sb.WriteString("\x07")
119125 return sb.String()
@@ -136,7 +142,14 @@ func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string {
136142
137143 // Pick the most constraining dimension and use only it. Kitty
138144 // 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"
140153 if maxCellsWide > 0 && maxCellsHigh > 0 {
141154 if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 {
142155 // rows that the native width would produce at maxCellsWide.
@@ -211,22 +224,42 @@ func CellAspectRatio() float64 {
211224 // rendered at cellsWide columns will occupy, preserving aspect ratio.
212225 // Clamped to maxRows. Returns 0 if the image cannot be decoded.
213226 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) {
214239 w, h := ImageDimensions(data)
215240 if w <= 0 || h <= 0 || cellsWide <= 0 {
216- return 0
241+ return 0, 0
217242 }
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.
223243 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))
225246 if rows < 1 {
226247 rows = 1
227248 }
249+ actualCells := cellsWide
228250 if maxRows > 0 && rows > maxRows {
229251 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
230263 }
231- return rows
264+ return rows, actualCells
232265 }
@@ -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. Kitty143 // 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 0241+ 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 = 1247 rows = 1
227 }248 }
249+ actualCells := cellsWide
228 if maxRows > 0 && rows > maxRows {250 if maxRows > 0 && rows > maxRows {
229 rows = maxRows251 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 rows264+ 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
572572 // reservation rows beneath it, and the gap row
573573 // before the metadata caption) are tagged with the
574574 // 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))
580583 }
581- lines = append(lines, toolBoxSide(v.Theme, line, width))
582584 }
583585 lines = append(lines, toolBoxSide(v.Theme, "", width))
584586 lines = append(lines, toolBoxBottom(v.Theme, width))
@@ -646,10 +648,12 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
646648 }
647649 body := toolResultBlock(v.Theme, tc.Result, width, color)
648650 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
651655 }
652- lines = append(lines, toolBoxSide(v.Theme, l, width))
656+ lines = append(lines, toolBoxSide(v.Theme, stripped, width))
653657 }
654658 lines = append(lines, toolBoxSide(v.Theme, "", width))
655659 lines = append(lines, toolBoxBottom(v.Theme, width))
@@ -698,10 +702,12 @@ func (v *View) wrapLiveBody(body []string, width int) []string {
698702 body = v.collapseToolBody(body, false)
699703 out := make([]string, 0, len(body))
700704 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
703709 }
704- out = append(out, toolBoxSide(v.Theme, l, width))
710+ out = append(out, toolBoxSide(v.Theme, stripped, width))
705711 }
706712 return out
707713 }
@@ -802,12 +808,36 @@ func hasImageEscapeLine(s string) bool {
802808 // reserved footprint — the escape row plus the blank rows below it
803809 // plus the gap row before the metadata caption. Any consumer that
804810 // 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.
809820 const imageFootprintSentinel = "\x1e"
810821
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+
811841 // toolBoxBodyTrimLeft is the number of leading literal spaces stripped
812842 // from each body line as it enters toolBoxSide. Body renderers
813843 // (renderToolText, renderRawFile, the diff/bash helpers, ...) all
@@ -837,16 +867,6 @@ func toolBoxSide(th Theme, line string, width int) string {
837867 inner := w - 2 - 2*toolBoxInnerPad // available between the two pads
838868 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
839869
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-
850870 cur := visibleWidth(line)
851871 if cur > inner {
852872 // Last-resort truncation. Strips ANSI styling at the cut
@@ -865,6 +885,40 @@ func toolBoxSide(th Theme, line string, width int) string {
865885 return margin + left + line + strings.Repeat(" ", pad) + right + margin
866886 }
867887
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+
868922 // trimLeadingSpaces removes up to n literal space characters from the
869923 // start of s without touching anything else. ANSI CSI escapes that
870924 // 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 {
11751229 }
11761230 const maxRows = 20
11771231 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)
11791233 if rows < 1 {
11801234 rows = 1
11811235 }
1236+ if actualCells < 1 {
1237+ actualCells = cells
1238+ }
11821239 // Reserve the image footprint first, then place the escape
11831240 // in the top-left of that blank rectangle. Keeping the
11841241 // metadata above the image made some terminals blend text
@@ -1195,8 +1252,12 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
11951252 // 4 leading cells push the escape past the box's left
11961253 // edge plus a small interior gutter so the image rectangle
11971254 // 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)
11981259 out := make([]string, 0, rows+3)
1199- out = append(out, imageFootprintSentinel+" "+seq)
1260+ out = append(out, widthHint+" "+seq)
12001261 for i := 1; i < rows; i++ {
12011262 out = append(out, imageFootprintSentinel)
12021263 }
@@ -572,13 +572,15 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
572 // reservation rows beneath it, and the gap row572 // reservation rows beneath it, and the gap row
573 // before the metadata caption) are tagged with the573 // before the metadata caption) are tagged with the
574 // imageFootprintSentinel by renderImageBlock. Strip574 // imageFootprintSentinel by renderImageBlock. Strip
575- // the tag, then wrap the row in the usual │ … │ box575+ // the tag, parse the optional width hint, then wrap
576- // edges so the box frame stays continuous around576+ // 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 out712 return out
707 }713 }
@@ -802,12 +808,36 @@ func hasImageEscapeLine(s string) bool {
802 // reserved footprint — the escape row plus the blank rows below it808 // reserved footprint — the escape row plus the blank rows below it
803 // plus the gap row before the metadata caption. Any consumer that809 // plus the gap row before the metadata caption. Any consumer that
804 // wraps content in box edges (│ … │) detects the sentinel, strips810 // wraps content in box edges (│ … │) detects the sentinel, strips
805-// it, and emits the row un-bordered so the box's vertical edges811+// it, and emits the row — the image graphics rectangle paints over
806-// don't show through next to the image's graphics rectangle. Uses a812+// whatever was drawn there. Uses a non-printing C0 control byte so
807-// non-printing C0 control byte so it can never appear in normal813+// 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 stripped841 // toolBoxBodyTrimLeft is the number of leading literal spaces stripped
812 // from each body line as it enters toolBoxSide. Body renderers842 // from each body line as it enters toolBoxSide. Body renderers
813 // (renderToolText, renderRawFile, the diff/bash helpers, ...) all843 // (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 pads867 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 cut872 // 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 + margin885 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 the922 // trimLeadingSpaces removes up to n literal space characters from the
869 // start of s without touching anything else. ANSI CSI escapes that923 // start of s without touching anything else. ANSI CSI escapes that
870 // appear before the spaces are skipped over (so a coloured indent924 // 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 = 201230 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 = 11234 rows = 1
1181 }1235 }
1236+ if actualCells < 1 {
1237+ actualCells = cells
1238+ }
1182 // Reserve the image footprint first, then place the escape1239 // Reserve the image footprint first, then place the escape
1183 // in the top-left of that blank rectangle. Keeping the1240 // in the top-left of that blank rectangle. Keeping the
1184 // metadata above the image made some terminals blend text1241 // 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 left1252 // 4 leading cells push the escape past the box's left
1196 // edge plus a small interior gutter so the image rectangle1253 // 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 }

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