v0.3.93 0192a2e

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	// History uses a separate alternate-screen renderer so browsing does not
51	// overwrite the main screen or its native scrollback and diff state.
52	history *Renderer
53
54	// keepScrollback is true when we must NOT emit \x1b[3J
55	// (erase-in-display 3, "clear scrollback").
56	//
57	// VS Code's integrated terminal (xterm.js) interprets \x1b[3J
58	// as "also snap the viewport to the top of the remaining
59	// buffer." Once the user has reopened a terminal with VS
60	// Code's persistent-sessions feature on, there is real
61	// replayed scrollback above the live cursor, and the snap is
62	// visible: the host scrollbar jumps to the top every time we
63	// do a full repaint (first paint, Ctrl+L via Renderer.Clear,
64	// any writeFull(true) shrink). On every other terminal we
65	// tested (iTerm, Ghostty, Kitty, Alacritty, Apple Terminal)
66	// \x1b[3J just drops scrollback rows without moving the
67	// viewport, which is what we actually want.
68	//
69	// The trade-off when keepScrollback is true: stale zot frames
70	// remain in scrollback above the live view, so scrolling up
71	// in VS Code's terminal shows old (already-superseded) zot
72	// output. That is strictly less disruptive than the
73	// scrollbar yanking to top on every Ctrl+L, and it is a
74	// limitation specific to VS Code's terminal that we have no
75	// way to work around without breaking other terminals.
76	keepScrollback bool
77
78	// theme is optional renderer-level styling applied at the final
79	// row-writing boundary. In particular, Theme.Background is painted
80	// as a full-width row background without making every View renderer
81	// know about terminal padding and reset semantics.
82	theme Theme
83}
84
85// NewRenderer returns a renderer that writes to out.
86//
87// Detects VS Code's integrated terminal via $TERM_PROGRAM and, when
88// detected, disables emission of \x1b[3J for the reasons documented
89// on Renderer.keepScrollback. The env var is set by VS Code itself
90// (and by Cursor, which forks VS Code's terminal — same xterm.js,
91// same bug), so no user configuration is required.
92func NewRenderer(out io.Writer) *Renderer {
93	return &Renderer{
94		out:            out,
95		keepScrollback: os.Getenv("TERM_PROGRAM") == "vscode",
96	}
97}
98
99// SetTheme updates renderer-level terminal styling. Changing the
100// background affects every row, so cached frame state is invalidated.
101func (r *Renderer) SetTheme(th Theme) {
102	r.theme = th
103	if r.history != nil {
104		r.history.SetTheme(th)
105	}
106	r.Invalidate()
107}
108
109// Resize tells the renderer the current terminal size.
110//
111// On a real size change we also issue a clear-screen so the next Draw
112// starts from a blank slate. Without the clear, characters from the
113// old (wider) layout linger past the new right edge and rows from
114// before the new bottom hang around as garbage.
115func (r *Renderer) ResetScrollRegion() {
116	if r.out != nil {
117		_, _ = io.WriteString(r.out, SeqResetScrollRegion)
118	}
119}
120
121func (r *Renderer) Resize(cols, rows int) {
122	if r.history != nil && (cols != r.cols || rows != r.rows) {
123		r.history.cols = cols
124		r.history.rows = rows
125		r.history.Invalidate()
126	}
127	if cols != r.cols || rows != r.rows {
128		r.cols = cols
129		r.rows = rows
130		r.prev = nil
131		r.logChat = nil
132		r.logBottom = nil
133		r.logLines = nil
134		r.logViewportTop = 0
135		r.logHardwareRow = 0
136		r.logInit = false
137		if r.out != nil && r.history == nil {
138			if r.keepScrollback {
139				// A resize is a discrete user action, like Ctrl+L: the
140				// old-width frame must be fully purged or the in-place
141				// viewport clear leaves the scrolled-away rows behind
142				// (the user otherwise has to press Ctrl+L to fix it).
143				// Emit \x1b[3J to drop the retained scrollback and
144				// repaint clean, accepting VS Code's viewport-snap the
145				// same way Clear does.
146				_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
147			} else {
148				// Clear both screen and (where safe) scrollback so stale
149				// content from the old width doesn't bleed through. Move
150				// to (1,1) so the next DrawLog/writeFull starts from a
151				// clean slate. Use the no-home variant: the explicit
152				// MoveTo below sets the cursor without triggering VS
153				// Code's viewport-snap.
154				_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
155			}
156		}
157	}
158}
159
160// Clear forces a full repaint on the next Draw and clears the screen
161// plus scrollback. In main-screen flow mode this is required whenever
162// already-emitted transcript layout changes (for example ctrl+o
163// expand/collapse), because terminal scrollback cannot be edited
164// reliably once printed.
165func (r *Renderer) Clear() {
166	if r.history != nil {
167		r.history.Invalidate()
168		return
169	}
170	r.prev = nil
171	r.logChat = nil
172	r.logBottom = nil
173	r.logLines = nil
174	r.logViewportTop = 0
175	r.logHardwareRow = 0
176	r.logInit = false
177	if r.keepScrollback {
178		// On VS Code's xterm.js the transcript is taller than the
179		// viewport, so an in-place clear (home + erase-to-end) only
180		// wipes the visible rows: the part that scrolled above the
181		// viewport stays in the retained buffer and the next full
182		// repaint stacks a duplicate above the live frame.
183		//
184		// Clear() is an explicit user refresh (Ctrl+L), so here we do
185		// emit \x1b[3J to actually drop that scrollback, then home and
186		// repaint. This is the one place we accept VS Code's
187		// viewport-snap, because the user asked for a clean screen.
188		// (Implicit repaints, e.g. closing a dialog, deliberately
189		// avoid this path so they never snap the viewport.)
190		if r.out != nil {
191			_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
192		}
193		return
194	}
195	_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
196}
197
198// clearScrollbackSeq returns the scrollback-clear escape, or the
199// empty string when we are running under a terminal where emitting
200// it has user-visible side effects (see Renderer.keepScrollback).
201// Callers concatenate this into a larger control sequence; an empty
202// return value is a no-op there.
203func (r *Renderer) clearScrollbackSeq() string {
204	if r.keepScrollback {
205		return ""
206	}
207	return SeqClearScrollback
208}
209
210// KeepsScrollback reports whether this renderer suppresses the
211// scrollback-clear escape (true under VS Code's terminal). Callers
212// use it to pick a viewport-safe full repaint (Invalidate) over a
213// scrollback-clearing one (Clear) when redrawing overlays.
214func (r *Renderer) KeepsScrollback() bool { return r.keepScrollback }
215
216// Invalidate forces a full repaint on the next Draw without clearing the
217// whole terminal first. Useful when the cached diff is unreliable but a
218// visible full-screen flash would be too distracting.
219func (r *Renderer) Invalidate() {
220	if r.history != nil {
221		r.history.Invalidate()
222		return
223	}
224	r.prev = nil
225	r.logLines = nil
226}
227
228// Draw updates the terminal so that the visible frame ends with the
229// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
230// the lines slice indicating where to place the terminal cursor; use
231// -1 to hide it.
232// containsImageEscape reports whether the line carries an inline-image
233// escape we must repaint rather than diff against the previous frame.
234func containsImageEscape(s string) bool {
235	return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
236}
237
238// paintBackgroundRow applies the optional theme background to a single
239// already-truncated terminal row. It pads with spaces to cols so the
240// background reaches the right edge, and re-applies the background
241// after full SGR resets inside the row so local styling does not punch
242// transparent holes through the global tint.
243func paintBackgroundRow(line string, cols int, th Theme) string {
244	bg := th.BackgroundStyle()
245	if bg == "" || cols <= 0 || containsImageEscape(line) {
246		return line
247	}
248	line = strings.ReplaceAll(line, reset, reset+bg)
249	if w := visibleWidth(line); w < cols {
250		line += strings.Repeat(" ", cols-w)
251	}
252	return bg + line + reset
253}
254
255// truncateToWidth clips s so its on-screen width doesn't exceed cols
256// cells, preserving ANSI CSI escape sequences (which don't consume
257// cells). Lines carrying an inline-image escape are returned as-is
258// since we can't measure their painted size.
259//
260// Fast path: a byte-length <= cols is a conservative upper bound
261// guaranteeing the cell width is also <= cols, so we skip all the
262// rune-width math. That covers the vast majority of lines in a
263// transcript (narrow terminals wrap early; wide ones leave headroom).
264func truncateToWidth(s string, cols int) string {
265	if cols <= 0 || containsImageEscape(s) {
266		return s
267	}
268	if len(s) <= cols {
269		return s
270	}
271	var out strings.Builder
272	out.Grow(len(s))
273	seen := 0
274	runes := []rune(s)
275	for i := 0; i < len(runes); {
276		r := runes[i]
277		// CSI escape sequence (ESC [ ... final): zero-width.
278		if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
279			out.WriteRune(r)
280			out.WriteRune(runes[i+1])
281			i += 2
282			for i < len(runes) {
283				c := runes[i]
284				out.WriteRune(c)
285				i++
286				if c >= 0x40 && c <= 0x7e {
287					break
288				}
289			}
290			continue
291		}
292		rw := runewidthRune(r)
293		if seen+rw > cols {
294			// Flush any trailing ANSI escapes (resets, erase-to-EOL)
295			// so background colors and cleanup sequences survive.
296			for i < len(runes) {
297				if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
298					out.WriteRune(runes[i])
299					out.WriteRune(runes[i+1])
300					i += 2
301					for i < len(runes) {
302						c := runes[i]
303						out.WriteRune(c)
304						i++
305						if c >= 0x40 && c <= 0x7e {
306							break
307						}
308					}
309				} else {
310					break
311				}
312			}
313			break
314		}
315		out.WriteRune(r)
316		seen += rw
317		i++
318	}
319	return out.String()
320}
321
322func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
323	if r.cols == 0 || r.rows == 0 {
324		return
325	}
326	// Bottom-align: only the last r.rows lines are visible.
327	visible := lines
328	if len(visible) > r.rows {
329		visible = visible[len(visible)-r.rows:]
330		cursorRow -= len(lines) - len(visible)
331	}
332	// Pad to r.rows with empty lines at the top. Every line is also
333	// hard-truncated to cols so the terminal never soft-wraps our output
334	// (which would push the status bar out of its row).
335	frame := make([]string, r.rows)
336	top := r.rows - len(visible)
337	for i := 0; i < top; i++ {
338		frame[i] = ""
339	}
340	for i, line := range visible {
341		frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
342	}
343	if r.theme.Background != nil {
344		for i := 0; i < top; i++ {
345			frame[i] = paintBackgroundRow("", r.cols, r.theme)
346		}
347	}
348
349	var w strings.Builder
350	w.WriteString(SeqSynchronizedOn)
351	w.WriteString(SeqHideCursor)
352
353	// When inline images are in play we always full-repaint (clear
354	// screen first, then rewrite every row). Terminals manage image
355	// pixels in a layer we cannot diff against, so the per-line cache
356	// is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
357	// the common code path below is the fast cached diff.
358	curHasImage := false
359	curHasKittyImage := false
360	for _, l := range frame {
361		if containsImageEscape(l) {
362			curHasImage = true
363			if strings.Contains(l, "\x1b_G") {
364				curHasKittyImage = true
365			}
366		}
367	}
368	forceAll := curHasImage || r.prevHadImage
369	if forceAll {
370		// No-home variant: the per-row MoveTo(i+1, 1) writes in the
371		// loop below position the cursor for every painted row, so
372		// the embedded \x1b[H would only serve to make VS Code snap
373		// its scrollbar to the top of the viewport on every image or
374		// selection-highlight frame.
375		w.WriteString(SeqClearScreenNoHome)
376		if curHasKittyImage {
377			// Delete previously placed kitty images once per frame,
378			// before rewriting all rows. Doing this inside each image
379			// escape makes only the last image in the frame survive.
380			w.WriteString("\x1b_Ga=d\x1b\\")
381		}
382	}
383
384	// Detect selection highlights: if the current OR previous frame
385	// has selection-background rows, force full repaint. VS Code's
386	// terminal doesn't reliably clear background colors on row
387	// overwrites, leaving ghost highlights behind.
388	hasSelection := false
389	if r.theme.Background == nil {
390		selectionBG := sgrBG(r.theme.SelectionBG)
391		for _, l := range frame {
392			if selectionBG != "" && strings.Contains(l, selectionBG) {
393				hasSelection = true
394				break
395			}
396		}
397		if !hasSelection && r.prev != nil {
398			for _, l := range r.prev {
399				if selectionBG != "" && strings.Contains(l, selectionBG) {
400					hasSelection = true
401					break
402				}
403			}
404		}
405	}
406
407	full := r.prev == nil || len(r.prev) != r.rows
408	for i := 0; i < r.rows; i++ {
409		if full || forceAll || hasSelection || r.prev[i] != frame[i] {
410			w.WriteString(MoveTo(i+1, 1))
411			w.WriteString("\x1b[0m") // reset all attributes first
412			w.WriteString(SeqClearLine)
413			w.WriteString(frame[i])
414		}
415	}
416
417	if cursorRow >= 0 {
418		absRow := top + cursorRow + 1
419		absCol := cursorCol + 1
420		if absRow >= 1 && absRow <= r.rows {
421			w.WriteString(MoveTo(absRow, absCol))
422			w.WriteString(SeqShowCursor)
423		}
424	}
425	w.WriteString(SeqSynchronizedOff)
426
427	_, _ = io.WriteString(r.out, w.String())
428
429	r.prev = frame
430	r.prevHadImage = curHasImage
431	r.cursorRow = cursorRow
432	r.cursorCol = cursorCol
433}
434
435// DrawHistory displays a bounded history frame without modifying native
436// scrollback. Cursor coordinates are relative to bottom, as in DrawLog.
437func (r *Renderer) DrawHistory(chat, bottom []string, cursorBottomRow, cursorCol int) {
438	if r.history == nil {
439		_, _ = io.WriteString(r.out, SeqAltScreenOn)
440		r.history = &Renderer{out: r.out, cols: r.cols, rows: r.rows, theme: r.theme}
441	}
442	frame := append(append([]string(nil), chat...), bottom...)
443	cursorRow := cursorBottomRow
444	if cursorRow >= 0 {
445		cursorRow += len(chat)
446	}
447	r.history.Draw(frame, cursorRow, cursorCol)
448}
449
450// CloseHistory restores the main screen, including its saved cursor. Call on
451// shutdown as well as when returning to the live log.
452func (r *Renderer) CloseHistory() {
453	if r.history != nil {
454		_, _ = io.WriteString(r.out, SeqAltScreenOff)
455		r.history = nil
456	}
457}
458
459// DrawLog renders zot in the terminal's main screen as normal terminal
460// flow rather than a fixed full-screen frame. Chat lines are emitted into
461// native scrollback and the bottom block is redrawn in place at the end.
462// Cursor coordinates are relative to bottom, not the full frame.
463func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
464	r.CloseHistory()
465	if r.cols == 0 || r.rows == 0 {
466		return
467	}
468	if len(bottom) == 0 {
469		bottom = []string{""}
470	}
471	chatFrame := make([]string, len(chat))
472	for i, line := range chat {
473		chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
474	}
475	bottomFrame := make([]string, len(bottom))
476	for i, line := range bottom {
477		bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
478	}
479
480	// Always reserve one real row below the editor/status band. This is
481	// renderer-owned (not a best-effort trailing blank in the caller's
482	// bottom block), so the logical-buffer diff keeps it visible and cursor
483	// placement remains relative to the editor itself.
484	const bottomMarginRows = 1
485	lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows)
486	lines = append(lines, chatFrame...)
487	lines = append(lines, bottomFrame...)
488	for range bottomMarginRows {
489		lines = append(lines, paintBackgroundRow("", r.cols, r.theme))
490	}
491	// In main-screen flow mode zot normally emits only its logical
492	// content rows and leaves the rest of the terminal viewport alone.
493	// When a theme background is configured, fill that otherwise-idle
494	// space with painted blank rows so the full window is tinted while
495	// keeping the scrollback-oriented renderer model unchanged for the
496	// default transparent case.
497	if r.theme.Background != nil {
498		for len(lines) < r.rows {
499			lines = append(lines, paintBackgroundRow("", r.cols, r.theme))
500		}
501	}
502	if len(lines) == 0 {
503		lines = []string{""}
504	}
505
506	cursorTargetRow := -1
507	if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
508		cursorTargetRow = len(chatFrame) + cursorBottomRow
509	}
510
511	// Idle no-op fast path. When the buffer AND the cursor position
512	// haven't changed since the last DrawLog, emit nothing. The
513	// alternative — always writing SeqHideCursor + cursor-position +
514	// SeqShowCursor — resets the terminal's cursor blink timer on
515	// every tick. At our 120ms animation cadence that means the
516	// caret in an idle dialog editor (e.g. an open swarm transcript
517	// for an agent that's currently idle) appears as a solid block
518	// that never blinks, because we keep "showing" it before the
519	// terminal can blink it off. Bailing out here lets the OS run
520	// its blink cycle.
521	if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
522		return
523	}
524
525	var w strings.Builder
526	w.WriteString(SeqSynchronizedOn)
527	w.WriteString(SeqHideCursor)
528
529	writeFull := func(clear, purgeScrollback bool) {
530		if clear {
531			w.WriteString(SeqDeleteKittyImages)
532			if r.keepScrollback {
533				// VS Code's xterm.js scrolls the visible content up into
534				// scrollback on \x1b[2J, which duplicates the frame (the
535				// old paint stays above the new one). Home to the
536				// viewport top and erase-to-end (\x1b[0J) instead: that
537				// clears the visible screen in place without pushing the
538				// previous frame into scrollback. We still cannot drop
539				// existing scrollback (\x1b[3J snaps the viewport there),
540				// but a full repaint no longer stacks a fresh copy below
541				// the old one.
542				w.WriteString(SeqCursorHome)
543				w.WriteString(SeqClearToEnd)
544			} else {
545				w.WriteString(SeqClearScreenNoHome)
546				// Purging scrollback invalidates native terminal selections.
547				// It is safe on the first paint because no zot output can be
548				// selected yet, but implicit recovery repaints happen while an
549				// agent is streaming and must preserve the user's selection.
550				if purgeScrollback {
551					w.WriteString(r.clearScrollbackSeq())
552				}
553				w.WriteString(MoveTo(1, 1))
554			}
555		}
556		for idx, line := range lines {
557			if idx > 0 {
558				w.WriteString("\r\n")
559			}
560			w.WriteString("\x1b[0m")
561			w.WriteString(SeqClearLine)
562			w.WriteString(line)
563		}
564		r.logHardwareRow = len(lines) - 1
565		r.logViewportTop = len(lines) - r.rows
566		if r.logViewportTop < 0 {
567			r.logViewportTop = 0
568		}
569	}
570
571	moveToLogicalRow := func(targetRow int) {
572		if targetRow < 0 {
573			targetRow = 0
574		}
575		if targetRow >= len(lines) {
576			targetRow = len(lines) - 1
577		}
578		viewportBottom := r.logViewportTop + r.rows - 1
579		if targetRow > viewportBottom {
580			currentScreenRow := r.logHardwareRow - r.logViewportTop
581			if currentScreenRow < 0 {
582				currentScreenRow = 0
583			}
584			if currentScreenRow >= r.rows {
585				currentScreenRow = r.rows - 1
586			}
587			moveToBottom := r.rows - 1 - currentScreenRow
588			if moveToBottom > 0 {
589				w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
590			}
591			scroll := targetRow - viewportBottom
592			for s := 0; s < scroll; s++ {
593				w.WriteString("\r\n")
594			}
595			r.logViewportTop += scroll
596			r.logHardwareRow = targetRow
597			return
598		}
599		currentScreenRow := r.logHardwareRow - r.logViewportTop
600		targetScreenRow := targetRow - r.logViewportTop
601		lineDiff := targetScreenRow - currentScreenRow
602		if lineDiff > 0 {
603			w.WriteString("\x1b[" + itoa(lineDiff) + "B")
604		} else if lineDiff < 0 {
605			w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
606		}
607		r.logHardwareRow = targetRow
608	}
609
610	positionCursor := func() {
611		if cursorTargetRow < 0 || cursorTargetRow >= len(lines) {
612			return
613		}
614		moveToLogicalRow(cursorTargetRow)
615		w.WriteString("\r")
616		if cursorCol > 0 {
617			w.WriteString("\x1b[" + itoa(cursorCol) + "C")
618		}
619		w.WriteString(SeqShowCursor)
620	}
621
622	// Selection-highlight workaround removed: it could mis-invalidate
623	// user-bubble padding rows whose colored bg made botHasHL trip,
624	// causing the next diff pass to leave those rows visually thinned
625	// because the cached entry was the \x00 sentinel rather than the
626	// real previous bg-colored row.
627
628	wasInitialized := r.logInit
629	bottomShrunkAboveViewport := false
630	if len(bottomFrame) < len(r.logBottom) {
631		newViewportTop := len(lines) - r.rows
632		if newViewportTop < 0 {
633			newViewportTop = 0
634		}
635		// If the shorter bottom frame starts above the currently addressable
636		// viewport, relative cursor movement cannot repaint its prefix. This
637		// happens when returning from a moderately long dialog to a short one.
638		bottomShrunkAboveViewport = newViewportTop < r.logViewportTop
639	}
640	full := !wasInitialized || len(r.logLines) == 0 || bottomShrunkAboveViewport
641	if full {
642		writeFull(true, !wasInitialized)
643		r.logInit = true
644	} else {
645		firstChanged := -1
646		lastChanged := -1
647		maxLines := len(lines)
648		if len(r.logLines) > maxLines {
649			maxLines = len(r.logLines)
650		}
651		for idx := 0; idx < maxLines; idx++ {
652			oldLine := ""
653			if idx < len(r.logLines) {
654				oldLine = r.logLines[idx]
655			}
656			newLine := ""
657			if idx < len(lines) {
658				newLine = lines[idx]
659			}
660			if oldLine != newLine {
661				if firstChanged == -1 {
662					firstChanged = idx
663				}
664				lastChanged = idx
665			}
666		}
667		// Buffer grew but the appended rows were empty (or otherwise
668		// equal to the implicit "" past the old end). The diff above
669		// won't flag those rows, yet the renderer still needs to
670		// advance its hardware cursor / viewport tracking past them so
671		// the next render starts from the correct position. Treat the
672		// extension as changed.
673		if len(lines) > len(r.logLines) {
674			if firstChanged == -1 {
675				firstChanged = len(r.logLines)
676			}
677			if lastChanged < len(lines)-1 {
678				lastChanged = len(lines) - 1
679			}
680		}
681
682		// Rows that have scrolled above the viewport are immutable terminal
683		// history. Replaying the full logical buffer to update one of them
684		// duplicates that history, and erasing it first destroys native text
685		// selections. Ignore inaccessible changes and patch only the first
686		// changed row that is still addressable.
687		if firstChanged >= 0 && firstChanged < r.logViewportTop {
688			delta := len(chatFrame) - len(r.logChat)
689			reflowAbove := false
690			if delta != 0 {
691				overlapEnd := len(chatFrame)
692				if len(r.logChat) < overlapEnd {
693					overlapEnd = len(r.logChat)
694				}
695				for idx := r.logViewportTop; idx < overlapEnd; idx++ {
696					if r.logChat[idx] != chatFrame[idx] {
697						reflowAbove = true
698						break
699					}
700				}
701			}
702			if reflowAbove {
703				// A height change above the viewport shifts every following
704				// logical row, but it does not move the rows already painted by
705				// the terminal. Rebase our logical coordinates by the same delta
706				// instead of clearing and replaying the transcript. The inaccessible
707				// prefix remains the historical snapshot the terminal actually
708				// holds; addressable chat and bottom rows stay aligned with their
709				// existing physical rows.
710				oldLines := r.logLines
711				oldChatLen := len(r.logChat)
712				newViewportTop := r.logViewportTop + delta
713				if newViewportTop < 0 {
714					newViewportTop = 0
715				}
716				rebasedLen := len(oldLines) + delta
717				if rebasedLen < 0 {
718					rebasedLen = 0
719				}
720				rebased := make([]string, rebasedLen)
721				for idx := range rebased {
722					switch {
723					case idx < newViewportTop:
724						// These rows cannot be repainted. Mark the new logical
725						// contents as accepted so they do not trigger a replay.
726						if idx < len(lines) {
727							rebased[idx] = lines[idx]
728						}
729					case idx < len(chatFrame):
730						oldIdx := idx - delta
731						if oldIdx >= 0 && oldIdx < oldChatLen {
732							rebased[idx] = oldLines[oldIdx]
733						}
734					default:
735						// Bottom rows are indexed relative to the end of chat,
736						// so preserve that relative position across the reflow.
737						oldIdx := oldChatLen + idx - len(chatFrame)
738						if oldIdx >= 0 && oldIdx < len(oldLines) {
739							rebased[idx] = oldLines[oldIdx]
740						}
741					}
742				}
743				r.logLines = rebased
744				r.logViewportTop = newViewportTop
745				r.logHardwareRow += delta
746				if r.logHardwareRow < 0 {
747					r.logHardwareRow = 0
748				}
749				maxLines = len(lines)
750				if len(r.logLines) > maxLines {
751					maxLines = len(r.logLines)
752				}
753			}
754
755			firstChanged = -1
756			lastChanged = -1
757			for idx := r.logViewportTop; idx < maxLines; idx++ {
758				oldLine := ""
759				if idx < len(r.logLines) {
760					oldLine = r.logLines[idx]
761				}
762				newLine := ""
763				if idx < len(lines) {
764					newLine = lines[idx]
765				}
766				if oldLine != newLine {
767					if firstChanged == -1 {
768						firstChanged = idx
769					}
770					lastChanged = idx
771				}
772			}
773			// A newly appended blank row compares equal to the implicit empty
774			// row past the old slice, but it still has to advance the terminal.
775			if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop {
776				firstChanged = len(r.logLines)
777				lastChanged = len(lines) - 1
778			}
779		}
780
781		if firstChanged == -1 {
782			// No addressable content changes; the final cursor positioning below
783			// may still move the hardware cursor if the editor cursor changed.
784		} else {
785			prevViewportTop := r.logViewportTop
786			viewportTop := prevViewportTop
787			hardwareRow := r.logHardwareRow
788			prevViewportBottom := prevViewportTop + r.rows - 1
789			appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0
790			moveTarget := firstChanged
791			if appendStart {
792				moveTarget = firstChanged - 1
793			}
794
795			if moveTarget > prevViewportBottom {
796				currentScreenRow := hardwareRow - prevViewportTop
797				if currentScreenRow < 0 {
798					currentScreenRow = 0
799				}
800				if currentScreenRow >= r.rows {
801					currentScreenRow = r.rows - 1
802				}
803				moveToBottom := r.rows - 1 - currentScreenRow
804				if moveToBottom > 0 {
805					w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
806				}
807				scroll := moveTarget - prevViewportBottom
808				for s := 0; s < scroll; s++ {
809					w.WriteString("\r\n")
810				}
811				prevViewportTop += scroll
812				viewportTop += scroll
813				hardwareRow = moveTarget
814			}
815
816			currentScreenRow := hardwareRow - prevViewportTop
817			targetScreenRow := moveTarget - viewportTop
818			lineDiff := targetScreenRow - currentScreenRow
819			if lineDiff > 0 {
820				w.WriteString("\x1b[" + itoa(lineDiff) + "B")
821			} else if lineDiff < 0 {
822				w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
823			}
824			if appendStart {
825				w.WriteString("\r\n")
826			} else {
827				w.WriteString("\r")
828			}
829
830			renderEnd := lastChanged
831			if renderEnd >= len(lines) {
832				renderEnd = len(lines) - 1
833			}
834			for idx := firstChanged; idx <= renderEnd; idx++ {
835				if idx > firstChanged {
836					w.WriteString("\r\n")
837				}
838				w.WriteString("\x1b[0m")
839				w.WriteString(SeqClearLine)
840				w.WriteString(lines[idx])
841			}
842			finalRow := renderEnd
843			fullRepaint := false
844			if len(r.logLines) > len(lines) {
845				extra := len(r.logLines) - len(lines)
846				if extra >= r.rows {
847					writeFull(true, false)
848					fullRepaint = true
849				} else {
850					for e := 0; e < extra; e++ {
851						w.WriteString("\x1b[1B")
852						w.WriteString("\r")
853						w.WriteString("\x1b[0m")
854						w.WriteString(SeqClearLine)
855						finalRow++
856					}
857					if extra > 0 {
858						w.WriteString("\x1b[" + itoa(extra) + "A")
859						finalRow -= extra
860					}
861				}
862			}
863			if !fullRepaint {
864				r.logHardwareRow = finalRow
865				r.logViewportTop = viewportTop
866				if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
867					r.logViewportTop = minTop
868				}
869				if r.logViewportTop < 0 {
870					r.logViewportTop = 0
871				}
872			}
873		}
874	}
875
876	positionCursor()
877	w.WriteString(SeqSynchronizedOff)
878	_, _ = io.WriteString(r.out, w.String())
879
880	r.logChat = append(r.logChat[:0], chatFrame...)
881	r.logBottom = append(r.logBottom[:0], bottomFrame...)
882	r.logLines = append(r.logLines[:0], lines...)
883	r.cursorRow = cursorBottomRow
884	r.cursorCol = cursorCol
885}
886
887// sameLines reports whether two []string have the exact same
888// length and per-row contents. Used by DrawLog's idle no-op fast
889// path; cheap enough at our frame rates and far simpler than
890// hashing every byte.
891func sameLines(a, b []string) bool {
892	if len(a) != len(b) {
893		return false
894	}
895	for i := range a {
896		if a[i] != b[i] {
897			return false
898		}
899	}
900	return true
901}
902
903func writeBlock(w *strings.Builder, lines []string) {
904	for i, line := range lines {
905		w.WriteString("\x1b[0m")
906		w.WriteString(SeqClearLine)
907		w.WriteString(line)
908		if i < len(lines)-1 {
909			w.WriteString("\r\n")
910		}
911	}
912}
913
914func tailTruncated(lines []string, maxRows, cols int) []string {
915	if maxRows <= 0 {
916		return nil
917	}
918	if len(lines) > maxRows {
919		lines = lines[len(lines)-maxRows:]
920	}
921	out := make([]string, len(lines))
922	for i, line := range lines {
923		out[i] = truncateToWidth(line, cols)
924	}
925	return out
926}

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