v0.25.1 025a25b

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close