v0.38.1 969e52a

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

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