v0.9.2 49bea57

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

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