v0.1.73 ce78a49

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

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 477

↑↓ moveenter openesc close