detached c01e026

Stabilize chat rendering: remove forced full repaints, fix scroll/diff edge cases

commit
c01e026961fce5f2764136930fbc63d5d34d6f67
author
patriceckhart · · 2026-05-10 08:36 UTC
parent
c98e701
signature
unsigned
3 files · +66 -95
M internal/agent/modes/interactive.go +11 -49
@@ -727,21 +727,13 @@ func (i *Interactive) buildChatLocked(cols int) []string {
727727 }
728728 }
729729 i.view.Err = i.statusErr
730- i.liveBlock = i.view.BuildLive(cols)
731-
732- streamingActive := i.view.StreamingActive
733- streaming := i.view.Streaming
734- toolCalls := i.view.ToolCalls
735- errLine := i.view.Err
736- i.view.StreamingActive = false
737- i.view.Streaming = ""
738- i.view.ToolCalls = nil
739- i.view.Err = ""
730+ // Live streaming/tool rows are appended to the chat buffer (not
731+ // hoisted into a separate live block above the editor). That keeps
732+ // the renderer's diff view append-only: when a tool finishes the
733+ // rows update in place at the end of the buffer, instead of the
734+ // whole bottom band shrinking and shifting chat lines around.
735+ i.liveBlock = nil
740736 chat := i.view.Build(cols)
741- i.view.StreamingActive = streamingActive
742- i.view.Streaming = streaming
743- i.view.ToolCalls = toolCalls
744- i.view.Err = errLine
745737
746738 // Welcome banner: shown at the top of the chat area when there is
747739 // no transcript yet. Disappears after the first message is sent.
@@ -1015,15 +1007,7 @@ func (i *Interactive) redraw() {
10151007 // content. The status block and editor get their own dedicated
10161008 // blanks so spacing stays consistent whether or not a dialog or
10171009 // popup is showing.
1018- bottom := make([]string, 0, len(i.liveBlock)+len(dialog)+len(suggest)+len(queue)+len(edLines)+9)
1019- if len(i.liveBlock) > 0 {
1020- // Live streaming/tool rows are rendered outside the immutable
1021- // transcript so native scrollback stays stable while the agent
1022- // works. Add the same breathing row the finalized transcript path
1023- // gets between adjacent messages.
1024- bottom = append(bottom, "")
1025- bottom = append(bottom, i.liveBlock...)
1026- }
1010+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+9)
10271011 if len(dialog) > 0 {
10281012 bottom = append(bottom, "")
10291013 }
@@ -1151,29 +1135,23 @@ func (i *Interactive) redraw() {
11511135 // statusLines and edLines (input breathing room). Without
11521136 // these the rendered cursor would land on a blank instead of
11531137 // inside the editor row.
1154- liveRows := len(i.liveBlock)
1155- if liveRows > 0 {
1156- // Account for the leading separator row inserted before live
1157- // streaming/tool content in the bottom block.
1158- liveRows++
1159- }
1160- cursorRow := liveRows + dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
1138+ cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
11611139 cursorCol := curC
11621140 if i.btwDialog.Active() {
11631141 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
1164- cursorRow = liveRows + dialogLead + r
1142+ cursorRow = dialogLead + r
11651143 cursorCol = c
11661144 }
11671145 }
11681146 if i.dialog.Active() {
11691147 if r, c := i.dialog.CursorPos(cols); r >= 0 {
1170- cursorRow = liveRows + dialogLead + r
1148+ cursorRow = dialogLead + r
11711149 cursorCol = c
11721150 }
11731151 }
11741152 if i.sessionDialog.Active() {
11751153 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1176- cursorRow = liveRows + dialogLead + r
1154+ cursorRow = dialogLead + r
11771155 cursorCol = c
11781156 }
11791157 }
@@ -3437,10 +3415,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
34373415 return
34383416 }
34393417 i.resetStreamingStateLocked()
3440- // Force full repaint: assistant text committed, layout shifts.
3441- if i.rend != nil {
3442- i.rend.Invalidate()
3443- }
34443418 case core.EvToolUseStart:
34453419 // Live streaming: pre-create the view so the user sees the
34463420 // tool call being composed in real time. Any subsequent
@@ -3500,12 +3474,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
35003474 }
35013475 tc.Result = text.String()
35023476 }
3503- // Force full repaint: tool completion shifts the layout
3504- // (collapsed body replaces streaming) and the diff renderer
3505- // may leave stale code fragments on status bar rows.
3506- if i.rend != nil {
3507- i.rend.Invalidate()
3508- }
35093477 if i.cfg.OnToolResult != nil {
35103478 i.cfg.OnToolResult(e.ID, e.Result)
35113479 }
@@ -3515,12 +3483,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
35153483 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
35163484 }
35173485 case core.EvTurnEnd:
3518- // Force full repaint: the turn ending shifts layout (streaming
3519- // buffer flushed, status bar updates) and the diff renderer
3520- // may leave stale content on reused rows.
3521- if i.rend != nil {
3522- i.rend.Invalidate()
3523- }
35243486 if e.Stop == provider.StopAborted {
35253487 i.resetStreamingStateLocked()
35263488 i.statusErr = ""
@@ -727,21 +727,13 @@ func (i *Interactive) buildChatLocked(cols int) []string {
727 }727 }
728 }728 }
729 i.view.Err = i.statusErr729 i.view.Err = i.statusErr
730- i.liveBlock = i.view.BuildLive(cols)730+ // Live streaming/tool rows are appended to the chat buffer (not
731-731+ // hoisted into a separate live block above the editor). That keeps
732- streamingActive := i.view.StreamingActive732+ // the renderer's diff view append-only: when a tool finishes the
733- streaming := i.view.Streaming733+ // rows update in place at the end of the buffer, instead of the
734- toolCalls := i.view.ToolCalls734+ // whole bottom band shrinking and shifting chat lines around.
735- errLine := i.view.Err735+ i.liveBlock = nil
736- i.view.StreamingActive = false
737- i.view.Streaming = ""
738- i.view.ToolCalls = nil
739- i.view.Err = ""
740 chat := i.view.Build(cols)736 chat := i.view.Build(cols)
741- i.view.StreamingActive = streamingActive
742- i.view.Streaming = streaming
743- i.view.ToolCalls = toolCalls
744- i.view.Err = errLine
745 737
746 // Welcome banner: shown at the top of the chat area when there is738 // Welcome banner: shown at the top of the chat area when there is
747 // no transcript yet. Disappears after the first message is sent.739 // no transcript yet. Disappears after the first message is sent.
@@ -1015,15 +1007,7 @@ func (i *Interactive) redraw() {
1015 // content. The status block and editor get their own dedicated1007 // content. The status block and editor get their own dedicated
1016 // blanks so spacing stays consistent whether or not a dialog or1008 // blanks so spacing stays consistent whether or not a dialog or
1017 // popup is showing.1009 // popup is showing.
1018- bottom := make([]string, 0, len(i.liveBlock)+len(dialog)+len(suggest)+len(queue)+len(edLines)+9)1010+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+9)
1019- if len(i.liveBlock) > 0 {
1020- // Live streaming/tool rows are rendered outside the immutable
1021- // transcript so native scrollback stays stable while the agent
1022- // works. Add the same breathing row the finalized transcript path
1023- // gets between adjacent messages.
1024- bottom = append(bottom, "")
1025- bottom = append(bottom, i.liveBlock...)
1026- }
1027 if len(dialog) > 0 {1011 if len(dialog) > 0 {
1028 bottom = append(bottom, "")1012 bottom = append(bottom, "")
1029 }1013 }
@@ -1151,29 +1135,23 @@ func (i *Interactive) redraw() {
1151 // statusLines and edLines (input breathing room). Without1135 // statusLines and edLines (input breathing room). Without
1152 // these the rendered cursor would land on a blank instead of1136 // these the rendered cursor would land on a blank instead of
1153 // inside the editor row.1137 // inside the editor row.
1154- liveRows := len(i.liveBlock)1138+ cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
1155- if liveRows > 0 {
1156- // Account for the leading separator row inserted before live
1157- // streaming/tool content in the bottom block.
1158- liveRows++
1159- }
1160- cursorRow := liveRows + dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
1161 cursorCol := curC1139 cursorCol := curC
1162 if i.btwDialog.Active() {1140 if i.btwDialog.Active() {
1163 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {1141 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
1164- cursorRow = liveRows + dialogLead + r1142+ cursorRow = dialogLead + r
1165 cursorCol = c1143 cursorCol = c
1166 }1144 }
1167 }1145 }
1168 if i.dialog.Active() {1146 if i.dialog.Active() {
1169 if r, c := i.dialog.CursorPos(cols); r >= 0 {1147 if r, c := i.dialog.CursorPos(cols); r >= 0 {
1170- cursorRow = liveRows + dialogLead + r1148+ cursorRow = dialogLead + r
1171 cursorCol = c1149 cursorCol = c
1172 }1150 }
1173 }1151 }
1174 if i.sessionDialog.Active() {1152 if i.sessionDialog.Active() {
1175 if r, c := i.sessionDialog.CursorPos(); r >= 0 {1153 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1176- cursorRow = liveRows + dialogLead + r1154+ cursorRow = dialogLead + r
1177 cursorCol = c1155 cursorCol = c
1178 }1156 }
1179 }1157 }
@@ -3437,10 +3415,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
3437 return3415 return
3438 }3416 }
3439 i.resetStreamingStateLocked()3417 i.resetStreamingStateLocked()
3440- // Force full repaint: assistant text committed, layout shifts.
3441- if i.rend != nil {
3442- i.rend.Invalidate()
3443- }
3444 case core.EvToolUseStart:3418 case core.EvToolUseStart:
3445 // Live streaming: pre-create the view so the user sees the3419 // Live streaming: pre-create the view so the user sees the
3446 // tool call being composed in real time. Any subsequent3420 // tool call being composed in real time. Any subsequent
@@ -3500,12 +3474,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
3500 }3474 }
3501 tc.Result = text.String()3475 tc.Result = text.String()
3502 }3476 }
3503- // Force full repaint: tool completion shifts the layout
3504- // (collapsed body replaces streaming) and the diff renderer
3505- // may leave stale code fragments on status bar rows.
3506- if i.rend != nil {
3507- i.rend.Invalidate()
3508- }
3509 if i.cfg.OnToolResult != nil {3477 if i.cfg.OnToolResult != nil {
3510 i.cfg.OnToolResult(e.ID, e.Result)3478 i.cfg.OnToolResult(e.ID, e.Result)
3511 }3479 }
@@ -3515,12 +3483,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
3515 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens3483 i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
3516 }3484 }
3517 case core.EvTurnEnd:3485 case core.EvTurnEnd:
3518- // Force full repaint: the turn ending shifts layout (streaming
3519- // buffer flushed, status bar updates) and the diff renderer
3520- // may leave stale content on reused rows.
3521- if i.rend != nil {
3522- i.rend.Invalidate()
3523- }
3524 if e.Stop == provider.StopAborted {3486 if e.Stop == provider.StopAborted {
3525 i.resetStreamingStateLocked()3487 i.resetStreamingStateLocked()
3526 i.statusErr = ""3488 i.statusErr = ""
M internal/tui/render.go +47 -46
@@ -58,6 +58,12 @@ func NewRenderer(out io.Writer) *Renderer {
5858 // starts from a blank slate. Without the clear, characters from the
5959 // old (wider) layout linger past the new right edge and rows from
6060 // before the new bottom hang around as garbage.
61+func (r *Renderer) ResetScrollRegion() {
62+ if r.out != nil {
63+ _, _ = io.WriteString(r.out, SeqResetScrollRegion)
64+ }
65+}
66+
6167 func (r *Renderer) Resize(cols, rows int) {
6268 if cols != r.cols || rows != r.rows {
6369 r.cols = cols
@@ -398,36 +404,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
398404 w.WriteString(SeqShowCursor)
399405 }
400406
401- // Selection-highlight workaround: VS Code's terminal (and some
402- // others) don't reliably clear background colors when a row is
403- // overwritten. When the current OR previous bottom block contains
404- // selection-highlight escapes (dialogs with a cursor row), wipe
405- // the cached bottom rows so the diff rewrites every one of them.
406- // This mirrors the hasSelection guard in Draw().
407- if r.logInit && len(r.logLines) > 0 {
408- botHasHL := func(block []string) bool {
409- for _, l := range block {
410- if strings.Contains(l, "\x1b[48;5;") {
411- return true
412- }
413- }
414- return false
415- }
416- if botHasHL(bottomFrame) || botHasHL(r.logBottom) {
417- // Invalidate only the bottom portion of the cached lines
418- // so the chat region (terminal scrollback) is still
419- // diffed cheaply. We do this by replacing the old bottom
420- // rows with empty strings so every bottom row looks
421- // "changed" to the diff below.
422- botStart := len(r.logLines) - len(r.logBottom) - bottomMarginRows
423- if botStart < 0 {
424- botStart = 0
425- }
426- for idx := botStart; idx < len(r.logLines); idx++ {
427- r.logLines[idx] = "\x00" // sentinel that won't match any real line
428- }
429- }
430- }
407+ // Selection-highlight workaround removed: it could mis-invalidate
408+ // user-bubble padding rows whose colored bg made botHasHL trip,
409+ // causing the next diff pass to leave those rows visually thinned
410+ // because the cached entry was the \x00 sentinel rather than the
411+ // real previous bg-colored row.
431412
432413 full := !r.logInit || len(r.logLines) == 0
433414 if full {
@@ -456,14 +437,26 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
456437 lastChanged = idx
457438 }
458439 }
440+ // Buffer grew but the appended rows were empty (or otherwise
441+ // equal to the implicit "" past the old end). The diff above
442+ // won't flag those rows, yet the renderer still needs to
443+ // advance its hardware cursor / viewport tracking past them so
444+ // the next render starts from the correct position. Treat the
445+ // extension as changed.
446+ if len(lines) > len(r.logLines) {
447+ if firstChanged == -1 {
448+ firstChanged = len(r.logLines)
449+ }
450+ if lastChanged < len(lines)-1 {
451+ lastChanged = len(lines) - 1
452+ }
453+ }
459454
460455 if firstChanged == -1 {
461456 // No content changes; the final cursor positioning below may still
462457 // move the hardware cursor if the editor cursor changed.
463- } else if len(lines) < len(r.logLines) || firstChanged < r.logViewportTop {
464- // Shrinks and changes above the visible viewport cannot be safely
465- // patched in terminal scrollback. Replay the current logical buffer
466- // instead of risking stale duplicated rows.
458+ } else if firstChanged < r.logViewportTop {
459+ // Changes above the visible viewport cannot be patched safely.
467460 writeFull(true)
468461 } else {
469462 prevViewportTop := r.logViewportTop
@@ -523,7 +516,26 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
523516 w.WriteString(SeqClearLine)
524517 w.WriteString(lines[idx])
525518 }
526- r.logHardwareRow = renderEnd
519+ finalRow := renderEnd
520+ if len(r.logLines) > len(lines) {
521+ extra := len(r.logLines) - len(lines)
522+ if extra > r.rows {
523+ writeFull(true)
524+ } else {
525+ for e := 0; e < extra; e++ {
526+ w.WriteString("\x1b[1B")
527+ w.WriteString("\r")
528+ w.WriteString("\x1b[0m")
529+ w.WriteString(SeqClearLine)
530+ finalRow++
531+ }
532+ if extra > 0 {
533+ w.WriteString("\x1b[" + itoa(extra) + "A")
534+ finalRow -= extra
535+ }
536+ }
537+ }
538+ r.logHardwareRow = finalRow
527539 r.logViewportTop = viewportTop
528540 if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
529541 r.logViewportTop = minTop
@@ -534,17 +546,6 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
534546 }
535547 }
536548
537- // Re-anchor the terminal viewport at the true end of the logical
538- // buffer on every frame. During busy turns most redraws only mutate
539- // spinner/live rows above the editor; without touching the final margin
540- // row, the terminal can visually lose the bottom padding even though it
541- // still exists in r.logLines.
542- if len(lines) > 0 {
543- moveToLogicalRow(len(lines) - 1)
544- w.WriteString("\r")
545- w.WriteString("\x1b[0m")
546- w.WriteString(SeqClearLine)
547- }
548549 positionCursor()
549550 w.WriteString(SeqSynchronizedOff)
550551 _, _ = io.WriteString(r.out, w.String())
@@ -58,6 +58,12 @@ func NewRenderer(out io.Writer) *Renderer {
58 // starts from a blank slate. Without the clear, characters from the58 // starts from a blank slate. Without the clear, characters from the
59 // old (wider) layout linger past the new right edge and rows from59 // old (wider) layout linger past the new right edge and rows from
60 // before the new bottom hang around as garbage.60 // before the new bottom hang around as garbage.
61+func (r *Renderer) ResetScrollRegion() {
62+ if r.out != nil {
63+ _, _ = io.WriteString(r.out, SeqResetScrollRegion)
64+ }
65+}
66+
61 func (r *Renderer) Resize(cols, rows int) {67 func (r *Renderer) Resize(cols, rows int) {
62 if cols != r.cols || rows != r.rows {68 if cols != r.cols || rows != r.rows {
63 r.cols = cols69 r.cols = cols
@@ -398,36 +404,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
398 w.WriteString(SeqShowCursor)404 w.WriteString(SeqShowCursor)
399 }405 }
400 406
401- // Selection-highlight workaround: VS Code's terminal (and some407+ // Selection-highlight workaround removed: it could mis-invalidate
402- // others) don't reliably clear background colors when a row is408+ // user-bubble padding rows whose colored bg made botHasHL trip,
403- // overwritten. When the current OR previous bottom block contains409+ // causing the next diff pass to leave those rows visually thinned
404- // selection-highlight escapes (dialogs with a cursor row), wipe410+ // because the cached entry was the \x00 sentinel rather than the
405- // the cached bottom rows so the diff rewrites every one of them.411+ // real previous bg-colored row.
406- // This mirrors the hasSelection guard in Draw().
407- if r.logInit && len(r.logLines) > 0 {
408- botHasHL := func(block []string) bool {
409- for _, l := range block {
410- if strings.Contains(l, "\x1b[48;5;") {
411- return true
412- }
413- }
414- return false
415- }
416- if botHasHL(bottomFrame) || botHasHL(r.logBottom) {
417- // Invalidate only the bottom portion of the cached lines
418- // so the chat region (terminal scrollback) is still
419- // diffed cheaply. We do this by replacing the old bottom
420- // rows with empty strings so every bottom row looks
421- // "changed" to the diff below.
422- botStart := len(r.logLines) - len(r.logBottom) - bottomMarginRows
423- if botStart < 0 {
424- botStart = 0
425- }
426- for idx := botStart; idx < len(r.logLines); idx++ {
427- r.logLines[idx] = "\x00" // sentinel that won't match any real line
428- }
429- }
430- }
431 412
432 full := !r.logInit || len(r.logLines) == 0413 full := !r.logInit || len(r.logLines) == 0
433 if full {414 if full {
@@ -456,14 +437,26 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
456 lastChanged = idx437 lastChanged = idx
457 }438 }
458 }439 }
440+ // Buffer grew but the appended rows were empty (or otherwise
441+ // equal to the implicit "" past the old end). The diff above
442+ // won't flag those rows, yet the renderer still needs to
443+ // advance its hardware cursor / viewport tracking past them so
444+ // the next render starts from the correct position. Treat the
445+ // extension as changed.
446+ if len(lines) > len(r.logLines) {
447+ if firstChanged == -1 {
448+ firstChanged = len(r.logLines)
449+ }
450+ if lastChanged < len(lines)-1 {
451+ lastChanged = len(lines) - 1
452+ }
453+ }
459 454
460 if firstChanged == -1 {455 if firstChanged == -1 {
461 // No content changes; the final cursor positioning below may still456 // No content changes; the final cursor positioning below may still
462 // move the hardware cursor if the editor cursor changed.457 // move the hardware cursor if the editor cursor changed.
463- } else if len(lines) < len(r.logLines) || firstChanged < r.logViewportTop {458+ } else if firstChanged < r.logViewportTop {
464- // Shrinks and changes above the visible viewport cannot be safely459+ // Changes above the visible viewport cannot be patched safely.
465- // patched in terminal scrollback. Replay the current logical buffer
466- // instead of risking stale duplicated rows.
467 writeFull(true)460 writeFull(true)
468 } else {461 } else {
469 prevViewportTop := r.logViewportTop462 prevViewportTop := r.logViewportTop
@@ -523,7 +516,26 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
523 w.WriteString(SeqClearLine)516 w.WriteString(SeqClearLine)
524 w.WriteString(lines[idx])517 w.WriteString(lines[idx])
525 }518 }
526- r.logHardwareRow = renderEnd519+ finalRow := renderEnd
520+ if len(r.logLines) > len(lines) {
521+ extra := len(r.logLines) - len(lines)
522+ if extra > r.rows {
523+ writeFull(true)
524+ } else {
525+ for e := 0; e < extra; e++ {
526+ w.WriteString("\x1b[1B")
527+ w.WriteString("\r")
528+ w.WriteString("\x1b[0m")
529+ w.WriteString(SeqClearLine)
530+ finalRow++
531+ }
532+ if extra > 0 {
533+ w.WriteString("\x1b[" + itoa(extra) + "A")
534+ finalRow -= extra
535+ }
536+ }
537+ }
538+ r.logHardwareRow = finalRow
527 r.logViewportTop = viewportTop539 r.logViewportTop = viewportTop
528 if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {540 if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
529 r.logViewportTop = minTop541 r.logViewportTop = minTop
@@ -534,17 +546,6 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
534 }546 }
535 }547 }
536 548
537- // Re-anchor the terminal viewport at the true end of the logical
538- // buffer on every frame. During busy turns most redraws only mutate
539- // spinner/live rows above the editor; without touching the final margin
540- // row, the terminal can visually lose the bottom padding even though it
541- // still exists in r.logLines.
542- if len(lines) > 0 {
543- moveToLogicalRow(len(lines) - 1)
544- w.WriteString("\r")
545- w.WriteString("\x1b[0m")
546- w.WriteString(SeqClearLine)
547- }
548 positionCursor()549 positionCursor()
549 w.WriteString(SeqSynchronizedOff)550 w.WriteString(SeqSynchronizedOff)
550 _, _ = io.WriteString(r.out, w.String())551 _, _ = io.WriteString(r.out, w.String())
M internal/tui/terminal.go +8 -0
@@ -111,6 +111,14 @@ const (
111111 SeqEraseToEnd = "\x1b[J"
112112 )
113113
114+// SetScrollRegion returns the DECSTBM sequence to set the terminal's
115+// scroll region (1-indexed, inclusive). Lines that scroll out of this
116+// region still flow into the terminal's scrollback, but content above
117+// or below the region is not affected by scroll-induced movement.
118+func SetScrollRegion(top, bottom int) string {
119+ return "\x1b[" + itoa(top) + ";" + itoa(bottom) + "r"
120+}
121+
114122 // MoveTo moves the cursor to 1-indexed (row, col).
115123 func MoveTo(row, col int) string {
116124 return "\x1b[" + itoa(row) + ";" + itoa(col) + "H"
@@ -111,6 +111,14 @@ const (
111 SeqEraseToEnd = "\x1b[J"111 SeqEraseToEnd = "\x1b[J"
112 )112 )
113 113
114+// SetScrollRegion returns the DECSTBM sequence to set the terminal's
115+// scroll region (1-indexed, inclusive). Lines that scroll out of this
116+// region still flow into the terminal's scrollback, but content above
117+// or below the region is not affected by scroll-induced movement.
118+func SetScrollRegion(top, bottom int) string {
119+ return "\x1b[" + itoa(top) + ";" + itoa(bottom) + "r"
120+}
121+
114 // MoveTo moves the cursor to 1-indexed (row, col).122 // MoveTo moves the cursor to 1-indexed (row, col).
115 func MoveTo(row, col int) string {123 func MoveTo(row, col int) string {
116 return "\x1b[" + itoa(row) + ";" + itoa(col) + "H"124 return "\x1b[" + itoa(row) + ";" + itoa(col) + "H"

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 477

↑↓ moveenter openesc close