v0.1.54 7134fb7

render.go RawHistoryFolder

1package tui
2
3import (
4	"io"
5	"strings"
6
7	"github.com/mattn/go-runewidth"
8)
9
10// runewidthRune reports the number of cells a rune occupies, pinned
11// here so the renderer does not depend on the editor's helper.
12func runewidthRune(r rune) int { return runewidth.RuneWidth(r) }
13
14// Renderer maintains a previous frame and writes only the lines that
15// changed on each Draw(). Callers pass a full target frame (slice of
16// styled lines, already wrapped to width).
17type Renderer struct {
18	out  io.Writer
19	prev []string
20	rows int // terminal rows
21	cols int // terminal cols
22
23	// Cursor position after last draw (for placing input cursor).
24	cursorRow int
25	cursorCol int
26
27	// hideCursor when true prevents ShowCursor from being emitted.
28	hideCursor bool
29
30	// prevHadImage tracks whether the previous frame contained an
31	// inline-image escape so we can force a full clear+repaint whenever
32	// the image set changes. Only matters when inline images are
33	// enabled via ZOT_INLINE_IMAGES; defaults to false.
34	prevHadImage bool
35
36	// Main-screen flow renderer state. logChat is the full chat buffer
37	// already emitted into terminal scrollback. logBottom is the
38	// editable/status block currently drawn after the chat.
39	logChat   []string
40	logBottom []string
41	logInit   bool
42}
43
44// NewRenderer returns a renderer that writes to out.
45func NewRenderer(out io.Writer) *Renderer {
46	return &Renderer{out: out}
47}
48
49// Resize tells the renderer the current terminal size.
50//
51// On a real size change we also issue a clear-screen so the next Draw
52// starts from a blank slate. Without the clear, characters from the
53// old (wider) layout linger past the new right edge and rows from
54// before the new bottom hang around as garbage.
55func (r *Renderer) Resize(cols, rows int) {
56	if cols != r.cols || rows != r.rows {
57		r.cols = cols
58		r.rows = rows
59		r.prev = nil
60		r.logChat = nil
61		r.logBottom = nil
62		r.logInit = false
63		if r.out != nil {
64			_, _ = io.WriteString(r.out, SeqClearScreen)
65		}
66	}
67}
68
69// Clear forces a full repaint on the next Draw and clears the screen
70// plus scrollback. In main-screen flow mode this is required whenever
71// already-emitted transcript layout changes (for example ctrl+o
72// expand/collapse), because terminal scrollback cannot be edited
73// reliably once printed.
74func (r *Renderer) Clear() {
75	r.prev = nil
76	r.logChat = nil
77	r.logBottom = nil
78	r.logInit = false
79	_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
80}
81
82// Invalidate forces a full repaint on the next Draw without clearing the
83// whole terminal first. Useful when the cached diff is unreliable but a
84// visible full-screen flash would be too distracting.
85func (r *Renderer) Invalidate() {
86	r.prev = nil
87}
88
89// Draw updates the terminal so that the visible frame ends with the
90// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
91// the lines slice indicating where to place the terminal cursor; use
92// -1 to hide it.
93// containsImageEscape reports whether the line carries an inline-image
94// escape we must repaint rather than diff against the previous frame.
95func containsImageEscape(s string) bool {
96	return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
97}
98
99// truncateToWidth clips s so its on-screen width doesn't exceed cols
100// cells, preserving ANSI CSI escape sequences (which don't consume
101// cells). Lines carrying an inline-image escape are returned as-is
102// since we can't measure their painted size.
103//
104// Fast path: a byte-length <= cols is a conservative upper bound
105// guaranteeing the cell width is also <= cols, so we skip all the
106// rune-width math. That covers the vast majority of lines in a
107// transcript (narrow terminals wrap early; wide ones leave headroom).
108func truncateToWidth(s string, cols int) string {
109	if cols <= 0 || containsImageEscape(s) {
110		return s
111	}
112	if len(s) <= cols {
113		return s
114	}
115	var out strings.Builder
116	out.Grow(len(s))
117	seen := 0
118	runes := []rune(s)
119	for i := 0; i < len(runes); {
120		r := runes[i]
121		// CSI escape sequence (ESC [ ... final): zero-width.
122		if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
123			out.WriteRune(r)
124			out.WriteRune(runes[i+1])
125			i += 2
126			for i < len(runes) {
127				c := runes[i]
128				out.WriteRune(c)
129				i++
130				if c >= 0x40 && c <= 0x7e {
131					break
132				}
133			}
134			continue
135		}
136		rw := runewidthRune(r)
137		if seen+rw > cols {
138			// Flush any trailing ANSI escapes (resets, erase-to-EOL)
139			// so background colors and cleanup sequences survive.
140			for i < len(runes) {
141				if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
142					out.WriteRune(runes[i])
143					out.WriteRune(runes[i+1])
144					i += 2
145					for i < len(runes) {
146						c := runes[i]
147						out.WriteRune(c)
148						i++
149						if c >= 0x40 && c <= 0x7e {
150							break
151						}
152					}
153				} else {
154					break
155				}
156			}
157			break
158		}
159		out.WriteRune(r)
160		seen += rw
161		i++
162	}
163	return out.String()
164}
165
166func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
167	if r.cols == 0 || r.rows == 0 {
168		return
169	}
170	// Bottom-align: only the last r.rows lines are visible.
171	visible := lines
172	if len(visible) > r.rows {
173		visible = visible[len(visible)-r.rows:]
174		cursorRow -= len(lines) - len(visible)
175	}
176	// Pad to r.rows with empty lines at the top. Every line is also
177	// hard-truncated to cols so the terminal never soft-wraps our output
178	// (which would push the status bar out of its row).
179	frame := make([]string, r.rows)
180	top := r.rows - len(visible)
181	for i := 0; i < top; i++ {
182		frame[i] = ""
183	}
184	for i, line := range visible {
185		frame[top+i] = truncateToWidth(line, r.cols)
186	}
187
188	var w strings.Builder
189	w.WriteString(SeqSynchronizedOn)
190	w.WriteString(SeqHideCursor)
191
192	// When inline images are in play we always full-repaint (clear
193	// screen first, then rewrite every row). Terminals manage image
194	// pixels in a layer we cannot diff against, so the per-line cache
195	// is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
196	// the common code path below is the fast cached diff.
197	curHasImage := false
198	curHasKittyImage := false
199	for _, l := range frame {
200		if containsImageEscape(l) {
201			curHasImage = true
202			if strings.Contains(l, "\x1b_G") {
203				curHasKittyImage = true
204			}
205		}
206	}
207	forceAll := curHasImage || r.prevHadImage
208	if forceAll {
209		w.WriteString(SeqClearScreen)
210		if curHasKittyImage {
211			// Delete previously placed kitty images once per frame,
212			// before rewriting all rows. Doing this inside each image
213			// escape makes only the last image in the frame survive.
214			w.WriteString("\x1b_Ga=d\x1b\\")
215		}
216	}
217
218	// Detect selection highlights: if the current OR previous frame
219	// has selection-background rows, force full repaint. VS Code's
220	// terminal doesn't reliably clear background colors on row
221	// overwrites, leaving ghost highlights behind.
222	hasSelection := false
223	for _, l := range frame {
224		if strings.Contains(l, "\x1b[48;5;") {
225			hasSelection = true
226			break
227		}
228	}
229	if !hasSelection && r.prev != nil {
230		for _, l := range r.prev {
231			if strings.Contains(l, "\x1b[48;5;") {
232				hasSelection = true
233				break
234			}
235		}
236	}
237
238	full := r.prev == nil || len(r.prev) != r.rows
239	for i := 0; i < r.rows; i++ {
240		if full || forceAll || hasSelection || r.prev[i] != frame[i] {
241			w.WriteString(MoveTo(i+1, 1))
242			w.WriteString("\x1b[0m") // reset all attributes first
243			w.WriteString(SeqClearLine)
244			w.WriteString(frame[i])
245		}
246	}
247
248	if cursorRow >= 0 {
249		absRow := top + cursorRow + 1
250		absCol := cursorCol + 1
251		if absRow >= 1 && absRow <= r.rows {
252			w.WriteString(MoveTo(absRow, absCol))
253			w.WriteString(SeqShowCursor)
254		}
255	}
256	w.WriteString(SeqSynchronizedOff)
257
258	_, _ = io.WriteString(r.out, w.String())
259
260	r.prev = frame
261	r.prevHadImage = curHasImage
262	r.cursorRow = cursorRow
263	r.cursorCol = cursorCol
264}
265
266// DrawLog renders zot in the terminal's main screen as normal terminal
267// flow rather than a fixed full-screen frame. Chat lines are emitted once
268// into the host terminal scrollback; the current bottom block (dialogs,
269// slash popup, status, editor) is erased and redrawn in place at the end.
270//
271// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
272func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
273	if r.cols == 0 || r.rows == 0 {
274		return
275	}
276	if len(bottom) == 0 {
277		bottom = []string{""}
278	}
279	chatFrame := make([]string, len(chat))
280	for i, line := range chat {
281		chatFrame[i] = truncateToWidth(line, r.cols)
282	}
283	bottomFrame := make([]string, len(bottom))
284	for i, line := range bottom {
285		bottomFrame[i] = truncateToWidth(line, r.cols)
286	}
287
288	var w strings.Builder
289	w.WriteString(SeqSynchronizedOn)
290	w.WriteString(SeqHideCursor)
291
292	if !r.logInit {
293		// First paint: start at top-left and print the transcript followed
294		// immediately by the live input/status block. No bottom padding,
295		// no footer layout.
296		w.WriteString(SeqClearScreen)
297		w.WriteString(SeqClearScrollback)
298		w.WriteString(MoveTo(1, 1))
299		for _, line := range chatFrame {
300			w.WriteString("\x1b[0m")
301			w.WriteString(SeqClearLine)
302			w.WriteString(line)
303			w.WriteString("\r\n")
304		}
305		writeBlock(&w, bottomFrame)
306		r.logInit = true
307	} else {
308		// Walk back up to the top of the previous bottom block. The cursor
309		// was last positioned somewhere inside the bottom band by the
310		// previous Draw (final ShowCursor below); we don't trust the
311		// terminal's saved cursor across frames because terminal-driven
312		// scrolling would invalidate it. Instead, rebuild the relative
313		// position from r.cursorRow inside the previous bottomFrame.
314		prevBottomRows := len(r.logBottom)
315		prevCursorRow := r.cursorRow
316		if prevCursorRow < 0 || prevCursorRow >= prevBottomRows {
317			prevCursorRow = prevBottomRows - 1
318			if prevCursorRow < 0 {
319				prevCursorRow = 0
320			}
321		}
322		up := prevCursorRow
323		if prevBottomRows > 0 && up > 0 {
324			w.WriteString("\x1b[" + itoa(up) + "A")
325		}
326		w.WriteString("\r")
327
328		prefix := len(r.logChat) <= len(chatFrame)
329		if prefix {
330			for i := range r.logChat {
331				if r.logChat[i] != chatFrame[i] {
332					prefix = false
333					break
334				}
335			}
336		}
337		if prefix {
338			// Erase old bottom band entirely, then append only genuinely new
339			// chat rows above the new bottom band. New chat rows become real
340			// terminal scrollback; inline image escapes are emitted once here.
341			w.WriteString(SeqEraseToEnd)
342			for _, line := range chatFrame[len(r.logChat):] {
343				w.WriteString("\x1b[0m")
344				w.WriteString(SeqClearLine)
345				w.WriteString(line)
346				w.WriteString("\r\n")
347			}
348		} else {
349			// Transcript changed in place (streaming text grows within the
350			// last assistant block, markdown finalises/reflows, ctrl+o
351			// expand/collapse, /clear, session load). In main-screen flow
352			// mode old chat lives in terminal scrollback; trying to move up
353			// and rewrite it is unreliable, especially once images or native
354			// scrolling are involved. Use the safe path: clear visible screen
355			// + scrollback, then replay the current transcript once.
356			w.WriteString(SeqDeleteKittyImages)
357			w.WriteString(SeqClearScreen)
358			w.WriteString(SeqClearScrollback)
359			w.WriteString(MoveTo(1, 1))
360			for _, line := range chatFrame {
361				w.WriteString("\x1b[0m")
362				w.WriteString(SeqClearLine)
363				w.WriteString(line)
364				w.WriteString("\r\n")
365			}
366		}
367		writeBlock(&w, bottomFrame)
368	}
369
370	// writeBlock leaves the cursor on the last bottom row. Move to the
371	// requested cursor position inside that block.
372	if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
373		up := (len(bottomFrame) - 1) - cursorBottomRow
374		if up > 0 {
375			w.WriteString("\x1b[" + itoa(up) + "A")
376		}
377		w.WriteString("\r")
378		if cursorCol > 0 {
379			w.WriteString("\x1b[" + itoa(cursorCol) + "C")
380		}
381		w.WriteString(SeqShowCursor)
382	}
383
384	w.WriteString(SeqSynchronizedOff)
385	_, _ = io.WriteString(r.out, w.String())
386
387	r.logChat = append(r.logChat[:0], chatFrame...)
388	r.logBottom = append(r.logBottom[:0], bottomFrame...)
389	r.cursorRow = cursorBottomRow
390	r.cursorCol = cursorCol
391}
392
393func writeBlock(w *strings.Builder, lines []string) {
394	for i, line := range lines {
395		w.WriteString("\x1b[0m")
396		w.WriteString(SeqClearLine)
397		w.WriteString(line)
398		if i < len(lines)-1 {
399			w.WriteString("\r\n")
400		}
401	}
402}
403
404func tailTruncated(lines []string, maxRows, cols int) []string {
405	if maxRows <= 0 {
406		return nil
407	}
408	if len(lines) > maxRows {
409		lines = lines[len(lines)-maxRows:]
410	}
411	out := make([]string, len(lines))
412	for i, line := range lines {
413		out[i] = truncateToWidth(line, cols)
414	}
415	return out
416}

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