detached 6c5c4f2

tui: make main-screen renderer viewport-safe

commit
6c5c4f213a5087fee06ec562e793042989817bf5
author
patriceckhart · · 2026-05-05 15:33 UTC
parent
ea10cba
signature
unsigned
refs
tag: v0.1.56
2 files · +208 -77
M internal/agent/modes/interactive.go +0 -1
@@ -1007,7 +1007,6 @@ func (i *Interactive) redraw() {
10071007 bottom = append(bottom, statusLines...)
10081008 bottom = append(bottom, "")
10091009 bottom = append(bottom, edLines...)
1010- bottom = append(bottom, "")
10111010
10121011 _, rows := i.cfg.Terminal.Size()
10131012 chatRows := rows - len(bottom)
@@ -1007,7 +1007,6 @@ func (i *Interactive) redraw() {
1007 bottom = append(bottom, statusLines...)1007 bottom = append(bottom, statusLines...)
1008 bottom = append(bottom, "")1008 bottom = append(bottom, "")
1009 bottom = append(bottom, edLines...)1009 bottom = append(bottom, edLines...)
1010- bottom = append(bottom, "")
1011 1010
1012 _, rows := i.cfg.Terminal.Size()1011 _, rows := i.cfg.Terminal.Size()
1013 chatRows := rows - len(bottom)1012 chatRows := rows - len(bottom)
M internal/tui/render.go +208 -76
@@ -33,12 +33,18 @@ type Renderer struct {
3333 // enabled via ZOT_INLINE_IMAGES; defaults to false.
3434 prevHadImage bool
3535
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
36+ // Main-screen flow renderer state. logLines is the full logical
37+ // buffer (chat + live bottom band) from the previous DrawLog call.
38+ // logViewportTop/logHardwareRow track where that logical buffer sits
39+ // in the terminal's visible viewport so we can diff safely, and bail
40+ // out to clear+replay when the diff would touch rows that are no
41+ // longer addressable.
42+ logChat []string
43+ logBottom []string
44+ logLines []string
45+ logViewportTop int
46+ logHardwareRow int
47+ logInit bool
4248 }
4349
4450 // NewRenderer returns a renderer that writes to out.
@@ -59,6 +65,9 @@ func (r *Renderer) Resize(cols, rows int) {
5965 r.prev = nil
6066 r.logChat = nil
6167 r.logBottom = nil
68+ r.logLines = nil
69+ r.logViewportTop = 0
70+ r.logHardwareRow = 0
6271 r.logInit = false
6372 if r.out != nil {
6473 _, _ = io.WriteString(r.out, SeqClearScreen)
@@ -75,6 +84,9 @@ func (r *Renderer) Clear() {
7584 r.prev = nil
7685 r.logChat = nil
7786 r.logBottom = nil
87+ r.logLines = nil
88+ r.logViewportTop = 0
89+ r.logHardwareRow = 0
7890 r.logInit = false
7991 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
8092 }
@@ -84,6 +96,7 @@ func (r *Renderer) Clear() {
8496 // visible full-screen flash would be too distracting.
8597 func (r *Renderer) Invalidate() {
8698 r.prev = nil
99+ r.logLines = nil
87100 }
88101
89102 // Draw updates the terminal so that the visible frame ends with the
@@ -285,107 +298,226 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
285298 bottomFrame[i] = truncateToWidth(line, r.cols)
286299 }
287300
301+ // Always reserve one real row below the editor/status band. This is
302+ // renderer-owned (not a best-effort trailing blank in the caller's
303+ // bottom block), so the logical-buffer diff keeps it visible and cursor
304+ // placement remains relative to the editor itself.
305+ const bottomMarginRows = 1
306+ lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows)
307+ lines = append(lines, chatFrame...)
308+ lines = append(lines, bottomFrame...)
309+ for range bottomMarginRows {
310+ lines = append(lines, "")
311+ }
312+ if len(lines) == 0 {
313+ lines = []string{""}
314+ }
315+
316+ cursorTargetRow := -1
317+ if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
318+ cursorTargetRow = len(chatFrame) + cursorBottomRow
319+ }
320+
288321 var w strings.Builder
289322 w.WriteString(SeqSynchronizedOn)
290323 w.WriteString(SeqHideCursor)
291324
292- if !r.logInit {
293- // First paint: start at top-left and print the transcript followed
294- // immediately by the live input/status block. No bottom padding,
295- // no footer layout.
296- w.WriteString(SeqClearScreen)
297- w.WriteString(SeqClearScrollback)
298- w.WriteString(MoveTo(1, 1))
299- for _, line := range chatFrame {
325+ writeFull := func(clear bool) {
326+ if clear {
327+ w.WriteString(SeqDeleteKittyImages)
328+ w.WriteString(SeqClearScreen)
329+ w.WriteString(SeqClearScrollback)
330+ w.WriteString(MoveTo(1, 1))
331+ }
332+ for idx, line := range lines {
333+ if idx > 0 {
334+ w.WriteString("\r\n")
335+ }
300336 w.WriteString("\x1b[0m")
301337 w.WriteString(SeqClearLine)
302338 w.WriteString(line)
303- w.WriteString("\r\n")
304339 }
305- writeBlock(&w, bottomFrame)
306- r.logInit = true
307- } else {
308- // Walk back up to the top of the previous bottom block. The cursor
309- // was last positioned somewhere inside the bottom band by the
310- // previous Draw (final ShowCursor below); we don't trust the
311- // terminal's saved cursor across frames because terminal-driven
312- // scrolling would invalidate it. Instead, rebuild the relative
313- // position from r.cursorRow inside the previous bottomFrame.
314- prevBottomRows := len(r.logBottom)
315- prevCursorRow := r.cursorRow
316- if prevCursorRow < 0 || prevCursorRow >= prevBottomRows {
317- prevCursorRow = prevBottomRows - 1
318- if prevCursorRow < 0 {
319- prevCursorRow = 0
340+ r.logHardwareRow = len(lines) - 1
341+ r.logViewportTop = len(lines) - r.rows
342+ if r.logViewportTop < 0 {
343+ r.logViewportTop = 0
344+ }
345+ }
346+
347+ moveToLogicalRow := func(targetRow int) {
348+ if targetRow < 0 {
349+ targetRow = 0
350+ }
351+ if targetRow >= len(lines) {
352+ targetRow = len(lines) - 1
353+ }
354+ viewportBottom := r.logViewportTop + r.rows - 1
355+ if targetRow > viewportBottom {
356+ currentScreenRow := r.logHardwareRow - r.logViewportTop
357+ if currentScreenRow < 0 {
358+ currentScreenRow = 0
359+ }
360+ if currentScreenRow >= r.rows {
361+ currentScreenRow = r.rows - 1
362+ }
363+ moveToBottom := r.rows - 1 - currentScreenRow
364+ if moveToBottom > 0 {
365+ w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
320366 }
367+ scroll := targetRow - viewportBottom
368+ for s := 0; s < scroll; s++ {
369+ w.WriteString("\r\n")
370+ }
371+ r.logViewportTop += scroll
372+ r.logHardwareRow = targetRow
373+ return
321374 }
322- up := prevCursorRow
323- if prevBottomRows > 0 && up > 0 {
324- w.WriteString("\x1b[" + itoa(up) + "A")
375+ currentScreenRow := r.logHardwareRow - r.logViewportTop
376+ targetScreenRow := targetRow - r.logViewportTop
377+ lineDiff := targetScreenRow - currentScreenRow
378+ if lineDiff > 0 {
379+ w.WriteString("\x1b[" + itoa(lineDiff) + "B")
380+ } else if lineDiff < 0 {
381+ w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
382+ }
383+ r.logHardwareRow = targetRow
384+ }
385+
386+ positionCursor := func() {
387+ if cursorTargetRow < 0 || cursorTargetRow >= len(lines) {
388+ return
325389 }
390+ moveToLogicalRow(cursorTargetRow)
326391 w.WriteString("\r")
392+ if cursorCol > 0 {
393+ w.WriteString("\x1b[" + itoa(cursorCol) + "C")
394+ }
395+ w.WriteString(SeqShowCursor)
396+ }
327397
328- prefix := len(r.logChat) <= len(chatFrame)
329- if prefix {
330- for i := range r.logChat {
331- if r.logChat[i] != chatFrame[i] {
332- prefix = false
333- break
398+ full := !r.logInit || len(r.logLines) == 0
399+ if full {
400+ writeFull(true)
401+ r.logInit = true
402+ } else {
403+ firstChanged := -1
404+ lastChanged := -1
405+ maxLines := len(lines)
406+ if len(r.logLines) > maxLines {
407+ maxLines = len(r.logLines)
408+ }
409+ for idx := 0; idx < maxLines; idx++ {
410+ oldLine := ""
411+ if idx < len(r.logLines) {
412+ oldLine = r.logLines[idx]
413+ }
414+ newLine := ""
415+ if idx < len(lines) {
416+ newLine = lines[idx]
417+ }
418+ if oldLine != newLine {
419+ if firstChanged == -1 {
420+ firstChanged = idx
334421 }
422+ lastChanged = idx
335423 }
336424 }
337- if prefix {
338- // Erase old bottom band entirely, then append only genuinely new
339- // chat rows above the new bottom band. New chat rows become real
340- // terminal scrollback; inline image escapes are emitted once here.
341- w.WriteString(SeqEraseToEnd)
342- for _, line := range chatFrame[len(r.logChat):] {
343- w.WriteString("\x1b[0m")
344- w.WriteString(SeqClearLine)
345- w.WriteString(line)
425+
426+ if firstChanged == -1 {
427+ // No content changes; the final cursor positioning below may still
428+ // move the hardware cursor if the editor cursor changed.
429+ } else if len(lines) < len(r.logLines) || firstChanged < r.logViewportTop {
430+ // Shrinks and changes above the visible viewport cannot be safely
431+ // patched in terminal scrollback. Replay the current logical buffer
432+ // instead of risking stale duplicated rows.
433+ writeFull(true)
434+ } else {
435+ prevViewportTop := r.logViewportTop
436+ viewportTop := prevViewportTop
437+ hardwareRow := r.logHardwareRow
438+ prevViewportBottom := prevViewportTop + r.rows - 1
439+ appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0
440+ moveTarget := firstChanged
441+ if appendStart {
442+ moveTarget = firstChanged - 1
443+ }
444+
445+ if moveTarget > prevViewportBottom {
446+ currentScreenRow := hardwareRow - prevViewportTop
447+ if currentScreenRow < 0 {
448+ currentScreenRow = 0
449+ }
450+ if currentScreenRow >= r.rows {
451+ currentScreenRow = r.rows - 1
452+ }
453+ moveToBottom := r.rows - 1 - currentScreenRow
454+ if moveToBottom > 0 {
455+ w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
456+ }
457+ scroll := moveTarget - prevViewportBottom
458+ for s := 0; s < scroll; s++ {
459+ w.WriteString("\r\n")
460+ }
461+ prevViewportTop += scroll
462+ viewportTop += scroll
463+ hardwareRow = moveTarget
464+ }
465+
466+ currentScreenRow := hardwareRow - prevViewportTop
467+ targetScreenRow := moveTarget - viewportTop
468+ lineDiff := targetScreenRow - currentScreenRow
469+ if lineDiff > 0 {
470+ w.WriteString("\x1b[" + itoa(lineDiff) + "B")
471+ } else if lineDiff < 0 {
472+ w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
473+ }
474+ if appendStart {
346475 w.WriteString("\r\n")
476+ } else {
477+ w.WriteString("\r")
347478 }
348- } else {
349- // Transcript changed in place (streaming text grows within the
350- // last assistant block, markdown finalises/reflows, ctrl+o
351- // expand/collapse, /clear, session load). In main-screen flow
352- // mode old chat lives in terminal scrollback; trying to move up
353- // and rewrite it is unreliable, especially once images or native
354- // scrolling are involved. Use the safe path: clear visible screen
355- // + scrollback, then replay the current transcript once.
356- w.WriteString(SeqDeleteKittyImages)
357- w.WriteString(SeqClearScreen)
358- w.WriteString(SeqClearScrollback)
359- w.WriteString(MoveTo(1, 1))
360- for _, line := range chatFrame {
479+
480+ renderEnd := lastChanged
481+ if renderEnd >= len(lines) {
482+ renderEnd = len(lines) - 1
483+ }
484+ for idx := firstChanged; idx <= renderEnd; idx++ {
485+ if idx > firstChanged {
486+ w.WriteString("\r\n")
487+ }
361488 w.WriteString("\x1b[0m")
362489 w.WriteString(SeqClearLine)
363- w.WriteString(line)
364- w.WriteString("\r\n")
490+ w.WriteString(lines[idx])
491+ }
492+ r.logHardwareRow = renderEnd
493+ r.logViewportTop = viewportTop
494+ if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
495+ r.logViewportTop = minTop
496+ }
497+ if r.logViewportTop < 0 {
498+ r.logViewportTop = 0
365499 }
366500 }
367- writeBlock(&w, bottomFrame)
368501 }
369502
370- // writeBlock leaves the cursor on the last bottom row. Move to the
371- // requested cursor position inside that block.
372- if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
373- up := (len(bottomFrame) - 1) - cursorBottomRow
374- if up > 0 {
375- w.WriteString("\x1b[" + itoa(up) + "A")
376- }
503+ // Re-anchor the terminal viewport at the true end of the logical
504+ // buffer on every frame. During busy turns most redraws only mutate
505+ // spinner/live rows above the editor; without touching the final margin
506+ // row, the terminal can visually lose the bottom padding even though it
507+ // still exists in r.logLines.
508+ if len(lines) > 0 {
509+ moveToLogicalRow(len(lines) - 1)
377510 w.WriteString("\r")
378- if cursorCol > 0 {
379- w.WriteString("\x1b[" + itoa(cursorCol) + "C")
380- }
381- w.WriteString(SeqShowCursor)
511+ w.WriteString("\x1b[0m")
512+ w.WriteString(SeqClearLine)
382513 }
383-
514+ positionCursor()
384515 w.WriteString(SeqSynchronizedOff)
385516 _, _ = io.WriteString(r.out, w.String())
386517
387518 r.logChat = append(r.logChat[:0], chatFrame...)
388519 r.logBottom = append(r.logBottom[:0], bottomFrame...)
520+ r.logLines = append(r.logLines[:0], lines...)
389521 r.cursorRow = cursorBottomRow
390522 r.cursorCol = cursorCol
391523 }
@@ -33,12 +33,18 @@ type Renderer struct {
33 // enabled via ZOT_INLINE_IMAGES; defaults to false.33 // enabled via ZOT_INLINE_IMAGES; defaults to false.
34 prevHadImage bool34 prevHadImage bool
35 35
36- // Main-screen flow renderer state. logChat is the full chat buffer36+ // Main-screen flow renderer state. logLines is the full logical
37- // already emitted into terminal scrollback. logBottom is the37+ // buffer (chat + live bottom band) from the previous DrawLog call.
38- // editable/status block currently drawn after the chat.38+ // logViewportTop/logHardwareRow track where that logical buffer sits
39- logChat []string39+ // in the terminal's visible viewport so we can diff safely, and bail
40- logBottom []string40+ // out to clear+replay when the diff would touch rows that are no
41- logInit bool41+ // longer addressable.
42+ logChat []string
43+ logBottom []string
44+ logLines []string
45+ logViewportTop int
46+ logHardwareRow int
47+ logInit bool
42 }48 }
43 49
44 // NewRenderer returns a renderer that writes to out.50 // NewRenderer returns a renderer that writes to out.
@@ -59,6 +65,9 @@ func (r *Renderer) Resize(cols, rows int) {
59 r.prev = nil65 r.prev = nil
60 r.logChat = nil66 r.logChat = nil
61 r.logBottom = nil67 r.logBottom = nil
68+ r.logLines = nil
69+ r.logViewportTop = 0
70+ r.logHardwareRow = 0
62 r.logInit = false71 r.logInit = false
63 if r.out != nil {72 if r.out != nil {
64 _, _ = io.WriteString(r.out, SeqClearScreen)73 _, _ = io.WriteString(r.out, SeqClearScreen)
@@ -75,6 +84,9 @@ func (r *Renderer) Clear() {
75 r.prev = nil84 r.prev = nil
76 r.logChat = nil85 r.logChat = nil
77 r.logBottom = nil86 r.logBottom = nil
87+ r.logLines = nil
88+ r.logViewportTop = 0
89+ r.logHardwareRow = 0
78 r.logInit = false90 r.logInit = false
79 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))91 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
80 }92 }
@@ -84,6 +96,7 @@ func (r *Renderer) Clear() {
84 // visible full-screen flash would be too distracting.96 // visible full-screen flash would be too distracting.
85 func (r *Renderer) Invalidate() {97 func (r *Renderer) Invalidate() {
86 r.prev = nil98 r.prev = nil
99+ r.logLines = nil
87 }100 }
88 101
89 // Draw updates the terminal so that the visible frame ends with the102 // Draw updates the terminal so that the visible frame ends with the
@@ -285,107 +298,226 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
285 bottomFrame[i] = truncateToWidth(line, r.cols)298 bottomFrame[i] = truncateToWidth(line, r.cols)
286 }299 }
287 300
301+ // Always reserve one real row below the editor/status band. This is
302+ // renderer-owned (not a best-effort trailing blank in the caller's
303+ // bottom block), so the logical-buffer diff keeps it visible and cursor
304+ // placement remains relative to the editor itself.
305+ const bottomMarginRows = 1
306+ lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows)
307+ lines = append(lines, chatFrame...)
308+ lines = append(lines, bottomFrame...)
309+ for range bottomMarginRows {
310+ lines = append(lines, "")
311+ }
312+ if len(lines) == 0 {
313+ lines = []string{""}
314+ }
315+
316+ cursorTargetRow := -1
317+ if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
318+ cursorTargetRow = len(chatFrame) + cursorBottomRow
319+ }
320+
288 var w strings.Builder321 var w strings.Builder
289 w.WriteString(SeqSynchronizedOn)322 w.WriteString(SeqSynchronizedOn)
290 w.WriteString(SeqHideCursor)323 w.WriteString(SeqHideCursor)
291 324
292- if !r.logInit {325+ writeFull := func(clear bool) {
293- // First paint: start at top-left and print the transcript followed326+ if clear {
294- // immediately by the live input/status block. No bottom padding,327+ w.WriteString(SeqDeleteKittyImages)
295- // no footer layout.328+ w.WriteString(SeqClearScreen)
296- w.WriteString(SeqClearScreen)329+ w.WriteString(SeqClearScrollback)
297- w.WriteString(SeqClearScrollback)330+ w.WriteString(MoveTo(1, 1))
298- w.WriteString(MoveTo(1, 1))331+ }
299- for _, line := range chatFrame {332+ for idx, line := range lines {
333+ if idx > 0 {
334+ w.WriteString("\r\n")
335+ }
300 w.WriteString("\x1b[0m")336 w.WriteString("\x1b[0m")
301 w.WriteString(SeqClearLine)337 w.WriteString(SeqClearLine)
302 w.WriteString(line)338 w.WriteString(line)
303- w.WriteString("\r\n")
304 }339 }
305- writeBlock(&w, bottomFrame)340+ r.logHardwareRow = len(lines) - 1
306- r.logInit = true341+ r.logViewportTop = len(lines) - r.rows
307- } else {342+ if r.logViewportTop < 0 {
308- // Walk back up to the top of the previous bottom block. The cursor343+ r.logViewportTop = 0
309- // was last positioned somewhere inside the bottom band by the344+ }
310- // previous Draw (final ShowCursor below); we don't trust the345+ }
311- // terminal's saved cursor across frames because terminal-driven346+
312- // scrolling would invalidate it. Instead, rebuild the relative347+ moveToLogicalRow := func(targetRow int) {
313- // position from r.cursorRow inside the previous bottomFrame.348+ if targetRow < 0 {
314- prevBottomRows := len(r.logBottom)349+ targetRow = 0
315- prevCursorRow := r.cursorRow350+ }
316- if prevCursorRow < 0 || prevCursorRow >= prevBottomRows {351+ if targetRow >= len(lines) {
317- prevCursorRow = prevBottomRows - 1352+ targetRow = len(lines) - 1
318- if prevCursorRow < 0 {353+ }
319- prevCursorRow = 0354+ viewportBottom := r.logViewportTop + r.rows - 1
355+ if targetRow > viewportBottom {
356+ currentScreenRow := r.logHardwareRow - r.logViewportTop
357+ if currentScreenRow < 0 {
358+ currentScreenRow = 0
359+ }
360+ if currentScreenRow >= r.rows {
361+ currentScreenRow = r.rows - 1
362+ }
363+ moveToBottom := r.rows - 1 - currentScreenRow
364+ if moveToBottom > 0 {
365+ w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
320 }366 }
367+ scroll := targetRow - viewportBottom
368+ for s := 0; s < scroll; s++ {
369+ w.WriteString("\r\n")
370+ }
371+ r.logViewportTop += scroll
372+ r.logHardwareRow = targetRow
373+ return
321 }374 }
322- up := prevCursorRow375+ currentScreenRow := r.logHardwareRow - r.logViewportTop
323- if prevBottomRows > 0 && up > 0 {376+ targetScreenRow := targetRow - r.logViewportTop
324- w.WriteString("\x1b[" + itoa(up) + "A")377+ lineDiff := targetScreenRow - currentScreenRow
378+ if lineDiff > 0 {
379+ w.WriteString("\x1b[" + itoa(lineDiff) + "B")
380+ } else if lineDiff < 0 {
381+ w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
382+ }
383+ r.logHardwareRow = targetRow
384+ }
385+
386+ positionCursor := func() {
387+ if cursorTargetRow < 0 || cursorTargetRow >= len(lines) {
388+ return
325 }389 }
390+ moveToLogicalRow(cursorTargetRow)
326 w.WriteString("\r")391 w.WriteString("\r")
392+ if cursorCol > 0 {
393+ w.WriteString("\x1b[" + itoa(cursorCol) + "C")
394+ }
395+ w.WriteString(SeqShowCursor)
396+ }
327 397
328- prefix := len(r.logChat) <= len(chatFrame)398+ full := !r.logInit || len(r.logLines) == 0
329- if prefix {399+ if full {
330- for i := range r.logChat {400+ writeFull(true)
331- if r.logChat[i] != chatFrame[i] {401+ r.logInit = true
332- prefix = false402+ } else {
333- break403+ firstChanged := -1
404+ lastChanged := -1
405+ maxLines := len(lines)
406+ if len(r.logLines) > maxLines {
407+ maxLines = len(r.logLines)
408+ }
409+ for idx := 0; idx < maxLines; idx++ {
410+ oldLine := ""
411+ if idx < len(r.logLines) {
412+ oldLine = r.logLines[idx]
413+ }
414+ newLine := ""
415+ if idx < len(lines) {
416+ newLine = lines[idx]
417+ }
418+ if oldLine != newLine {
419+ if firstChanged == -1 {
420+ firstChanged = idx
334 }421 }
422+ lastChanged = idx
335 }423 }
336 }424 }
337- if prefix {425+
338- // Erase old bottom band entirely, then append only genuinely new426+ if firstChanged == -1 {
339- // chat rows above the new bottom band. New chat rows become real427+ // No content changes; the final cursor positioning below may still
340- // terminal scrollback; inline image escapes are emitted once here.428+ // move the hardware cursor if the editor cursor changed.
341- w.WriteString(SeqEraseToEnd)429+ } else if len(lines) < len(r.logLines) || firstChanged < r.logViewportTop {
342- for _, line := range chatFrame[len(r.logChat):] {430+ // Shrinks and changes above the visible viewport cannot be safely
343- w.WriteString("\x1b[0m")431+ // patched in terminal scrollback. Replay the current logical buffer
344- w.WriteString(SeqClearLine)432+ // instead of risking stale duplicated rows.
345- w.WriteString(line)433+ writeFull(true)
434+ } else {
435+ prevViewportTop := r.logViewportTop
436+ viewportTop := prevViewportTop
437+ hardwareRow := r.logHardwareRow
438+ prevViewportBottom := prevViewportTop + r.rows - 1
439+ appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0
440+ moveTarget := firstChanged
441+ if appendStart {
442+ moveTarget = firstChanged - 1
443+ }
444+
445+ if moveTarget > prevViewportBottom {
446+ currentScreenRow := hardwareRow - prevViewportTop
447+ if currentScreenRow < 0 {
448+ currentScreenRow = 0
449+ }
450+ if currentScreenRow >= r.rows {
451+ currentScreenRow = r.rows - 1
452+ }
453+ moveToBottom := r.rows - 1 - currentScreenRow
454+ if moveToBottom > 0 {
455+ w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
456+ }
457+ scroll := moveTarget - prevViewportBottom
458+ for s := 0; s < scroll; s++ {
459+ w.WriteString("\r\n")
460+ }
461+ prevViewportTop += scroll
462+ viewportTop += scroll
463+ hardwareRow = moveTarget
464+ }
465+
466+ currentScreenRow := hardwareRow - prevViewportTop
467+ targetScreenRow := moveTarget - viewportTop
468+ lineDiff := targetScreenRow - currentScreenRow
469+ if lineDiff > 0 {
470+ w.WriteString("\x1b[" + itoa(lineDiff) + "B")
471+ } else if lineDiff < 0 {
472+ w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
473+ }
474+ if appendStart {
346 w.WriteString("\r\n")475 w.WriteString("\r\n")
476+ } else {
477+ w.WriteString("\r")
347 }478 }
348- } else {479+
349- // Transcript changed in place (streaming text grows within the480+ renderEnd := lastChanged
350- // last assistant block, markdown finalises/reflows, ctrl+o481+ if renderEnd >= len(lines) {
351- // expand/collapse, /clear, session load). In main-screen flow482+ renderEnd = len(lines) - 1
352- // mode old chat lives in terminal scrollback; trying to move up483+ }
353- // and rewrite it is unreliable, especially once images or native484+ for idx := firstChanged; idx <= renderEnd; idx++ {
354- // scrolling are involved. Use the safe path: clear visible screen485+ if idx > firstChanged {
355- // + scrollback, then replay the current transcript once.486+ w.WriteString("\r\n")
356- w.WriteString(SeqDeleteKittyImages)487+ }
357- w.WriteString(SeqClearScreen)
358- w.WriteString(SeqClearScrollback)
359- w.WriteString(MoveTo(1, 1))
360- for _, line := range chatFrame {
361 w.WriteString("\x1b[0m")488 w.WriteString("\x1b[0m")
362 w.WriteString(SeqClearLine)489 w.WriteString(SeqClearLine)
363- w.WriteString(line)490+ w.WriteString(lines[idx])
364- w.WriteString("\r\n")491+ }
492+ r.logHardwareRow = renderEnd
493+ r.logViewportTop = viewportTop
494+ if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
495+ r.logViewportTop = minTop
496+ }
497+ if r.logViewportTop < 0 {
498+ r.logViewportTop = 0
365 }499 }
366 }500 }
367- writeBlock(&w, bottomFrame)
368 }501 }
369 502
370- // writeBlock leaves the cursor on the last bottom row. Move to the503+ // Re-anchor the terminal viewport at the true end of the logical
371- // requested cursor position inside that block.504+ // buffer on every frame. During busy turns most redraws only mutate
372- if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {505+ // spinner/live rows above the editor; without touching the final margin
373- up := (len(bottomFrame) - 1) - cursorBottomRow506+ // row, the terminal can visually lose the bottom padding even though it
374- if up > 0 {507+ // still exists in r.logLines.
375- w.WriteString("\x1b[" + itoa(up) + "A")508+ if len(lines) > 0 {
376- }509+ moveToLogicalRow(len(lines) - 1)
377 w.WriteString("\r")510 w.WriteString("\r")
378- if cursorCol > 0 {511+ w.WriteString("\x1b[0m")
379- w.WriteString("\x1b[" + itoa(cursorCol) + "C")512+ w.WriteString(SeqClearLine)
380- }
381- w.WriteString(SeqShowCursor)
382 }513 }
383-514+ positionCursor()
384 w.WriteString(SeqSynchronizedOff)515 w.WriteString(SeqSynchronizedOff)
385 _, _ = io.WriteString(r.out, w.String())516 _, _ = io.WriteString(r.out, w.String())
386 517
387 r.logChat = append(r.logChat[:0], chatFrame...)518 r.logChat = append(r.logChat[:0], chatFrame...)
388 r.logBottom = append(r.logBottom[:0], bottomFrame...)519 r.logBottom = append(r.logBottom[:0], bottomFrame...)
520+ r.logLines = append(r.logLines[:0], lines...)
389 r.cursorRow = cursorBottomRow521 r.cursorRow = cursorBottomRow
390 r.cursorCol = cursorCol522 r.cursorCol = cursorCol
391 }523 }

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