detached 7ff6942

tui: render interactive mode in main scrollback

commit
7ff6942f71a21307fec0a4a737eded464c77b17f
author
patriceckhart · · 2026-05-04 12:12 UTC
parent
471fdea
signature
unsigned
3 files · +192 -17
M internal/agent/modes/interactive.go +14 -17
@@ -369,9 +369,12 @@ func (i *Interactive) Run(ctx context.Context) error {
369369 // selection over the wheel-speed boost, so we no longer turn it
370370 // on automatically. Wheel events fall through to the terminal's
371371 // own scrollback handler.
372- _, _ = term.Write([]byte(tui.SeqBracketedPasteOn))
373- _, _ = term.Write([]byte(tui.SeqAltScreenOn))
374- defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
372+ // Keep zot on the terminal's main screen. We intentionally do not
373+ // enter the alternate-screen buffer (CSI ?1049h). The renderer emits
374+ // chat as normal terminal flow/scrollback and redraws only the live
375+ // input/status block on normal typing.
376+ _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScrollback + tui.SeqClearScreen + tui.MoveTo(1, 1)))
377+ defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
375378
376379 // Streaming pacer: drains buffered text deltas at a steady rate
377380 // so typewriter feel is identical across providers regardless of
@@ -1061,16 +1064,9 @@ func (i *Interactive) redraw() {
10611064 }
10621065 }
10631066
1064- frame := make([]string, 0, len(visibleChat)+len(bottom))
1065- frame = append(frame, visibleChat...)
1066- frame = append(frame, bottom...)
1067-
10681067 // Default: the real terminal cursor sits on the main editor's
1069- // input position. When an overlay dialog has its own input
1070- // field (today just /btw), route the cursor there instead so
1071- // the blinking cursor shows where the user is actually typing.
1072- // Dialogs without a cursor (model picker, /help, /login, etc.)
1073- // return -1 and the main editor keeps the cursor.
1068+ // input position. In main-screen log mode cursor rows are relative
1069+ // to the fixed bottom band, not the chat transcript.
10741070 // dialogLead is 1 when the bottom region prepends a blank above
10751071 // the dialog block (whenever a dialog is showing) so popup-side
10761072 // cursor positions still land in the right cell.
@@ -1083,23 +1079,23 @@ func (i *Interactive) redraw() {
10831079 // statusLines and edLines (input breathing room). Without
10841080 // these the rendered cursor would land on a blank instead of
10851081 // inside the editor row.
1086- cursorRow := len(visibleChat) + dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
1082+ cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
10871083 cursorCol := curC
10881084 if i.btwDialog.Active() {
10891085 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
1090- cursorRow = len(visibleChat) + dialogLead + r
1086+ cursorRow = dialogLead + r
10911087 cursorCol = c
10921088 }
10931089 }
10941090 if i.dialog.Active() {
10951091 if r, c := i.dialog.CursorPos(cols); r >= 0 {
1096- cursorRow = len(visibleChat) + dialogLead + r
1092+ cursorRow = dialogLead + r
10971093 cursorCol = c
10981094 }
10991095 }
11001096 if i.sessionDialog.Active() {
11011097 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1102- cursorRow = len(visibleChat) + dialogLead + r
1098+ cursorRow = dialogLead + r
11031099 cursorCol = c
11041100 }
11051101 }
@@ -1107,7 +1103,8 @@ func (i *Interactive) redraw() {
11071103 cursorRow = -1
11081104 cursorCol = 0
11091105 }
1110- i.rend.Draw(frame, cursorRow, cursorCol)
1106+ _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
1107+ i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
11111108 }
11121109
11131110 func hasImageEscape(line string) bool {
@@ -369,9 +369,12 @@ func (i *Interactive) Run(ctx context.Context) error {
369 // selection over the wheel-speed boost, so we no longer turn it369 // selection over the wheel-speed boost, so we no longer turn it
370 // on automatically. Wheel events fall through to the terminal's370 // on automatically. Wheel events fall through to the terminal's
371 // own scrollback handler.371 // own scrollback handler.
372- _, _ = term.Write([]byte(tui.SeqBracketedPasteOn))372+ // Keep zot on the terminal's main screen. We intentionally do not
373- _, _ = term.Write([]byte(tui.SeqAltScreenOn))373+ // enter the alternate-screen buffer (CSI ?1049h). The renderer emits
374- defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))374+ // chat as normal terminal flow/scrollback and redraws only the live
375+ // input/status block on normal typing.
376+ _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScrollback + tui.SeqClearScreen + tui.MoveTo(1, 1)))
377+ defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
375 378
376 // Streaming pacer: drains buffered text deltas at a steady rate379 // Streaming pacer: drains buffered text deltas at a steady rate
377 // so typewriter feel is identical across providers regardless of380 // so typewriter feel is identical across providers regardless of
@@ -1061,16 +1064,9 @@ func (i *Interactive) redraw() {
1061 }1064 }
1062 }1065 }
1063 1066
1064- frame := make([]string, 0, len(visibleChat)+len(bottom))
1065- frame = append(frame, visibleChat...)
1066- frame = append(frame, bottom...)
1067-
1068 // Default: the real terminal cursor sits on the main editor's1067 // Default: the real terminal cursor sits on the main editor's
1069- // input position. When an overlay dialog has its own input1068+ // input position. In main-screen log mode cursor rows are relative
1070- // field (today just /btw), route the cursor there instead so1069+ // to the fixed bottom band, not the chat transcript.
1071- // the blinking cursor shows where the user is actually typing.
1072- // Dialogs without a cursor (model picker, /help, /login, etc.)
1073- // return -1 and the main editor keeps the cursor.
1074 // dialogLead is 1 when the bottom region prepends a blank above1070 // dialogLead is 1 when the bottom region prepends a blank above
1075 // the dialog block (whenever a dialog is showing) so popup-side1071 // the dialog block (whenever a dialog is showing) so popup-side
1076 // cursor positions still land in the right cell.1072 // cursor positions still land in the right cell.
@@ -1083,23 +1079,23 @@ func (i *Interactive) redraw() {
1083 // statusLines and edLines (input breathing room). Without1079 // statusLines and edLines (input breathing room). Without
1084 // these the rendered cursor would land on a blank instead of1080 // these the rendered cursor would land on a blank instead of
1085 // inside the editor row.1081 // inside the editor row.
1086- cursorRow := len(visibleChat) + dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR1082+ cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
1087 cursorCol := curC1083 cursorCol := curC
1088 if i.btwDialog.Active() {1084 if i.btwDialog.Active() {
1089 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {1085 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
1090- cursorRow = len(visibleChat) + dialogLead + r1086+ cursorRow = dialogLead + r
1091 cursorCol = c1087 cursorCol = c
1092 }1088 }
1093 }1089 }
1094 if i.dialog.Active() {1090 if i.dialog.Active() {
1095 if r, c := i.dialog.CursorPos(cols); r >= 0 {1091 if r, c := i.dialog.CursorPos(cols); r >= 0 {
1096- cursorRow = len(visibleChat) + dialogLead + r1092+ cursorRow = dialogLead + r
1097 cursorCol = c1093 cursorCol = c
1098 }1094 }
1099 }1095 }
1100 if i.sessionDialog.Active() {1096 if i.sessionDialog.Active() {
1101 if r, c := i.sessionDialog.CursorPos(); r >= 0 {1097 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1102- cursorRow = len(visibleChat) + dialogLead + r1098+ cursorRow = dialogLead + r
1103 cursorCol = c1099 cursorCol = c
1104 }1100 }
1105 }1101 }
@@ -1107,7 +1103,8 @@ func (i *Interactive) redraw() {
1107 cursorRow = -11103 cursorRow = -1
1108 cursorCol = 01104 cursorCol = 0
1109 }1105 }
1110- i.rend.Draw(frame, cursorRow, cursorCol)1106+ _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
1107+ i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
1111 }1108 }
1112 1109
1113 func hasImageEscape(line string) bool {1110 func hasImageEscape(line string) bool {
M internal/tui/render.go +175 -0
@@ -32,6 +32,14 @@ type Renderer struct {
3232 // the image set changes. Only matters when inline images are
3333 // enabled via ZOT_INLINE_IMAGES; defaults to false.
3434 prevHadImage bool
35+
36+ // Main-screen flow renderer state. logChat is the full chat buffer
37+ // already emitted into terminal scrollback. logBottom is the
38+ // editable/status block currently drawn after the chat.
39+ logChat []string
40+ logBottom []string
41+ logInit bool
42+ logCursorR int
3543 }
3644
3745 // NewRenderer returns a renderer that writes to out.
@@ -50,6 +58,10 @@ func (r *Renderer) Resize(cols, rows int) {
5058 r.cols = cols
5159 r.rows = rows
5260 r.prev = nil
61+ r.logChat = nil
62+ r.logBottom = nil
63+ r.logInit = false
64+ r.logCursorR = 0
5365 if r.out != nil {
5466 _, _ = io.WriteString(r.out, SeqClearScreen)
5567 }
@@ -59,6 +71,10 @@ func (r *Renderer) Resize(cols, rows int) {
5971 // Clear forces a full repaint on the next Draw and clears the screen.
6072 func (r *Renderer) Clear() {
6173 r.prev = nil
74+ r.logChat = nil
75+ r.logBottom = nil
76+ r.logInit = false
77+ r.logCursorR = 0
6278 _, _ = io.WriteString(r.out, SeqClearScreen)
6379 }
6480
@@ -245,3 +261,162 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
245261 r.cursorRow = cursorRow
246262 r.cursorCol = cursorCol
247263 }
264+
265+// DrawLog renders zot in the terminal's main screen as normal terminal
266+// flow rather than a fixed full-screen frame. Chat lines are emitted once
267+// into the host terminal scrollback; the current bottom block (dialogs,
268+// slash popup, status, editor) is erased and redrawn in place at the end.
269+//
270+// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
271+func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
272+ if r.cols == 0 || r.rows == 0 {
273+ return
274+ }
275+ if len(bottom) == 0 {
276+ bottom = []string{""}
277+ }
278+ chatFrame := make([]string, len(chat))
279+ for i, line := range chat {
280+ chatFrame[i] = truncateToWidth(line, r.cols)
281+ }
282+ bottomFrame := make([]string, len(bottom))
283+ for i, line := range bottom {
284+ bottomFrame[i] = truncateToWidth(line, r.cols)
285+ }
286+
287+ var w strings.Builder
288+ w.WriteString(SeqSynchronizedOn)
289+ w.WriteString(SeqHideCursor)
290+
291+ if !r.logInit {
292+ // First paint: start at top-left and print the transcript followed
293+ // immediately by the live input/status block. No bottom padding,
294+ // no footer layout.
295+ w.WriteString(SeqClearScreen)
296+ w.WriteString(MoveTo(1, 1))
297+ for _, line := range chatFrame {
298+ w.WriteString("\x1b[0m")
299+ w.WriteString(SeqClearLine)
300+ w.WriteString(line)
301+ w.WriteString("\r\n")
302+ }
303+ writeBlock(&w, bottomFrame)
304+ r.logInit = true
305+ } else {
306+ // Move from the currently exposed cursor back to the top of the
307+ // live bottom block, then erase the old block. We track the row
308+ // inside the bottom block where we left the cursor last Draw.
309+ w.WriteString("\r")
310+ if r.logCursorR > 0 {
311+ w.WriteString("\x1b[" + itoa(r.logCursorR) + "A")
312+ }
313+ eraseRows(&w, len(r.logBottom))
314+
315+ prefix := len(r.logChat) <= len(chatFrame)
316+ if prefix {
317+ for i := range r.logChat {
318+ if r.logChat[i] != chatFrame[i] {
319+ prefix = false
320+ break
321+ }
322+ }
323+ }
324+ if prefix {
325+ // Append only genuinely new chat rows. They become real terminal
326+ // scrollback, and inline image escapes are emitted once here — not
327+ // on every keystroke.
328+ for _, line := range chatFrame[len(r.logChat):] {
329+ w.WriteString("\x1b[0m")
330+ w.WriteString(SeqClearLine)
331+ w.WriteString(line)
332+ w.WriteString("\r\n")
333+ }
334+ } else {
335+ // Transcript changed in place (streaming text grows within the
336+ // last assistant block, markdown finalises/reflows, /clear or a
337+ // session load replaces content). Do not append a second full copy
338+ // to terminal scrollback. We are currently at the top of the old
339+ // bottom block, so the old chat block is immediately above us;
340+ // move up to its top, erase it in place, and write the replacement
341+ // chat before the live bottom block.
342+ if len(r.logChat) > 0 {
343+ w.WriteString("\x1b[" + itoa(len(r.logChat)) + "A")
344+ }
345+ eraseRows(&w, len(r.logChat))
346+ for _, line := range chatFrame {
347+ w.WriteString("\x1b[0m")
348+ w.WriteString(SeqClearLine)
349+ w.WriteString(line)
350+ w.WriteString("\r\n")
351+ }
352+ }
353+ writeBlock(&w, bottomFrame)
354+ }
355+
356+ // writeBlock leaves the cursor on the last bottom row. Move to the
357+ // requested cursor position inside that block.
358+ if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
359+ up := (len(bottomFrame) - 1) - cursorBottomRow
360+ if up > 0 {
361+ w.WriteString("\x1b[" + itoa(up) + "A")
362+ }
363+ w.WriteString("\r")
364+ if cursorCol > 0 {
365+ w.WriteString("\x1b[" + itoa(cursorCol) + "C")
366+ }
367+ w.WriteString(SeqShowCursor)
368+ r.logCursorR = cursorBottomRow
369+ } else {
370+ r.logCursorR = len(bottomFrame) - 1
371+ }
372+
373+ w.WriteString(SeqSynchronizedOff)
374+ _, _ = io.WriteString(r.out, w.String())
375+
376+ r.logChat = append(r.logChat[:0], chatFrame...)
377+ r.logBottom = append(r.logBottom[:0], bottomFrame...)
378+ r.cursorRow = cursorBottomRow
379+ r.cursorCol = cursorCol
380+}
381+
382+func writeBlock(w *strings.Builder, lines []string) {
383+ for i, line := range lines {
384+ w.WriteString("\x1b[0m")
385+ w.WriteString(SeqClearLine)
386+ w.WriteString(line)
387+ if i < len(lines)-1 {
388+ w.WriteString("\r\n")
389+ }
390+ }
391+}
392+
393+func eraseRows(w *strings.Builder, n int) {
394+ if n <= 0 {
395+ return
396+ }
397+ for i := 0; i < n; i++ {
398+ w.WriteString("\x1b[0m")
399+ w.WriteString(SeqClearLine)
400+ if i < n-1 {
401+ w.WriteString("\r\n")
402+ }
403+ }
404+ if n > 1 {
405+ w.WriteString("\x1b[" + itoa(n-1) + "A")
406+ }
407+ w.WriteString("\r")
408+}
409+
410+func tailTruncated(lines []string, maxRows, cols int) []string {
411+ if maxRows <= 0 {
412+ return nil
413+ }
414+ if len(lines) > maxRows {
415+ lines = lines[len(lines)-maxRows:]
416+ }
417+ out := make([]string, len(lines))
418+ for i, line := range lines {
419+ out[i] = truncateToWidth(line, cols)
420+ }
421+ return out
422+}
@@ -32,6 +32,14 @@ type Renderer struct {
32 // the image set changes. Only matters when inline images are32 // the image set changes. Only matters when inline images are
33 // enabled via ZOT_INLINE_IMAGES; defaults to false.33 // enabled via ZOT_INLINE_IMAGES; defaults to false.
34 prevHadImage bool34 prevHadImage bool
35+
36+ // Main-screen flow renderer state. logChat is the full chat buffer
37+ // already emitted into terminal scrollback. logBottom is the
38+ // editable/status block currently drawn after the chat.
39+ logChat []string
40+ logBottom []string
41+ logInit bool
42+ logCursorR int
35 }43 }
36 44
37 // NewRenderer returns a renderer that writes to out.45 // NewRenderer returns a renderer that writes to out.
@@ -50,6 +58,10 @@ func (r *Renderer) Resize(cols, rows int) {
50 r.cols = cols58 r.cols = cols
51 r.rows = rows59 r.rows = rows
52 r.prev = nil60 r.prev = nil
61+ r.logChat = nil
62+ r.logBottom = nil
63+ r.logInit = false
64+ r.logCursorR = 0
53 if r.out != nil {65 if r.out != nil {
54 _, _ = io.WriteString(r.out, SeqClearScreen)66 _, _ = io.WriteString(r.out, SeqClearScreen)
55 }67 }
@@ -59,6 +71,10 @@ func (r *Renderer) Resize(cols, rows int) {
59 // Clear forces a full repaint on the next Draw and clears the screen.71 // Clear forces a full repaint on the next Draw and clears the screen.
60 func (r *Renderer) Clear() {72 func (r *Renderer) Clear() {
61 r.prev = nil73 r.prev = nil
74+ r.logChat = nil
75+ r.logBottom = nil
76+ r.logInit = false
77+ r.logCursorR = 0
62 _, _ = io.WriteString(r.out, SeqClearScreen)78 _, _ = io.WriteString(r.out, SeqClearScreen)
63 }79 }
64 80
@@ -245,3 +261,162 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
245 r.cursorRow = cursorRow261 r.cursorRow = cursorRow
246 r.cursorCol = cursorCol262 r.cursorCol = cursorCol
247 }263 }
264+
265+// DrawLog renders zot in the terminal's main screen as normal terminal
266+// flow rather than a fixed full-screen frame. Chat lines are emitted once
267+// into the host terminal scrollback; the current bottom block (dialogs,
268+// slash popup, status, editor) is erased and redrawn in place at the end.
269+//
270+// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
271+func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
272+ if r.cols == 0 || r.rows == 0 {
273+ return
274+ }
275+ if len(bottom) == 0 {
276+ bottom = []string{""}
277+ }
278+ chatFrame := make([]string, len(chat))
279+ for i, line := range chat {
280+ chatFrame[i] = truncateToWidth(line, r.cols)
281+ }
282+ bottomFrame := make([]string, len(bottom))
283+ for i, line := range bottom {
284+ bottomFrame[i] = truncateToWidth(line, r.cols)
285+ }
286+
287+ var w strings.Builder
288+ w.WriteString(SeqSynchronizedOn)
289+ w.WriteString(SeqHideCursor)
290+
291+ if !r.logInit {
292+ // First paint: start at top-left and print the transcript followed
293+ // immediately by the live input/status block. No bottom padding,
294+ // no footer layout.
295+ w.WriteString(SeqClearScreen)
296+ w.WriteString(MoveTo(1, 1))
297+ for _, line := range chatFrame {
298+ w.WriteString("\x1b[0m")
299+ w.WriteString(SeqClearLine)
300+ w.WriteString(line)
301+ w.WriteString("\r\n")
302+ }
303+ writeBlock(&w, bottomFrame)
304+ r.logInit = true
305+ } else {
306+ // Move from the currently exposed cursor back to the top of the
307+ // live bottom block, then erase the old block. We track the row
308+ // inside the bottom block where we left the cursor last Draw.
309+ w.WriteString("\r")
310+ if r.logCursorR > 0 {
311+ w.WriteString("\x1b[" + itoa(r.logCursorR) + "A")
312+ }
313+ eraseRows(&w, len(r.logBottom))
314+
315+ prefix := len(r.logChat) <= len(chatFrame)
316+ if prefix {
317+ for i := range r.logChat {
318+ if r.logChat[i] != chatFrame[i] {
319+ prefix = false
320+ break
321+ }
322+ }
323+ }
324+ if prefix {
325+ // Append only genuinely new chat rows. They become real terminal
326+ // scrollback, and inline image escapes are emitted once here — not
327+ // on every keystroke.
328+ for _, line := range chatFrame[len(r.logChat):] {
329+ w.WriteString("\x1b[0m")
330+ w.WriteString(SeqClearLine)
331+ w.WriteString(line)
332+ w.WriteString("\r\n")
333+ }
334+ } else {
335+ // Transcript changed in place (streaming text grows within the
336+ // last assistant block, markdown finalises/reflows, /clear or a
337+ // session load replaces content). Do not append a second full copy
338+ // to terminal scrollback. We are currently at the top of the old
339+ // bottom block, so the old chat block is immediately above us;
340+ // move up to its top, erase it in place, and write the replacement
341+ // chat before the live bottom block.
342+ if len(r.logChat) > 0 {
343+ w.WriteString("\x1b[" + itoa(len(r.logChat)) + "A")
344+ }
345+ eraseRows(&w, len(r.logChat))
346+ for _, line := range chatFrame {
347+ w.WriteString("\x1b[0m")
348+ w.WriteString(SeqClearLine)
349+ w.WriteString(line)
350+ w.WriteString("\r\n")
351+ }
352+ }
353+ writeBlock(&w, bottomFrame)
354+ }
355+
356+ // writeBlock leaves the cursor on the last bottom row. Move to the
357+ // requested cursor position inside that block.
358+ if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
359+ up := (len(bottomFrame) - 1) - cursorBottomRow
360+ if up > 0 {
361+ w.WriteString("\x1b[" + itoa(up) + "A")
362+ }
363+ w.WriteString("\r")
364+ if cursorCol > 0 {
365+ w.WriteString("\x1b[" + itoa(cursorCol) + "C")
366+ }
367+ w.WriteString(SeqShowCursor)
368+ r.logCursorR = cursorBottomRow
369+ } else {
370+ r.logCursorR = len(bottomFrame) - 1
371+ }
372+
373+ w.WriteString(SeqSynchronizedOff)
374+ _, _ = io.WriteString(r.out, w.String())
375+
376+ r.logChat = append(r.logChat[:0], chatFrame...)
377+ r.logBottom = append(r.logBottom[:0], bottomFrame...)
378+ r.cursorRow = cursorBottomRow
379+ r.cursorCol = cursorCol
380+}
381+
382+func writeBlock(w *strings.Builder, lines []string) {
383+ for i, line := range lines {
384+ w.WriteString("\x1b[0m")
385+ w.WriteString(SeqClearLine)
386+ w.WriteString(line)
387+ if i < len(lines)-1 {
388+ w.WriteString("\r\n")
389+ }
390+ }
391+}
392+
393+func eraseRows(w *strings.Builder, n int) {
394+ if n <= 0 {
395+ return
396+ }
397+ for i := 0; i < n; i++ {
398+ w.WriteString("\x1b[0m")
399+ w.WriteString(SeqClearLine)
400+ if i < n-1 {
401+ w.WriteString("\r\n")
402+ }
403+ }
404+ if n > 1 {
405+ w.WriteString("\x1b[" + itoa(n-1) + "A")
406+ }
407+ w.WriteString("\r")
408+}
409+
410+func tailTruncated(lines []string, maxRows, cols int) []string {
411+ if maxRows <= 0 {
412+ return nil
413+ }
414+ if len(lines) > maxRows {
415+ lines = lines[len(lines)-maxRows:]
416+ }
417+ out := make([]string, len(lines))
418+ for i, line := range lines {
419+ out[i] = truncateToWidth(line, cols)
420+ }
421+ return out
422+}
M internal/tui/terminal.go +3 -0
@@ -86,7 +86,10 @@ const (
8686 SeqHideCursor = "\x1b[?25l"
8787 SeqShowCursor = "\x1b[?25h"
8888 SeqClearScreen = "\x1b[2J\x1b[H"
89+ SeqClearScrollback = "\x1b[3J"
8990 SeqClearLine = "\x1b[2K"
91+ SeqResetScrollRegion = "\x1b[r"
92+ SeqDeleteKittyImages = "\x1b_Ga=d\x1b\\"
9093 SeqBracketedPasteOn = "\x1b[?2004h"
9194 SeqBracketedPasteOff = "\x1b[?2004l"
9295 // Basic mouse tracking + SGR extended coordinates. Used only
@@ -86,7 +86,10 @@ const (
86 SeqHideCursor = "\x1b[?25l"86 SeqHideCursor = "\x1b[?25l"
87 SeqShowCursor = "\x1b[?25h"87 SeqShowCursor = "\x1b[?25h"
88 SeqClearScreen = "\x1b[2J\x1b[H"88 SeqClearScreen = "\x1b[2J\x1b[H"
89+ SeqClearScrollback = "\x1b[3J"
89 SeqClearLine = "\x1b[2K"90 SeqClearLine = "\x1b[2K"
91+ SeqResetScrollRegion = "\x1b[r"
92+ SeqDeleteKittyImages = "\x1b_Ga=d\x1b\\"
90 SeqBracketedPasteOn = "\x1b[?2004h"93 SeqBracketedPasteOn = "\x1b[?2004h"
91 SeqBracketedPasteOff = "\x1b[?2004l"94 SeqBracketedPasteOff = "\x1b[?2004l"
92 // Basic mouse tracking + SGR extended coordinates. Used only95 // Basic mouse tracking + SGR extended coordinates. Used only

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