v0.0.42 ac6d556

image.go RawHistoryFolder

1package tui
2
3import (
4	"bytes"
5	"encoding/base64"
6	"fmt"
7	"image"
8	_ "image/gif"
9	_ "image/jpeg"
10	_ "image/png"
11	"os"
12	"strings"
13)
14
15// ImageProtocol describes which inline-image escape the current
16// terminal understands.
17type ImageProtocol int
18
19const (
20	ImageProtocolNone   ImageProtocol = iota // no inline images, use text fallback
21	ImageProtocolITerm2                      // iTerm2 proprietary OSC 1337 File= (also: WezTerm)
22	ImageProtocolKitty                       // Kitty graphics protocol
23)
24
25// DetectImageProtocol returns the best inline-image protocol supported
26// by the current terminal, or ImageProtocolNone.
27//
28// The default is to auto-detect: if the terminal advertises iTerm2 or
29// Kitty-graphics support, we use it. The ZOT_INLINE_IMAGES env var
30// overrides the default:
31//
32//	ZOT_INLINE_IMAGES=off   -> force text fallback
33//	ZOT_INLINE_IMAGES=iterm -> force iTerm2 protocol
34//	ZOT_INLINE_IMAGES=kitty -> force Kitty protocol
35//	ZOT_INLINE_IMAGES=auto  -> explicit auto-detect (same as default)
36func DetectImageProtocol() ImageProtocol {
37	switch strings.ToLower(os.Getenv("ZOT_INLINE_IMAGES")) {
38	case "off", "none", "false", "0":
39		return ImageProtocolNone
40	case "iterm", "iterm2":
41		return ImageProtocolITerm2
42	case "kitty":
43		return ImageProtocolKitty
44	}
45	return detectImageProtocolAuto()
46}
47
48// detectImageProtocolAuto returns the best protocol by sniffing the
49// current terminal via env vars. Same detection logic as before.
50func detectImageProtocolAuto() ImageProtocol {
51	termProgram := os.Getenv("TERM_PROGRAM")
52	term := os.Getenv("TERM")
53	kittyWindow := os.Getenv("KITTY_WINDOW_ID")
54
55	if kittyWindow != "" || strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") {
56		return ImageProtocolKitty
57	}
58	if termProgram == "ghostty" || termProgram == "kitty" {
59		return ImageProtocolKitty
60	}
61	if termProgram == "iTerm.app" || termProgram == "WezTerm" {
62		return ImageProtocolITerm2
63	}
64	if strings.Contains(strings.ToLower(termProgram), "ghostty") {
65		return ImageProtocolKitty
66	}
67	return ImageProtocolNone
68}
69
70// RenderInlineImage returns a terminal escape sequence that draws data
71// inline. If the protocol is None, returns "" so the caller can fall
72// back to a text placeholder.
73//
74// maxCellsWide caps the rendered width in terminal cells (columns) for
75// protocols that honor it. 0 means "let the terminal decide".
76func RenderInlineImage(proto ImageProtocol, data []byte, mime string, maxCellsWide int) string {
77	return RenderInlineImageScaled(proto, data, mime, maxCellsWide, 0)
78}
79
80// RenderInlineImageScaled renders an image with both width and height
81// clamps (in terminal cells). Values <= 0 mean "let the terminal decide".
82func RenderInlineImageScaled(proto ImageProtocol, data []byte, mime string, maxCellsWide, maxCellsHigh int) string {
83	switch proto {
84	case ImageProtocolITerm2:
85		return renderITerm2(data, maxCellsWide, maxCellsHigh)
86	case ImageProtocolKitty:
87		return renderKitty(data, maxCellsWide, maxCellsHigh)
88	}
89	return ""
90}
91
92// renderITerm2 builds an OSC 1337 File= sequence. Works in iTerm2 and WezTerm.
93//
94// Reference: https://iterm2.com/documentation-images.html
95func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string {
96	b64 := base64.StdEncoding.EncodeToString(data)
97	var sb strings.Builder
98	sb.WriteString("\x1b]1337;File=inline=1")
99	if maxCellsWide > 0 {
100		fmt.Fprintf(&sb, ";width=%d", maxCellsWide)
101	}
102	if maxCellsHigh > 0 {
103		fmt.Fprintf(&sb, ";height=%d", maxCellsHigh)
104	}
105	sb.WriteString(";preserveAspectRatio=1:")
106	sb.WriteString(b64)
107	sb.WriteString("\x07")
108	return sb.String()
109}
110
111// renderKitty builds a Kitty graphics protocol sequence. Supports chunked
112// data via the "m" continuation flag; chunk size is 4096 to stay under
113// terminal escape-buffer limits.
114//
115// The Kitty protocol preserves aspect ratio automatically when only one
116// of c= (columns) or r= (rows) is set. Setting both causes the image to
117// be stretched non-uniformly. We pick whichever constraint is tighter
118// for the input image so it fits inside maxCellsWide x maxCellsHigh.
119//
120// Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/
121func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string {
122	b64 := base64.StdEncoding.EncodeToString(data)
123	const chunk = 4096
124	var sb strings.Builder
125
126	// Prefix: delete any previously-placed images so old frames don't
127	// linger on screen when the chat scrolls past them.
128	sb.WriteString("\x1b_Ga=d\x1b\\")
129
130	// Pick the most constraining dimension and use only it. Kitty
131	// preserves aspect ratio when exactly one of c/r is provided.
132	hdr := "a=T,f=100"
133	if maxCellsWide > 0 && maxCellsHigh > 0 {
134		if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 {
135			// rows that the native width would produce at maxCellsWide
136			nativeRows := int(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio)
137			if nativeRows > maxCellsHigh {
138				hdr += fmt.Sprintf(",r=%d", maxCellsHigh)
139			} else {
140				hdr += fmt.Sprintf(",c=%d", maxCellsWide)
141			}
142		} else {
143			hdr += fmt.Sprintf(",c=%d", maxCellsWide)
144		}
145	} else if maxCellsWide > 0 {
146		hdr += fmt.Sprintf(",c=%d", maxCellsWide)
147	} else if maxCellsHigh > 0 {
148		hdr += fmt.Sprintf(",r=%d", maxCellsHigh)
149	}
150
151	for i := 0; i < len(b64); i += chunk {
152		end := i + chunk
153		if end > len(b64) {
154			end = len(b64)
155		}
156		more := 1
157		if end == len(b64) {
158			more = 0
159		}
160		if i == 0 {
161			fmt.Fprintf(&sb, "\x1b_G%s,m=%d;%s\x1b\\", hdr, more, b64[i:end])
162		} else {
163			fmt.Fprintf(&sb, "\x1b_Gm=%d;%s\x1b\\", more, b64[i:end])
164		}
165	}
166	return sb.String()
167}
168
169// ImageDimensions returns width and height in pixels, or zeros on error.
170// Used for the text fallback so the user sees something useful.
171func ImageDimensions(data []byte) (int, int) {
172	cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
173	if err != nil {
174		return 0, 0
175	}
176	return cfg.Width, cfg.Height
177}
178
179// CellAspectRatio approximates how many pixel-rows one terminal row
180// occupies. Typical monospace cells are ~2x tall as wide; we use 2.0
181// as a safe default. Used to compute the rendered row count when
182// scaling an image to fit a cell width.
183const CellAspectRatio = 2.0
184
185// RowsForInlineImage returns the number of terminal rows an image
186// rendered at cellsWide columns will occupy, preserving aspect ratio.
187// Clamped to maxRows. Returns 0 if the image cannot be decoded.
188func RowsForInlineImage(data []byte, cellsWide, maxRows int) int {
189	w, h := ImageDimensions(data)
190	if w <= 0 || h <= 0 || cellsWide <= 0 {
191		return 0
192	}
193	// pixels per cell (horizontal)
194	// scaleX = imageWidthPx / cellsWide
195	// rendered height in cells = imageHeightPx / (scaleX * CellAspectRatio)
196	scaleX := float64(w) / float64(cellsWide)
197	rows := int(float64(h) / (scaleX * CellAspectRatio))
198	if rows < 1 {
199		rows = 1
200	}
201	if maxRows > 0 && rows > maxRows {
202		rows = maxRows
203	}
204	return rows
205}

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