detached 3e4884f

Preserve terminal selections during repaints

commit
3e4884f829f203802942c6fbf8e6720052451ec6
author
patriceckhart · · 2026-07-13 16:55 UTC
parent
1b17517
signature
unsigned
2 files · +57 -6
M packages/tui/render.go +15 -6
@@ -483,7 +483,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
483483 w.WriteString(SeqSynchronizedOn)
484484 w.WriteString(SeqHideCursor)
485485
486- writeFull := func(clear bool) {
486+ writeFull := func(clear, purgeScrollback bool) {
487487 if clear {
488488 w.WriteString(SeqDeleteKittyImages)
489489 if r.keepScrollback {
@@ -500,7 +500,13 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
500500 w.WriteString(SeqClearToEnd)
501501 } else {
502502 w.WriteString(SeqClearScreenNoHome)
503- w.WriteString(r.clearScrollbackSeq())
503+ // Purging scrollback invalidates native terminal selections.
504+ // It is safe on the first paint because no zot output can be
505+ // selected yet, but implicit recovery repaints happen while an
506+ // agent is streaming and must preserve the user's selection.
507+ if purgeScrollback {
508+ w.WriteString(r.clearScrollbackSeq())
509+ }
504510 w.WriteString(MoveTo(1, 1))
505511 }
506512 }
@@ -576,9 +582,10 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
576582 // because the cached entry was the \x00 sentinel rather than the
577583 // real previous bg-colored row.
578584
579- full := !r.logInit || len(r.logLines) == 0
585+ wasInitialized := r.logInit
586+ full := !wasInitialized || len(r.logLines) == 0
580587 if full {
581- writeFull(true)
588+ writeFull(true, !wasInitialized)
582589 r.logInit = true
583590 } else {
584591 firstChanged := -1
@@ -623,7 +630,9 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
623630 // move the hardware cursor if the editor cursor changed.
624631 } else if firstChanged < r.logViewportTop {
625632 // Changes above the visible viewport cannot be patched safely.
626- writeFull(true)
633+ // Repaint without erasing scrollback: the terminal may have a
634+ // native text selection anchored there while output streams.
635+ writeFull(true, false)
627636 } else {
628637 prevViewportTop := r.logViewportTop
629638 viewportTop := prevViewportTop
@@ -686,7 +695,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
686695 if len(r.logLines) > len(lines) {
687696 extra := len(r.logLines) - len(lines)
688697 if extra > r.rows {
689- writeFull(true)
698+ writeFull(true, false)
690699 } else {
691700 for e := 0; e < extra; e++ {
692701 w.WriteString("\x1b[1B")
@@ -483,7 +483,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
483 w.WriteString(SeqSynchronizedOn)483 w.WriteString(SeqSynchronizedOn)
484 w.WriteString(SeqHideCursor)484 w.WriteString(SeqHideCursor)
485 485
486- writeFull := func(clear bool) {486+ writeFull := func(clear, purgeScrollback bool) {
487 if clear {487 if clear {
488 w.WriteString(SeqDeleteKittyImages)488 w.WriteString(SeqDeleteKittyImages)
489 if r.keepScrollback {489 if r.keepScrollback {
@@ -500,7 +500,13 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
500 w.WriteString(SeqClearToEnd)500 w.WriteString(SeqClearToEnd)
501 } else {501 } else {
502 w.WriteString(SeqClearScreenNoHome)502 w.WriteString(SeqClearScreenNoHome)
503- w.WriteString(r.clearScrollbackSeq())503+ // Purging scrollback invalidates native terminal selections.
504+ // It is safe on the first paint because no zot output can be
505+ // selected yet, but implicit recovery repaints happen while an
506+ // agent is streaming and must preserve the user's selection.
507+ if purgeScrollback {
508+ w.WriteString(r.clearScrollbackSeq())
509+ }
504 w.WriteString(MoveTo(1, 1))510 w.WriteString(MoveTo(1, 1))
505 }511 }
506 }512 }
@@ -576,9 +582,10 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
576 // because the cached entry was the \x00 sentinel rather than the582 // because the cached entry was the \x00 sentinel rather than the
577 // real previous bg-colored row.583 // real previous bg-colored row.
578 584
579- full := !r.logInit || len(r.logLines) == 0585+ wasInitialized := r.logInit
586+ full := !wasInitialized || len(r.logLines) == 0
580 if full {587 if full {
581- writeFull(true)588+ writeFull(true, !wasInitialized)
582 r.logInit = true589 r.logInit = true
583 } else {590 } else {
584 firstChanged := -1591 firstChanged := -1
@@ -623,7 +630,9 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
623 // move the hardware cursor if the editor cursor changed.630 // move the hardware cursor if the editor cursor changed.
624 } else if firstChanged < r.logViewportTop {631 } else if firstChanged < r.logViewportTop {
625 // Changes above the visible viewport cannot be patched safely.632 // Changes above the visible viewport cannot be patched safely.
626- writeFull(true)633+ // Repaint without erasing scrollback: the terminal may have a
634+ // native text selection anchored there while output streams.
635+ writeFull(true, false)
627 } else {636 } else {
628 prevViewportTop := r.logViewportTop637 prevViewportTop := r.logViewportTop
629 viewportTop := prevViewportTop638 viewportTop := prevViewportTop
@@ -686,7 +695,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
686 if len(r.logLines) > len(lines) {695 if len(r.logLines) > len(lines) {
687 extra := len(r.logLines) - len(lines)696 extra := len(r.logLines) - len(lines)
688 if extra > r.rows {697 if extra > r.rows {
689- writeFull(true)698+ writeFull(true, false)
690 } else {699 } else {
691 for e := 0; e < extra; e++ {700 for e := 0; e < extra; e++ {
692 w.WriteString("\x1b[1B")701 w.WriteString("\x1b[1B")
M packages/tui/render_test.go +42 -0
@@ -101,3 +101,45 @@ func TestDrawLogResizeForcesFullRedraw(t *testing.T) {
101101 t.Fatal("post-resize redraw skipped; the new frame would never reach the terminal")
102102 }
103103 }
104+
105+// TestDrawLogRecoveryPreservesScrollbackSelection covers long streaming
106+// output whose changing first row has already scrolled above the viewport.
107+// DrawLog must repaint in that case, but erasing terminal scrollback also
108+// erases the text backing any native mouse selection the user is making.
109+func TestDrawLogRecoveryPreservesScrollbackSelection(t *testing.T) {
110+ t.Setenv("TERM_PROGRAM", "")
111+ var buf bytes.Buffer
112+ r := NewRenderer(&buf)
113+ r.Resize(80, 3)
114+ r.DrawLog([]string{"partial", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
115+ buf.Reset()
116+
117+ // The changed row is above logViewportTop, forcing the recovery repaint
118+ // used while a long streamed response continues to be reformatted.
119+ r.DrawLog([]string{"partial response", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
120+ got := buf.String()
121+ if !strings.Contains(got, SeqClearScreenNoHome) {
122+ t.Fatalf("recovery path did not repaint the screen: %q", got)
123+ }
124+ if strings.Contains(got, SeqClearScrollback) {
125+ t.Fatalf("recovery repaint erased scrollback and native selection: %q", got)
126+ }
127+}
128+
129+// TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for
130+// cache invalidations, which can happen during an active turn independently
131+// of an inaccessible changed row.
132+func TestDrawLogInvalidationPreservesScrollbackSelection(t *testing.T) {
133+ t.Setenv("TERM_PROGRAM", "")
134+ var buf bytes.Buffer
135+ r := NewRenderer(&buf)
136+ r.Resize(80, 3)
137+ r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
138+ buf.Reset()
139+
140+ r.Invalidate()
141+ r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
142+ if got := buf.String(); strings.Contains(got, SeqClearScrollback) {
143+ t.Fatalf("invalidation repaint erased scrollback and native selection: %q", got)
144+ }
145+}
@@ -101,3 +101,45 @@ func TestDrawLogResizeForcesFullRedraw(t *testing.T) {
101 t.Fatal("post-resize redraw skipped; the new frame would never reach the terminal")101 t.Fatal("post-resize redraw skipped; the new frame would never reach the terminal")
102 }102 }
103 }103 }
104+
105+// TestDrawLogRecoveryPreservesScrollbackSelection covers long streaming
106+// output whose changing first row has already scrolled above the viewport.
107+// DrawLog must repaint in that case, but erasing terminal scrollback also
108+// erases the text backing any native mouse selection the user is making.
109+func TestDrawLogRecoveryPreservesScrollbackSelection(t *testing.T) {
110+ t.Setenv("TERM_PROGRAM", "")
111+ var buf bytes.Buffer
112+ r := NewRenderer(&buf)
113+ r.Resize(80, 3)
114+ r.DrawLog([]string{"partial", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
115+ buf.Reset()
116+
117+ // The changed row is above logViewportTop, forcing the recovery repaint
118+ // used while a long streamed response continues to be reformatted.
119+ r.DrawLog([]string{"partial response", "line 2", "line 3", "line 4"}, []string{"input"}, 0, 0)
120+ got := buf.String()
121+ if !strings.Contains(got, SeqClearScreenNoHome) {
122+ t.Fatalf("recovery path did not repaint the screen: %q", got)
123+ }
124+ if strings.Contains(got, SeqClearScrollback) {
125+ t.Fatalf("recovery repaint erased scrollback and native selection: %q", got)
126+ }
127+}
128+
129+// TestDrawLogInvalidationPreservesScrollbackSelection pins the same rule for
130+// cache invalidations, which can happen during an active turn independently
131+// of an inaccessible changed row.
132+func TestDrawLogInvalidationPreservesScrollbackSelection(t *testing.T) {
133+ t.Setenv("TERM_PROGRAM", "")
134+ var buf bytes.Buffer
135+ r := NewRenderer(&buf)
136+ r.Resize(80, 3)
137+ r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
138+ buf.Reset()
139+
140+ r.Invalidate()
141+ r.DrawLog([]string{"one", "two", "three", "four"}, []string{"input"}, 0, 0)
142+ if got := buf.String(); strings.Contains(got, SeqClearScrollback) {
143+ t.Fatalf("invalidation repaint erased scrollback and native selection: %q", got)
144+ }
145+}

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