v0.3.70 5bef29b

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

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