v0.1.78 f47996b

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

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