v0.11.2 619aabb

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

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