v0.1.58 25a4caf

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. logLines is the full logical
37	// buffer (chat + live bottom band) from the previous DrawLog call.
38	// logViewportTop/logHardwareRow track where that logical buffer sits
39	// in the terminal's visible viewport so we can diff safely, and bail
40	// out to clear+replay when the diff would touch rows that are no
41	// longer addressable.
42	logChat        []string
43	logBottom      []string
44	logLines       []string
45	logViewportTop int
46	logHardwareRow int
47	logInit        bool
48}
49
50// NewRenderer returns a renderer that writes to out.
51func NewRenderer(out io.Writer) *Renderer {
52	return &Renderer{out: out}
53}
54
55// Resize tells the renderer the current terminal size.
56//
57// On a real size change we also issue a clear-screen so the next Draw
58// starts from a blank slate. Without the clear, characters from the
59// old (wider) layout linger past the new right edge and rows from
60// before the new bottom hang around as garbage.
61func (r *Renderer) Resize(cols, rows int) {
62	if cols != r.cols || rows != r.rows {
63		r.cols = cols
64		r.rows = rows
65		r.prev = nil
66		r.logChat = nil
67		r.logBottom = nil
68		r.logLines = nil
69		r.logViewportTop = 0
70		r.logHardwareRow = 0
71		r.logInit = false
72		if r.out != nil {
73			_, _ = io.WriteString(r.out, SeqClearScreen)
74		}
75	}
76}
77
78// Clear forces a full repaint on the next Draw and clears the screen
79// plus scrollback. In main-screen flow mode this is required whenever
80// already-emitted transcript layout changes (for example ctrl+o
81// expand/collapse), because terminal scrollback cannot be edited
82// reliably once printed.
83func (r *Renderer) Clear() {
84	r.prev = nil
85	r.logChat = nil
86	r.logBottom = nil
87	r.logLines = nil
88	r.logViewportTop = 0
89	r.logHardwareRow = 0
90	r.logInit = false
91	_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
92}
93
94// Invalidate forces a full repaint on the next Draw without clearing the
95// whole terminal first. Useful when the cached diff is unreliable but a
96// visible full-screen flash would be too distracting.
97func (r *Renderer) Invalidate() {
98	r.prev = nil
99	r.logLines = nil
100}
101
102// Draw updates the terminal so that the visible frame ends with the
103// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
104// the lines slice indicating where to place the terminal cursor; use
105// -1 to hide it.
106// containsImageEscape reports whether the line carries an inline-image
107// escape we must repaint rather than diff against the previous frame.
108func containsImageEscape(s string) bool {
109	return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
110}
111
112// truncateToWidth clips s so its on-screen width doesn't exceed cols
113// cells, preserving ANSI CSI escape sequences (which don't consume
114// cells). Lines carrying an inline-image escape are returned as-is
115// since we can't measure their painted size.
116//
117// Fast path: a byte-length <= cols is a conservative upper bound
118// guaranteeing the cell width is also <= cols, so we skip all the
119// rune-width math. That covers the vast majority of lines in a
120// transcript (narrow terminals wrap early; wide ones leave headroom).
121func truncateToWidth(s string, cols int) string {
122	if cols <= 0 || containsImageEscape(s) {
123		return s
124	}
125	if len(s) <= cols {
126		return s
127	}
128	var out strings.Builder
129	out.Grow(len(s))
130	seen := 0
131	runes := []rune(s)
132	for i := 0; i < len(runes); {
133		r := runes[i]
134		// CSI escape sequence (ESC [ ... final): zero-width.
135		if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
136			out.WriteRune(r)
137			out.WriteRune(runes[i+1])
138			i += 2
139			for i < len(runes) {
140				c := runes[i]
141				out.WriteRune(c)
142				i++
143				if c >= 0x40 && c <= 0x7e {
144					break
145				}
146			}
147			continue
148		}
149		rw := runewidthRune(r)
150		if seen+rw > cols {
151			// Flush any trailing ANSI escapes (resets, erase-to-EOL)
152			// so background colors and cleanup sequences survive.
153			for i < len(runes) {
154				if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
155					out.WriteRune(runes[i])
156					out.WriteRune(runes[i+1])
157					i += 2
158					for i < len(runes) {
159						c := runes[i]
160						out.WriteRune(c)
161						i++
162						if c >= 0x40 && c <= 0x7e {
163							break
164						}
165					}
166				} else {
167					break
168				}
169			}
170			break
171		}
172		out.WriteRune(r)
173		seen += rw
174		i++
175	}
176	return out.String()
177}
178
179func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
180	if r.cols == 0 || r.rows == 0 {
181		return
182	}
183	// Bottom-align: only the last r.rows lines are visible.
184	visible := lines
185	if len(visible) > r.rows {
186		visible = visible[len(visible)-r.rows:]
187		cursorRow -= len(lines) - len(visible)
188	}
189	// Pad to r.rows with empty lines at the top. Every line is also
190	// hard-truncated to cols so the terminal never soft-wraps our output
191	// (which would push the status bar out of its row).
192	frame := make([]string, r.rows)
193	top := r.rows - len(visible)
194	for i := 0; i < top; i++ {
195		frame[i] = ""
196	}
197	for i, line := range visible {
198		frame[top+i] = truncateToWidth(line, r.cols)
199	}
200
201	var w strings.Builder
202	w.WriteString(SeqSynchronizedOn)
203	w.WriteString(SeqHideCursor)
204
205	// When inline images are in play we always full-repaint (clear
206	// screen first, then rewrite every row). Terminals manage image
207	// pixels in a layer we cannot diff against, so the per-line cache
208	// is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
209	// the common code path below is the fast cached diff.
210	curHasImage := false
211	curHasKittyImage := false
212	for _, l := range frame {
213		if containsImageEscape(l) {
214			curHasImage = true
215			if strings.Contains(l, "\x1b_G") {
216				curHasKittyImage = true
217			}
218		}
219	}
220	forceAll := curHasImage || r.prevHadImage
221	if forceAll {
222		w.WriteString(SeqClearScreen)
223		if curHasKittyImage {
224			// Delete previously placed kitty images once per frame,
225			// before rewriting all rows. Doing this inside each image
226			// escape makes only the last image in the frame survive.
227			w.WriteString("\x1b_Ga=d\x1b\\")
228		}
229	}
230
231	// Detect selection highlights: if the current OR previous frame
232	// has selection-background rows, force full repaint. VS Code's
233	// terminal doesn't reliably clear background colors on row
234	// overwrites, leaving ghost highlights behind.
235	hasSelection := false
236	for _, l := range frame {
237		if strings.Contains(l, "\x1b[48;5;") {
238			hasSelection = true
239			break
240		}
241	}
242	if !hasSelection && r.prev != nil {
243		for _, l := range r.prev {
244			if strings.Contains(l, "\x1b[48;5;") {
245				hasSelection = true
246				break
247			}
248		}
249	}
250
251	full := r.prev == nil || len(r.prev) != r.rows
252	for i := 0; i < r.rows; i++ {
253		if full || forceAll || hasSelection || r.prev[i] != frame[i] {
254			w.WriteString(MoveTo(i+1, 1))
255			w.WriteString("\x1b[0m") // reset all attributes first
256			w.WriteString(SeqClearLine)
257			w.WriteString(frame[i])
258		}
259	}
260
261	if cursorRow >= 0 {
262		absRow := top + cursorRow + 1
263		absCol := cursorCol + 1
264		if absRow >= 1 && absRow <= r.rows {
265			w.WriteString(MoveTo(absRow, absCol))
266			w.WriteString(SeqShowCursor)
267		}
268	}
269	w.WriteString(SeqSynchronizedOff)
270
271	_, _ = io.WriteString(r.out, w.String())
272
273	r.prev = frame
274	r.prevHadImage = curHasImage
275	r.cursorRow = cursorRow
276	r.cursorCol = cursorCol
277}
278
279// DrawLog renders zot in the terminal's main screen as normal terminal
280// flow rather than a fixed full-screen frame. Chat lines are emitted once
281// into the host terminal scrollback; the current bottom block (dialogs,
282// slash popup, status, editor) is erased and redrawn in place at the end.
283//
284// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
285func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
286	if r.cols == 0 || r.rows == 0 {
287		return
288	}
289	if len(bottom) == 0 {
290		bottom = []string{""}
291	}
292	chatFrame := make([]string, len(chat))
293	for i, line := range chat {
294		chatFrame[i] = truncateToWidth(line, r.cols)
295	}
296	bottomFrame := make([]string, len(bottom))
297	for i, line := range bottom {
298		bottomFrame[i] = truncateToWidth(line, r.cols)
299	}
300
301	// Always reserve one real row below the editor/status band. This is
302	// renderer-owned (not a best-effort trailing blank in the caller's
303	// bottom block), so the logical-buffer diff keeps it visible and cursor
304	// placement remains relative to the editor itself.
305	const bottomMarginRows = 1
306	lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows)
307	lines = append(lines, chatFrame...)
308	lines = append(lines, bottomFrame...)
309	for range bottomMarginRows {
310		lines = append(lines, "")
311	}
312	if len(lines) == 0 {
313		lines = []string{""}
314	}
315
316	cursorTargetRow := -1
317	if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
318		cursorTargetRow = len(chatFrame) + cursorBottomRow
319	}
320
321	var w strings.Builder
322	w.WriteString(SeqSynchronizedOn)
323	w.WriteString(SeqHideCursor)
324
325	writeFull := func(clear bool) {
326		if clear {
327			w.WriteString(SeqDeleteKittyImages)
328			w.WriteString(SeqClearScreen)
329			w.WriteString(SeqClearScrollback)
330			w.WriteString(MoveTo(1, 1))
331		}
332		for idx, line := range lines {
333			if idx > 0 {
334				w.WriteString("\r\n")
335			}
336			w.WriteString("\x1b[0m")
337			w.WriteString(SeqClearLine)
338			w.WriteString(line)
339		}
340		r.logHardwareRow = len(lines) - 1
341		r.logViewportTop = len(lines) - r.rows
342		if r.logViewportTop < 0 {
343			r.logViewportTop = 0
344		}
345	}
346
347	moveToLogicalRow := func(targetRow int) {
348		if targetRow < 0 {
349			targetRow = 0
350		}
351		if targetRow >= len(lines) {
352			targetRow = len(lines) - 1
353		}
354		viewportBottom := r.logViewportTop + r.rows - 1
355		if targetRow > viewportBottom {
356			currentScreenRow := r.logHardwareRow - r.logViewportTop
357			if currentScreenRow < 0 {
358				currentScreenRow = 0
359			}
360			if currentScreenRow >= r.rows {
361				currentScreenRow = r.rows - 1
362			}
363			moveToBottom := r.rows - 1 - currentScreenRow
364			if moveToBottom > 0 {
365				w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
366			}
367			scroll := targetRow - viewportBottom
368			for s := 0; s < scroll; s++ {
369				w.WriteString("\r\n")
370			}
371			r.logViewportTop += scroll
372			r.logHardwareRow = targetRow
373			return
374		}
375		currentScreenRow := r.logHardwareRow - r.logViewportTop
376		targetScreenRow := targetRow - r.logViewportTop
377		lineDiff := targetScreenRow - currentScreenRow
378		if lineDiff > 0 {
379			w.WriteString("\x1b[" + itoa(lineDiff) + "B")
380		} else if lineDiff < 0 {
381			w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
382		}
383		r.logHardwareRow = targetRow
384	}
385
386	positionCursor := func() {
387		if cursorTargetRow < 0 || cursorTargetRow >= len(lines) {
388			return
389		}
390		moveToLogicalRow(cursorTargetRow)
391		w.WriteString("\r")
392		if cursorCol > 0 {
393			w.WriteString("\x1b[" + itoa(cursorCol) + "C")
394		}
395		w.WriteString(SeqShowCursor)
396	}
397
398	full := !r.logInit || len(r.logLines) == 0
399	if full {
400		writeFull(true)
401		r.logInit = true
402	} else {
403		firstChanged := -1
404		lastChanged := -1
405		maxLines := len(lines)
406		if len(r.logLines) > maxLines {
407			maxLines = len(r.logLines)
408		}
409		for idx := 0; idx < maxLines; idx++ {
410			oldLine := ""
411			if idx < len(r.logLines) {
412				oldLine = r.logLines[idx]
413			}
414			newLine := ""
415			if idx < len(lines) {
416				newLine = lines[idx]
417			}
418			if oldLine != newLine {
419				if firstChanged == -1 {
420					firstChanged = idx
421				}
422				lastChanged = idx
423			}
424		}
425
426		if firstChanged == -1 {
427			// No content changes; the final cursor positioning below may still
428			// move the hardware cursor if the editor cursor changed.
429		} else if len(lines) < len(r.logLines) || firstChanged < r.logViewportTop {
430			// Shrinks and changes above the visible viewport cannot be safely
431			// patched in terminal scrollback. Replay the current logical buffer
432			// instead of risking stale duplicated rows.
433			writeFull(true)
434		} else {
435			prevViewportTop := r.logViewportTop
436			viewportTop := prevViewportTop
437			hardwareRow := r.logHardwareRow
438			prevViewportBottom := prevViewportTop + r.rows - 1
439			appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0
440			moveTarget := firstChanged
441			if appendStart {
442				moveTarget = firstChanged - 1
443			}
444
445			if moveTarget > prevViewportBottom {
446				currentScreenRow := hardwareRow - prevViewportTop
447				if currentScreenRow < 0 {
448					currentScreenRow = 0
449				}
450				if currentScreenRow >= r.rows {
451					currentScreenRow = r.rows - 1
452				}
453				moveToBottom := r.rows - 1 - currentScreenRow
454				if moveToBottom > 0 {
455					w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
456				}
457				scroll := moveTarget - prevViewportBottom
458				for s := 0; s < scroll; s++ {
459					w.WriteString("\r\n")
460				}
461				prevViewportTop += scroll
462				viewportTop += scroll
463				hardwareRow = moveTarget
464			}
465
466			currentScreenRow := hardwareRow - prevViewportTop
467			targetScreenRow := moveTarget - viewportTop
468			lineDiff := targetScreenRow - currentScreenRow
469			if lineDiff > 0 {
470				w.WriteString("\x1b[" + itoa(lineDiff) + "B")
471			} else if lineDiff < 0 {
472				w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
473			}
474			if appendStart {
475				w.WriteString("\r\n")
476			} else {
477				w.WriteString("\r")
478			}
479
480			renderEnd := lastChanged
481			if renderEnd >= len(lines) {
482				renderEnd = len(lines) - 1
483			}
484			for idx := firstChanged; idx <= renderEnd; idx++ {
485				if idx > firstChanged {
486					w.WriteString("\r\n")
487				}
488				w.WriteString("\x1b[0m")
489				w.WriteString(SeqClearLine)
490				w.WriteString(lines[idx])
491			}
492			r.logHardwareRow = renderEnd
493			r.logViewportTop = viewportTop
494			if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
495				r.logViewportTop = minTop
496			}
497			if r.logViewportTop < 0 {
498				r.logViewportTop = 0
499			}
500		}
501	}
502
503	// Re-anchor the terminal viewport at the true end of the logical
504	// buffer on every frame. During busy turns most redraws only mutate
505	// spinner/live rows above the editor; without touching the final margin
506	// row, the terminal can visually lose the bottom padding even though it
507	// still exists in r.logLines.
508	if len(lines) > 0 {
509		moveToLogicalRow(len(lines) - 1)
510		w.WriteString("\r")
511		w.WriteString("\x1b[0m")
512		w.WriteString(SeqClearLine)
513	}
514	positionCursor()
515	w.WriteString(SeqSynchronizedOff)
516	_, _ = io.WriteString(r.out, w.String())
517
518	r.logChat = append(r.logChat[:0], chatFrame...)
519	r.logBottom = append(r.logBottom[:0], bottomFrame...)
520	r.logLines = append(r.logLines[:0], lines...)
521	r.cursorRow = cursorBottomRow
522	r.cursorCol = cursorCol
523}
524
525func writeBlock(w *strings.Builder, lines []string) {
526	for i, line := range lines {
527		w.WriteString("\x1b[0m")
528		w.WriteString(SeqClearLine)
529		w.WriteString(line)
530		if i < len(lines)-1 {
531			w.WriteString("\r\n")
532		}
533	}
534}
535
536func tailTruncated(lines []string, maxRows, cols int) []string {
537	if maxRows <= 0 {
538		return nil
539	}
540	if len(lines) > maxRows {
541		lines = lines[len(lines)-maxRows:]
542	}
543	out := make([]string, len(lines))
544	for i, line := range lines {
545		out[i] = truncateToWidth(line, cols)
546	}
547	return out
548}

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