v0.1.52 07c0730

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

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