v0.15.0 6b178f8

render.go RawHistoryFolder

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

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