v0.2.6 917da8c

render.go RawHistoryFolder

1package tui
2
3import (
4	"io"
5	"os"
6	"strings"
7
8	"github.com/mattn/go-runewidth"
9)
10
11// runewidthRune reports the number of cells a rune occupies, pinned
12// here so the renderer does not depend on the editor's helper.
13func runewidthRune(r rune) int { return runewidth.RuneWidth(r) }
14
15// Renderer maintains a previous frame and writes only the lines that
16// changed on each Draw(). Callers pass a full target frame (slice of
17// styled lines, already wrapped to width).
18type Renderer struct {
19	out  io.Writer
20	prev []string
21	rows int // terminal rows
22	cols int // terminal cols
23
24	// Cursor position after last draw (for placing input cursor).
25	cursorRow int
26	cursorCol int
27
28	// hideCursor when true prevents ShowCursor from being emitted.
29	hideCursor bool
30
31	// prevHadImage tracks whether the previous frame contained an
32	// inline-image escape so we can force a full clear+repaint whenever
33	// the image set changes. Only matters when inline images are
34	// enabled via ZOT_INLINE_IMAGES; defaults to false.
35	prevHadImage bool
36
37	// Main-screen flow renderer state. logLines is the full logical
38	// buffer (chat + live bottom band) from the previous DrawLog call.
39	// logViewportTop/logHardwareRow track where that logical buffer sits
40	// in the terminal's visible viewport so we can diff safely, and bail
41	// out to clear+replay when the diff would touch rows that are no
42	// longer addressable.
43	logChat        []string
44	logBottom      []string
45	logLines       []string
46	logViewportTop int
47	logHardwareRow int
48	logInit        bool
49
50	// keepScrollback is true when we must NOT emit \x1b[3J
51	// (erase-in-display 3, "clear scrollback").
52	//
53	// VS Code's integrated terminal (xterm.js) interprets \x1b[3J
54	// as "also snap the viewport to the top of the remaining
55	// buffer." Once the user has reopened a terminal with VS
56	// Code's persistent-sessions feature on, there is real
57	// replayed scrollback above the live cursor, and the snap is
58	// visible: the host scrollbar jumps to the top every time we
59	// do a full repaint (first paint, Ctrl+L via Renderer.Clear,
60	// any writeFull(true) shrink). On every other terminal we
61	// tested (iTerm, Ghostty, Kitty, Alacritty, Apple Terminal)
62	// \x1b[3J just drops scrollback rows without moving the
63	// viewport, which is what we actually want.
64	//
65	// The trade-off when keepScrollback is true: stale zot frames
66	// remain in scrollback above the live view, so scrolling up
67	// in VS Code's terminal shows old (already-superseded) zot
68	// output. That is strictly less disruptive than the
69	// scrollbar yanking to top on every Ctrl+L, and it is a
70	// limitation specific to VS Code's terminal that we have no
71	// way to work around without breaking other terminals.
72	keepScrollback bool
73
74	// theme is optional renderer-level styling applied at the final
75	// row-writing boundary. In particular, Theme.Background is painted
76	// as a full-width row background without making every View renderer
77	// know about terminal padding and reset semantics.
78	theme Theme
79}
80
81// NewRenderer returns a renderer that writes to out.
82//
83// Detects VS Code's integrated terminal via $TERM_PROGRAM and, when
84// detected, disables emission of \x1b[3J for the reasons documented
85// on Renderer.keepScrollback. The env var is set by VS Code itself
86// (and by Cursor, which forks VS Code's terminal — same xterm.js,
87// same bug), so no user configuration is required.
88func NewRenderer(out io.Writer) *Renderer {
89	return &Renderer{
90		out:            out,
91		keepScrollback: os.Getenv("TERM_PROGRAM") == "vscode",
92	}
93}
94
95// SetTheme updates renderer-level terminal styling. Changing the
96// background affects every row, so cached frame state is invalidated.
97func (r *Renderer) SetTheme(th Theme) {
98	r.theme = th
99	r.Invalidate()
100}
101
102// Resize tells the renderer the current terminal size.
103//
104// On a real size change we also issue a clear-screen so the next Draw
105// starts from a blank slate. Without the clear, characters from the
106// old (wider) layout linger past the new right edge and rows from
107// before the new bottom hang around as garbage.
108func (r *Renderer) ResetScrollRegion() {
109	if r.out != nil {
110		_, _ = io.WriteString(r.out, SeqResetScrollRegion)
111	}
112}
113
114func (r *Renderer) Resize(cols, rows int) {
115	if cols != r.cols || rows != r.rows {
116		r.cols = cols
117		r.rows = rows
118		r.prev = nil
119		r.logChat = nil
120		r.logBottom = nil
121		r.logLines = nil
122		r.logViewportTop = 0
123		r.logHardwareRow = 0
124		r.logInit = false
125		if r.out != nil {
126			// Clear both screen and (where safe) scrollback so stale
127			// content from the old width doesn't bleed through. Move
128			// to (1,1) so the next DrawLog/writeFull starts from a
129			// clean slate. Use the no-home variant: the explicit
130			// MoveTo below sets the cursor without triggering VS
131			// Code's viewport-snap. See Renderer.keepScrollback for
132			// why we skip \x1b[3J on VS Code's terminal.
133			_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
134		}
135	}
136}
137
138// Clear forces a full repaint on the next Draw and clears the screen
139// plus scrollback. In main-screen flow mode this is required whenever
140// already-emitted transcript layout changes (for example ctrl+o
141// expand/collapse), because terminal scrollback cannot be edited
142// reliably once printed.
143func (r *Renderer) Clear() {
144	r.prev = nil
145	r.logChat = nil
146	r.logBottom = nil
147	r.logLines = nil
148	r.logViewportTop = 0
149	r.logHardwareRow = 0
150	r.logInit = false
151	_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
152}
153
154// clearScrollbackSeq returns the scrollback-clear escape, or the
155// empty string when we are running under a terminal where emitting
156// it has user-visible side effects (see Renderer.keepScrollback).
157// Callers concatenate this into a larger control sequence; an empty
158// return value is a no-op there.
159func (r *Renderer) clearScrollbackSeq() string {
160	if r.keepScrollback {
161		return ""
162	}
163	return SeqClearScrollback
164}
165
166// Invalidate forces a full repaint on the next Draw without clearing the
167// whole terminal first. Useful when the cached diff is unreliable but a
168// visible full-screen flash would be too distracting.
169func (r *Renderer) Invalidate() {
170	r.prev = nil
171	r.logLines = nil
172}
173
174// Draw updates the terminal so that the visible frame ends with the
175// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
176// the lines slice indicating where to place the terminal cursor; use
177// -1 to hide it.
178// containsImageEscape reports whether the line carries an inline-image
179// escape we must repaint rather than diff against the previous frame.
180func containsImageEscape(s string) bool {
181	return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
182}
183
184// paintBackgroundRow applies the optional theme background to a single
185// already-truncated terminal row. It pads with spaces to cols so the
186// background reaches the right edge, and re-applies the background
187// after full SGR resets inside the row so local styling does not punch
188// transparent holes through the global tint.
189func paintBackgroundRow(line string, cols int, th Theme) string {
190	bg := th.BackgroundStyle()
191	if bg == "" || cols <= 0 || containsImageEscape(line) {
192		return line
193	}
194	line = strings.ReplaceAll(line, reset, reset+bg)
195	if w := visibleWidth(line); w < cols {
196		line += strings.Repeat(" ", cols-w)
197	}
198	return bg + line + reset
199}
200
201// truncateToWidth clips s so its on-screen width doesn't exceed cols
202// cells, preserving ANSI CSI escape sequences (which don't consume
203// cells). Lines carrying an inline-image escape are returned as-is
204// since we can't measure their painted size.
205//
206// Fast path: a byte-length <= cols is a conservative upper bound
207// guaranteeing the cell width is also <= cols, so we skip all the
208// rune-width math. That covers the vast majority of lines in a
209// transcript (narrow terminals wrap early; wide ones leave headroom).
210func truncateToWidth(s string, cols int) string {
211	if cols <= 0 || containsImageEscape(s) {
212		return s
213	}
214	if len(s) <= cols {
215		return s
216	}
217	var out strings.Builder
218	out.Grow(len(s))
219	seen := 0
220	runes := []rune(s)
221	for i := 0; i < len(runes); {
222		r := runes[i]
223		// CSI escape sequence (ESC [ ... final): zero-width.
224		if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
225			out.WriteRune(r)
226			out.WriteRune(runes[i+1])
227			i += 2
228			for i < len(runes) {
229				c := runes[i]
230				out.WriteRune(c)
231				i++
232				if c >= 0x40 && c <= 0x7e {
233					break
234				}
235			}
236			continue
237		}
238		rw := runewidthRune(r)
239		if seen+rw > cols {
240			// Flush any trailing ANSI escapes (resets, erase-to-EOL)
241			// so background colors and cleanup sequences survive.
242			for i < len(runes) {
243				if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
244					out.WriteRune(runes[i])
245					out.WriteRune(runes[i+1])
246					i += 2
247					for i < len(runes) {
248						c := runes[i]
249						out.WriteRune(c)
250						i++
251						if c >= 0x40 && c <= 0x7e {
252							break
253						}
254					}
255				} else {
256					break
257				}
258			}
259			break
260		}
261		out.WriteRune(r)
262		seen += rw
263		i++
264	}
265	return out.String()
266}
267
268func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
269	if r.cols == 0 || r.rows == 0 {
270		return
271	}
272	// Bottom-align: only the last r.rows lines are visible.
273	visible := lines
274	if len(visible) > r.rows {
275		visible = visible[len(visible)-r.rows:]
276		cursorRow -= len(lines) - len(visible)
277	}
278	// Pad to r.rows with empty lines at the top. Every line is also
279	// hard-truncated to cols so the terminal never soft-wraps our output
280	// (which would push the status bar out of its row).
281	frame := make([]string, r.rows)
282	top := r.rows - len(visible)
283	for i := 0; i < top; i++ {
284		frame[i] = ""
285	}
286	for i, line := range visible {
287		frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
288	}
289	if r.theme.Background != nil {
290		for i := 0; i < top; i++ {
291			frame[i] = paintBackgroundRow("", r.cols, r.theme)
292		}
293	}
294
295	var w strings.Builder
296	w.WriteString(SeqSynchronizedOn)
297	w.WriteString(SeqHideCursor)
298
299	// When inline images are in play we always full-repaint (clear
300	// screen first, then rewrite every row). Terminals manage image
301	// pixels in a layer we cannot diff against, so the per-line cache
302	// is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
303	// the common code path below is the fast cached diff.
304	curHasImage := false
305	curHasKittyImage := false
306	for _, l := range frame {
307		if containsImageEscape(l) {
308			curHasImage = true
309			if strings.Contains(l, "\x1b_G") {
310				curHasKittyImage = true
311			}
312		}
313	}
314	forceAll := curHasImage || r.prevHadImage
315	if forceAll {
316		// No-home variant: the per-row MoveTo(i+1, 1) writes in the
317		// loop below position the cursor for every painted row, so
318		// the embedded \x1b[H would only serve to make VS Code snap
319		// its scrollbar to the top of the viewport on every image or
320		// selection-highlight frame.
321		w.WriteString(SeqClearScreenNoHome)
322		if curHasKittyImage {
323			// Delete previously placed kitty images once per frame,
324			// before rewriting all rows. Doing this inside each image
325			// escape makes only the last image in the frame survive.
326			w.WriteString("\x1b_Ga=d\x1b\\")
327		}
328	}
329
330	// Detect selection highlights: if the current OR previous frame
331	// has selection-background rows, force full repaint. VS Code's
332	// terminal doesn't reliably clear background colors on row
333	// overwrites, leaving ghost highlights behind.
334	hasSelection := false
335	if r.theme.Background == nil {
336		selectionBG := sgrBG(r.theme.SelectionBG)
337		for _, l := range frame {
338			if selectionBG != "" && strings.Contains(l, selectionBG) {
339				hasSelection = true
340				break
341			}
342		}
343		if !hasSelection && r.prev != nil {
344			for _, l := range r.prev {
345				if selectionBG != "" && strings.Contains(l, selectionBG) {
346					hasSelection = true
347					break
348				}
349			}
350		}
351	}
352
353	full := r.prev == nil || len(r.prev) != r.rows
354	for i := 0; i < r.rows; i++ {
355		if full || forceAll || hasSelection || r.prev[i] != frame[i] {
356			w.WriteString(MoveTo(i+1, 1))
357			w.WriteString("\x1b[0m") // reset all attributes first
358			w.WriteString(SeqClearLine)
359			w.WriteString(frame[i])
360		}
361	}
362
363	if cursorRow >= 0 {
364		absRow := top + cursorRow + 1
365		absCol := cursorCol + 1
366		if absRow >= 1 && absRow <= r.rows {
367			w.WriteString(MoveTo(absRow, absCol))
368			w.WriteString(SeqShowCursor)
369		}
370	}
371	w.WriteString(SeqSynchronizedOff)
372
373	_, _ = io.WriteString(r.out, w.String())
374
375	r.prev = frame
376	r.prevHadImage = curHasImage
377	r.cursorRow = cursorRow
378	r.cursorCol = cursorCol
379}
380
381// DrawLog renders zot in the terminal's main screen as normal terminal
382// flow rather than a fixed full-screen frame. Chat lines are emitted once
383// into the host terminal scrollback; the current bottom block (dialogs,
384// slash popup, status, editor) is erased and redrawn in place at the end.
385//
386// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
387func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
388	if r.cols == 0 || r.rows == 0 {
389		return
390	}
391	if len(bottom) == 0 {
392		bottom = []string{""}
393	}
394	chatFrame := make([]string, len(chat))
395	for i, line := range chat {
396		chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
397	}
398	bottomFrame := make([]string, len(bottom))
399	for i, line := range bottom {
400		bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
401	}
402
403	// Always reserve one real row below the editor/status band. This is
404	// renderer-owned (not a best-effort trailing blank in the caller's
405	// bottom block), so the logical-buffer diff keeps it visible and cursor
406	// placement remains relative to the editor itself.
407	const bottomMarginRows = 1
408	lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows)
409	lines = append(lines, chatFrame...)
410	lines = append(lines, bottomFrame...)
411	for range bottomMarginRows {
412		lines = append(lines, paintBackgroundRow("", r.cols, r.theme))
413	}
414	// In main-screen flow mode zot normally emits only its logical
415	// content rows and leaves the rest of the terminal viewport alone.
416	// When a theme background is configured, fill that otherwise-idle
417	// space with painted blank rows so the full window is tinted while
418	// keeping the scrollback-oriented renderer model unchanged for the
419	// default transparent case.
420	if r.theme.Background != nil {
421		for len(lines) < r.rows {
422			lines = append(lines, paintBackgroundRow("", r.cols, r.theme))
423		}
424	}
425	if len(lines) == 0 {
426		lines = []string{""}
427	}
428
429	cursorTargetRow := -1
430	if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
431		cursorTargetRow = len(chatFrame) + cursorBottomRow
432	}
433
434	// Idle no-op fast path. When the buffer AND the cursor position
435	// haven't changed since the last DrawLog, emit nothing. The
436	// alternative — always writing SeqHideCursor + cursor-position +
437	// SeqShowCursor — resets the terminal's cursor blink timer on
438	// every tick. At our 120ms animation cadence that means the
439	// caret in an idle dialog editor (e.g. an open swarm transcript
440	// for an agent that's currently idle) appears as a solid block
441	// that never blinks, because we keep "showing" it before the
442	// terminal can blink it off. Bailing out here lets the OS run
443	// its blink cycle.
444	if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
445		return
446	}
447
448	var w strings.Builder
449	w.WriteString(SeqSynchronizedOn)
450	w.WriteString(SeqHideCursor)
451
452	writeFull := func(clear bool) {
453		if clear {
454			w.WriteString(SeqDeleteKittyImages)
455			w.WriteString(SeqClearScreenNoHome)
456			w.WriteString(r.clearScrollbackSeq())
457			w.WriteString(MoveTo(1, 1))
458		}
459		for idx, line := range lines {
460			if idx > 0 {
461				w.WriteString("\r\n")
462			}
463			w.WriteString("\x1b[0m")
464			w.WriteString(SeqClearLine)
465			w.WriteString(line)
466		}
467		r.logHardwareRow = len(lines) - 1
468		r.logViewportTop = len(lines) - r.rows
469		if r.logViewportTop < 0 {
470			r.logViewportTop = 0
471		}
472	}
473
474	moveToLogicalRow := func(targetRow int) {
475		if targetRow < 0 {
476			targetRow = 0
477		}
478		if targetRow >= len(lines) {
479			targetRow = len(lines) - 1
480		}
481		viewportBottom := r.logViewportTop + r.rows - 1
482		if targetRow > viewportBottom {
483			currentScreenRow := r.logHardwareRow - r.logViewportTop
484			if currentScreenRow < 0 {
485				currentScreenRow = 0
486			}
487			if currentScreenRow >= r.rows {
488				currentScreenRow = r.rows - 1
489			}
490			moveToBottom := r.rows - 1 - currentScreenRow
491			if moveToBottom > 0 {
492				w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
493			}
494			scroll := targetRow - viewportBottom
495			for s := 0; s < scroll; s++ {
496				w.WriteString("\r\n")
497			}
498			r.logViewportTop += scroll
499			r.logHardwareRow = targetRow
500			return
501		}
502		currentScreenRow := r.logHardwareRow - r.logViewportTop
503		targetScreenRow := targetRow - r.logViewportTop
504		lineDiff := targetScreenRow - currentScreenRow
505		if lineDiff > 0 {
506			w.WriteString("\x1b[" + itoa(lineDiff) + "B")
507		} else if lineDiff < 0 {
508			w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
509		}
510		r.logHardwareRow = targetRow
511	}
512
513	positionCursor := func() {
514		if cursorTargetRow < 0 || cursorTargetRow >= len(lines) {
515			return
516		}
517		moveToLogicalRow(cursorTargetRow)
518		w.WriteString("\r")
519		if cursorCol > 0 {
520			w.WriteString("\x1b[" + itoa(cursorCol) + "C")
521		}
522		w.WriteString(SeqShowCursor)
523	}
524
525	// Selection-highlight workaround removed: it could mis-invalidate
526	// user-bubble padding rows whose colored bg made botHasHL trip,
527	// causing the next diff pass to leave those rows visually thinned
528	// because the cached entry was the \x00 sentinel rather than the
529	// real previous bg-colored row.
530
531	full := !r.logInit || len(r.logLines) == 0
532	if full {
533		writeFull(true)
534		r.logInit = true
535	} else {
536		firstChanged := -1
537		lastChanged := -1
538		maxLines := len(lines)
539		if len(r.logLines) > maxLines {
540			maxLines = len(r.logLines)
541		}
542		for idx := 0; idx < maxLines; idx++ {
543			oldLine := ""
544			if idx < len(r.logLines) {
545				oldLine = r.logLines[idx]
546			}
547			newLine := ""
548			if idx < len(lines) {
549				newLine = lines[idx]
550			}
551			if oldLine != newLine {
552				if firstChanged == -1 {
553					firstChanged = idx
554				}
555				lastChanged = idx
556			}
557		}
558		// Buffer grew but the appended rows were empty (or otherwise
559		// equal to the implicit "" past the old end). The diff above
560		// won't flag those rows, yet the renderer still needs to
561		// advance its hardware cursor / viewport tracking past them so
562		// the next render starts from the correct position. Treat the
563		// extension as changed.
564		if len(lines) > len(r.logLines) {
565			if firstChanged == -1 {
566				firstChanged = len(r.logLines)
567			}
568			if lastChanged < len(lines)-1 {
569				lastChanged = len(lines) - 1
570			}
571		}
572
573		if firstChanged == -1 {
574			// No content changes; the final cursor positioning below may still
575			// move the hardware cursor if the editor cursor changed.
576		} else if firstChanged < r.logViewportTop {
577			// Changes above the visible viewport cannot be patched safely.
578			writeFull(true)
579		} else {
580			prevViewportTop := r.logViewportTop
581			viewportTop := prevViewportTop
582			hardwareRow := r.logHardwareRow
583			prevViewportBottom := prevViewportTop + r.rows - 1
584			appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0
585			moveTarget := firstChanged
586			if appendStart {
587				moveTarget = firstChanged - 1
588			}
589
590			if moveTarget > prevViewportBottom {
591				currentScreenRow := hardwareRow - prevViewportTop
592				if currentScreenRow < 0 {
593					currentScreenRow = 0
594				}
595				if currentScreenRow >= r.rows {
596					currentScreenRow = r.rows - 1
597				}
598				moveToBottom := r.rows - 1 - currentScreenRow
599				if moveToBottom > 0 {
600					w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
601				}
602				scroll := moveTarget - prevViewportBottom
603				for s := 0; s < scroll; s++ {
604					w.WriteString("\r\n")
605				}
606				prevViewportTop += scroll
607				viewportTop += scroll
608				hardwareRow = moveTarget
609			}
610
611			currentScreenRow := hardwareRow - prevViewportTop
612			targetScreenRow := moveTarget - viewportTop
613			lineDiff := targetScreenRow - currentScreenRow
614			if lineDiff > 0 {
615				w.WriteString("\x1b[" + itoa(lineDiff) + "B")
616			} else if lineDiff < 0 {
617				w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
618			}
619			if appendStart {
620				w.WriteString("\r\n")
621			} else {
622				w.WriteString("\r")
623			}
624
625			renderEnd := lastChanged
626			if renderEnd >= len(lines) {
627				renderEnd = len(lines) - 1
628			}
629			for idx := firstChanged; idx <= renderEnd; idx++ {
630				if idx > firstChanged {
631					w.WriteString("\r\n")
632				}
633				w.WriteString("\x1b[0m")
634				w.WriteString(SeqClearLine)
635				w.WriteString(lines[idx])
636			}
637			finalRow := renderEnd
638			if len(r.logLines) > len(lines) {
639				extra := len(r.logLines) - len(lines)
640				if extra > r.rows {
641					writeFull(true)
642				} else {
643					for e := 0; e < extra; e++ {
644						w.WriteString("\x1b[1B")
645						w.WriteString("\r")
646						w.WriteString("\x1b[0m")
647						w.WriteString(SeqClearLine)
648						finalRow++
649					}
650					if extra > 0 {
651						w.WriteString("\x1b[" + itoa(extra) + "A")
652						finalRow -= extra
653					}
654				}
655			}
656			r.logHardwareRow = finalRow
657			r.logViewportTop = viewportTop
658			if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
659				r.logViewportTop = minTop
660			}
661			if r.logViewportTop < 0 {
662				r.logViewportTop = 0
663			}
664		}
665	}
666
667	positionCursor()
668	w.WriteString(SeqSynchronizedOff)
669	_, _ = io.WriteString(r.out, w.String())
670
671	r.logChat = append(r.logChat[:0], chatFrame...)
672	r.logBottom = append(r.logBottom[:0], bottomFrame...)
673	r.logLines = append(r.logLines[:0], lines...)
674	r.cursorRow = cursorBottomRow
675	r.cursorCol = cursorCol
676}
677
678// sameLines reports whether two []string have the exact same
679// length and per-row contents. Used by DrawLog's idle no-op fast
680// path; cheap enough at our frame rates and far simpler than
681// hashing every byte.
682func sameLines(a, b []string) bool {
683	if len(a) != len(b) {
684		return false
685	}
686	for i := range a {
687		if a[i] != b[i] {
688			return false
689		}
690	}
691	return true
692}
693
694func writeBlock(w *strings.Builder, lines []string) {
695	for i, line := range lines {
696		w.WriteString("\x1b[0m")
697		w.WriteString(SeqClearLine)
698		w.WriteString(line)
699		if i < len(lines)-1 {
700			w.WriteString("\r\n")
701		}
702	}
703}
704
705func tailTruncated(lines []string, maxRows, cols int) []string {
706	if maxRows <= 0 {
707		return nil
708	}
709	if len(lines) > maxRows {
710		lines = lines[len(lines)-maxRows:]
711	}
712	out := make([]string, len(lines))
713	for i, line := range lines {
714		out[i] = truncateToWidth(line, cols)
715	}
716	return out
717}

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