v0.1.98 d55af26

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
75// NewRenderer returns a renderer that writes to out.
76//
77// Detects VS Code's integrated terminal via $TERM_PROGRAM and, when
78// detected, disables emission of \x1b[3J for the reasons documented
79// on Renderer.keepScrollback. The env var is set by VS Code itself
80// (and by Cursor, which forks VS Code's terminal — same xterm.js,
81// same bug), so no user configuration is required.
82func NewRenderer(out io.Writer) *Renderer {
83	return &Renderer{
84		out:            out,
85		keepScrollback: os.Getenv("TERM_PROGRAM") == "vscode",
86	}
87}
88
89// Resize tells the renderer the current terminal size.
90//
91// On a real size change we also issue a clear-screen so the next Draw
92// starts from a blank slate. Without the clear, characters from the
93// old (wider) layout linger past the new right edge and rows from
94// before the new bottom hang around as garbage.
95func (r *Renderer) ResetScrollRegion() {
96	if r.out != nil {
97		_, _ = io.WriteString(r.out, SeqResetScrollRegion)
98	}
99}
100
101func (r *Renderer) Resize(cols, rows int) {
102	if cols != r.cols || rows != r.rows {
103		r.cols = cols
104		r.rows = rows
105		r.prev = nil
106		r.logChat = nil
107		r.logBottom = nil
108		r.logLines = nil
109		r.logViewportTop = 0
110		r.logHardwareRow = 0
111		r.logInit = false
112		if r.out != nil {
113			// Clear both screen and (where safe) scrollback so stale
114			// content from the old width doesn't bleed through. Move
115			// to (1,1) so the next DrawLog/writeFull starts from a
116			// clean slate. Use the no-home variant: the explicit
117			// MoveTo below sets the cursor without triggering VS
118			// Code's viewport-snap. See Renderer.keepScrollback for
119			// why we skip \x1b[3J on VS Code's terminal.
120			_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
121		}
122	}
123}
124
125// Clear forces a full repaint on the next Draw and clears the screen
126// plus scrollback. In main-screen flow mode this is required whenever
127// already-emitted transcript layout changes (for example ctrl+o
128// expand/collapse), because terminal scrollback cannot be edited
129// reliably once printed.
130func (r *Renderer) Clear() {
131	r.prev = nil
132	r.logChat = nil
133	r.logBottom = nil
134	r.logLines = nil
135	r.logViewportTop = 0
136	r.logHardwareRow = 0
137	r.logInit = false
138	_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
139}
140
141// clearScrollbackSeq returns the scrollback-clear escape, or the
142// empty string when we are running under a terminal where emitting
143// it has user-visible side effects (see Renderer.keepScrollback).
144// Callers concatenate this into a larger control sequence; an empty
145// return value is a no-op there.
146func (r *Renderer) clearScrollbackSeq() string {
147	if r.keepScrollback {
148		return ""
149	}
150	return SeqClearScrollback
151}
152
153// Invalidate forces a full repaint on the next Draw without clearing the
154// whole terminal first. Useful when the cached diff is unreliable but a
155// visible full-screen flash would be too distracting.
156func (r *Renderer) Invalidate() {
157	r.prev = nil
158	r.logLines = nil
159}
160
161// Draw updates the terminal so that the visible frame ends with the
162// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
163// the lines slice indicating where to place the terminal cursor; use
164// -1 to hide it.
165// containsImageEscape reports whether the line carries an inline-image
166// escape we must repaint rather than diff against the previous frame.
167func containsImageEscape(s string) bool {
168	return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
169}
170
171// truncateToWidth clips s so its on-screen width doesn't exceed cols
172// cells, preserving ANSI CSI escape sequences (which don't consume
173// cells). Lines carrying an inline-image escape are returned as-is
174// since we can't measure their painted size.
175//
176// Fast path: a byte-length <= cols is a conservative upper bound
177// guaranteeing the cell width is also <= cols, so we skip all the
178// rune-width math. That covers the vast majority of lines in a
179// transcript (narrow terminals wrap early; wide ones leave headroom).
180func truncateToWidth(s string, cols int) string {
181	if cols <= 0 || containsImageEscape(s) {
182		return s
183	}
184	if len(s) <= cols {
185		return s
186	}
187	var out strings.Builder
188	out.Grow(len(s))
189	seen := 0
190	runes := []rune(s)
191	for i := 0; i < len(runes); {
192		r := runes[i]
193		// CSI escape sequence (ESC [ ... final): zero-width.
194		if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
195			out.WriteRune(r)
196			out.WriteRune(runes[i+1])
197			i += 2
198			for i < len(runes) {
199				c := runes[i]
200				out.WriteRune(c)
201				i++
202				if c >= 0x40 && c <= 0x7e {
203					break
204				}
205			}
206			continue
207		}
208		rw := runewidthRune(r)
209		if seen+rw > cols {
210			// Flush any trailing ANSI escapes (resets, erase-to-EOL)
211			// so background colors and cleanup sequences survive.
212			for i < len(runes) {
213				if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
214					out.WriteRune(runes[i])
215					out.WriteRune(runes[i+1])
216					i += 2
217					for i < len(runes) {
218						c := runes[i]
219						out.WriteRune(c)
220						i++
221						if c >= 0x40 && c <= 0x7e {
222							break
223						}
224					}
225				} else {
226					break
227				}
228			}
229			break
230		}
231		out.WriteRune(r)
232		seen += rw
233		i++
234	}
235	return out.String()
236}
237
238func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
239	if r.cols == 0 || r.rows == 0 {
240		return
241	}
242	// Bottom-align: only the last r.rows lines are visible.
243	visible := lines
244	if len(visible) > r.rows {
245		visible = visible[len(visible)-r.rows:]
246		cursorRow -= len(lines) - len(visible)
247	}
248	// Pad to r.rows with empty lines at the top. Every line is also
249	// hard-truncated to cols so the terminal never soft-wraps our output
250	// (which would push the status bar out of its row).
251	frame := make([]string, r.rows)
252	top := r.rows - len(visible)
253	for i := 0; i < top; i++ {
254		frame[i] = ""
255	}
256	for i, line := range visible {
257		frame[top+i] = truncateToWidth(line, r.cols)
258	}
259
260	var w strings.Builder
261	w.WriteString(SeqSynchronizedOn)
262	w.WriteString(SeqHideCursor)
263
264	// When inline images are in play we always full-repaint (clear
265	// screen first, then rewrite every row). Terminals manage image
266	// pixels in a layer we cannot diff against, so the per-line cache
267	// is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
268	// the common code path below is the fast cached diff.
269	curHasImage := false
270	curHasKittyImage := false
271	for _, l := range frame {
272		if containsImageEscape(l) {
273			curHasImage = true
274			if strings.Contains(l, "\x1b_G") {
275				curHasKittyImage = true
276			}
277		}
278	}
279	forceAll := curHasImage || r.prevHadImage
280	if forceAll {
281		// No-home variant: the per-row MoveTo(i+1, 1) writes in the
282		// loop below position the cursor for every painted row, so
283		// the embedded \x1b[H would only serve to make VS Code snap
284		// its scrollbar to the top of the viewport on every image or
285		// selection-highlight frame.
286		w.WriteString(SeqClearScreenNoHome)
287		if curHasKittyImage {
288			// Delete previously placed kitty images once per frame,
289			// before rewriting all rows. Doing this inside each image
290			// escape makes only the last image in the frame survive.
291			w.WriteString("\x1b_Ga=d\x1b\\")
292		}
293	}
294
295	// Detect selection highlights: if the current OR previous frame
296	// has selection-background rows, force full repaint. VS Code's
297	// terminal doesn't reliably clear background colors on row
298	// overwrites, leaving ghost highlights behind.
299	hasSelection := false
300	for _, l := range frame {
301		if strings.Contains(l, "\x1b[48;5;") {
302			hasSelection = true
303			break
304		}
305	}
306	if !hasSelection && r.prev != nil {
307		for _, l := range r.prev {
308			if strings.Contains(l, "\x1b[48;5;") {
309				hasSelection = true
310				break
311			}
312		}
313	}
314
315	full := r.prev == nil || len(r.prev) != r.rows
316	for i := 0; i < r.rows; i++ {
317		if full || forceAll || hasSelection || r.prev[i] != frame[i] {
318			w.WriteString(MoveTo(i+1, 1))
319			w.WriteString("\x1b[0m") // reset all attributes first
320			w.WriteString(SeqClearLine)
321			w.WriteString(frame[i])
322		}
323	}
324
325	if cursorRow >= 0 {
326		absRow := top + cursorRow + 1
327		absCol := cursorCol + 1
328		if absRow >= 1 && absRow <= r.rows {
329			w.WriteString(MoveTo(absRow, absCol))
330			w.WriteString(SeqShowCursor)
331		}
332	}
333	w.WriteString(SeqSynchronizedOff)
334
335	_, _ = io.WriteString(r.out, w.String())
336
337	r.prev = frame
338	r.prevHadImage = curHasImage
339	r.cursorRow = cursorRow
340	r.cursorCol = cursorCol
341}
342
343// DrawLog renders zot in the terminal's main screen as normal terminal
344// flow rather than a fixed full-screen frame. Chat lines are emitted once
345// into the host terminal scrollback; the current bottom block (dialogs,
346// slash popup, status, editor) is erased and redrawn in place at the end.
347//
348// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
349func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
350	if r.cols == 0 || r.rows == 0 {
351		return
352	}
353	if len(bottom) == 0 {
354		bottom = []string{""}
355	}
356	chatFrame := make([]string, len(chat))
357	for i, line := range chat {
358		chatFrame[i] = truncateToWidth(line, r.cols)
359	}
360	bottomFrame := make([]string, len(bottom))
361	for i, line := range bottom {
362		bottomFrame[i] = truncateToWidth(line, r.cols)
363	}
364
365	// Always reserve one real row below the editor/status band. This is
366	// renderer-owned (not a best-effort trailing blank in the caller's
367	// bottom block), so the logical-buffer diff keeps it visible and cursor
368	// placement remains relative to the editor itself.
369	const bottomMarginRows = 1
370	lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows)
371	lines = append(lines, chatFrame...)
372	lines = append(lines, bottomFrame...)
373	for range bottomMarginRows {
374		lines = append(lines, "")
375	}
376	if len(lines) == 0 {
377		lines = []string{""}
378	}
379
380	cursorTargetRow := -1
381	if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
382		cursorTargetRow = len(chatFrame) + cursorBottomRow
383	}
384
385	// Idle no-op fast path. When the buffer AND the cursor position
386	// haven't changed since the last DrawLog, emit nothing. The
387	// alternative — always writing SeqHideCursor + cursor-position +
388	// SeqShowCursor — resets the terminal's cursor blink timer on
389	// every tick. At our 120ms animation cadence that means the
390	// caret in an idle dialog editor (e.g. an open swarm transcript
391	// for an agent that's currently idle) appears as a solid block
392	// that never blinks, because we keep "showing" it before the
393	// terminal can blink it off. Bailing out here lets the OS run
394	// its blink cycle.
395	if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
396		return
397	}
398
399	var w strings.Builder
400	w.WriteString(SeqSynchronizedOn)
401	w.WriteString(SeqHideCursor)
402
403	writeFull := func(clear bool) {
404		if clear {
405			w.WriteString(SeqDeleteKittyImages)
406			w.WriteString(SeqClearScreenNoHome)
407			w.WriteString(r.clearScrollbackSeq())
408			w.WriteString(MoveTo(1, 1))
409		}
410		for idx, line := range lines {
411			if idx > 0 {
412				w.WriteString("\r\n")
413			}
414			w.WriteString("\x1b[0m")
415			w.WriteString(SeqClearLine)
416			w.WriteString(line)
417		}
418		r.logHardwareRow = len(lines) - 1
419		r.logViewportTop = len(lines) - r.rows
420		if r.logViewportTop < 0 {
421			r.logViewportTop = 0
422		}
423	}
424
425	moveToLogicalRow := func(targetRow int) {
426		if targetRow < 0 {
427			targetRow = 0
428		}
429		if targetRow >= len(lines) {
430			targetRow = len(lines) - 1
431		}
432		viewportBottom := r.logViewportTop + r.rows - 1
433		if targetRow > viewportBottom {
434			currentScreenRow := r.logHardwareRow - r.logViewportTop
435			if currentScreenRow < 0 {
436				currentScreenRow = 0
437			}
438			if currentScreenRow >= r.rows {
439				currentScreenRow = r.rows - 1
440			}
441			moveToBottom := r.rows - 1 - currentScreenRow
442			if moveToBottom > 0 {
443				w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
444			}
445			scroll := targetRow - viewportBottom
446			for s := 0; s < scroll; s++ {
447				w.WriteString("\r\n")
448			}
449			r.logViewportTop += scroll
450			r.logHardwareRow = targetRow
451			return
452		}
453		currentScreenRow := r.logHardwareRow - r.logViewportTop
454		targetScreenRow := targetRow - r.logViewportTop
455		lineDiff := targetScreenRow - currentScreenRow
456		if lineDiff > 0 {
457			w.WriteString("\x1b[" + itoa(lineDiff) + "B")
458		} else if lineDiff < 0 {
459			w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
460		}
461		r.logHardwareRow = targetRow
462	}
463
464	positionCursor := func() {
465		if cursorTargetRow < 0 || cursorTargetRow >= len(lines) {
466			return
467		}
468		moveToLogicalRow(cursorTargetRow)
469		w.WriteString("\r")
470		if cursorCol > 0 {
471			w.WriteString("\x1b[" + itoa(cursorCol) + "C")
472		}
473		w.WriteString(SeqShowCursor)
474	}
475
476	// Selection-highlight workaround removed: it could mis-invalidate
477	// user-bubble padding rows whose colored bg made botHasHL trip,
478	// causing the next diff pass to leave those rows visually thinned
479	// because the cached entry was the \x00 sentinel rather than the
480	// real previous bg-colored row.
481
482	full := !r.logInit || len(r.logLines) == 0
483	if full {
484		writeFull(true)
485		r.logInit = true
486	} else {
487		firstChanged := -1
488		lastChanged := -1
489		maxLines := len(lines)
490		if len(r.logLines) > maxLines {
491			maxLines = len(r.logLines)
492		}
493		for idx := 0; idx < maxLines; idx++ {
494			oldLine := ""
495			if idx < len(r.logLines) {
496				oldLine = r.logLines[idx]
497			}
498			newLine := ""
499			if idx < len(lines) {
500				newLine = lines[idx]
501			}
502			if oldLine != newLine {
503				if firstChanged == -1 {
504					firstChanged = idx
505				}
506				lastChanged = idx
507			}
508		}
509		// Buffer grew but the appended rows were empty (or otherwise
510		// equal to the implicit "" past the old end). The diff above
511		// won't flag those rows, yet the renderer still needs to
512		// advance its hardware cursor / viewport tracking past them so
513		// the next render starts from the correct position. Treat the
514		// extension as changed.
515		if len(lines) > len(r.logLines) {
516			if firstChanged == -1 {
517				firstChanged = len(r.logLines)
518			}
519			if lastChanged < len(lines)-1 {
520				lastChanged = len(lines) - 1
521			}
522		}
523
524		if firstChanged == -1 {
525			// No content changes; the final cursor positioning below may still
526			// move the hardware cursor if the editor cursor changed.
527		} else if firstChanged < r.logViewportTop {
528			// Changes above the visible viewport cannot be patched safely.
529			writeFull(true)
530		} else {
531			prevViewportTop := r.logViewportTop
532			viewportTop := prevViewportTop
533			hardwareRow := r.logHardwareRow
534			prevViewportBottom := prevViewportTop + r.rows - 1
535			appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0
536			moveTarget := firstChanged
537			if appendStart {
538				moveTarget = firstChanged - 1
539			}
540
541			if moveTarget > prevViewportBottom {
542				currentScreenRow := hardwareRow - prevViewportTop
543				if currentScreenRow < 0 {
544					currentScreenRow = 0
545				}
546				if currentScreenRow >= r.rows {
547					currentScreenRow = r.rows - 1
548				}
549				moveToBottom := r.rows - 1 - currentScreenRow
550				if moveToBottom > 0 {
551					w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
552				}
553				scroll := moveTarget - prevViewportBottom
554				for s := 0; s < scroll; s++ {
555					w.WriteString("\r\n")
556				}
557				prevViewportTop += scroll
558				viewportTop += scroll
559				hardwareRow = moveTarget
560			}
561
562			currentScreenRow := hardwareRow - prevViewportTop
563			targetScreenRow := moveTarget - viewportTop
564			lineDiff := targetScreenRow - currentScreenRow
565			if lineDiff > 0 {
566				w.WriteString("\x1b[" + itoa(lineDiff) + "B")
567			} else if lineDiff < 0 {
568				w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
569			}
570			if appendStart {
571				w.WriteString("\r\n")
572			} else {
573				w.WriteString("\r")
574			}
575
576			renderEnd := lastChanged
577			if renderEnd >= len(lines) {
578				renderEnd = len(lines) - 1
579			}
580			for idx := firstChanged; idx <= renderEnd; idx++ {
581				if idx > firstChanged {
582					w.WriteString("\r\n")
583				}
584				w.WriteString("\x1b[0m")
585				w.WriteString(SeqClearLine)
586				w.WriteString(lines[idx])
587			}
588			finalRow := renderEnd
589			if len(r.logLines) > len(lines) {
590				extra := len(r.logLines) - len(lines)
591				if extra > r.rows {
592					writeFull(true)
593				} else {
594					for e := 0; e < extra; e++ {
595						w.WriteString("\x1b[1B")
596						w.WriteString("\r")
597						w.WriteString("\x1b[0m")
598						w.WriteString(SeqClearLine)
599						finalRow++
600					}
601					if extra > 0 {
602						w.WriteString("\x1b[" + itoa(extra) + "A")
603						finalRow -= extra
604					}
605				}
606			}
607			r.logHardwareRow = finalRow
608			r.logViewportTop = viewportTop
609			if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
610				r.logViewportTop = minTop
611			}
612			if r.logViewportTop < 0 {
613				r.logViewportTop = 0
614			}
615		}
616	}
617
618	positionCursor()
619	w.WriteString(SeqSynchronizedOff)
620	_, _ = io.WriteString(r.out, w.String())
621
622	r.logChat = append(r.logChat[:0], chatFrame...)
623	r.logBottom = append(r.logBottom[:0], bottomFrame...)
624	r.logLines = append(r.logLines[:0], lines...)
625	r.cursorRow = cursorBottomRow
626	r.cursorCol = cursorCol
627}
628
629// sameLines reports whether two []string have the exact same
630// length and per-row contents. Used by DrawLog's idle no-op fast
631// path; cheap enough at our frame rates and far simpler than
632// hashing every byte.
633func sameLines(a, b []string) bool {
634	if len(a) != len(b) {
635		return false
636	}
637	for i := range a {
638		if a[i] != b[i] {
639			return false
640		}
641	}
642	return true
643}
644
645func writeBlock(w *strings.Builder, lines []string) {
646	for i, line := range lines {
647		w.WriteString("\x1b[0m")
648		w.WriteString(SeqClearLine)
649		w.WriteString(line)
650		if i < len(lines)-1 {
651			w.WriteString("\r\n")
652		}
653	}
654}
655
656func tailTruncated(lines []string, maxRows, cols int) []string {
657	if maxRows <= 0 {
658		return nil
659	}
660	if len(lines) > maxRows {
661		lines = lines[len(lines)-maxRows:]
662	}
663	out := make([]string, len(lines))
664	for i, line := range lines {
665		out[i] = truncateToWidth(line, cols)
666	}
667	return out
668}

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