detached 4bcdf88

Purge VS Code scrollback on clear, overlay close, and resize

On VS Code's xterm.js the transcript is taller than the viewport, so an
in-place clear (home + erase-to-end) only wipes the visible rows and the
scrolled-away part lingers in retained scrollback, stacking a duplicate
on the next full repaint.

- Clear() (Ctrl+L) now emits \x1b[3J under keepScrollback to actually
  drop that scrollback, then homes and repaints. Accepts VS Code's
  viewport-snap since the user explicitly asked for a clean screen.
- Overlay close (esc on a dialog, slash/file popup dismissal) now runs
  the same Clear() so closing a picker purges the stale overlay rows
  instead of leaving them in scrollback.
- Resize() does the same purge under keepScrollback; previously it
  skipped the wipe and left a half-repainted old-width frame until the
  user pressed Ctrl+L.

Other terminals keep their no-snap clear path.
commit
4bcdf8804b6a9251c8363e58b83f6229708618a1
author
patriceckhart · · 2026-06-04 17:16 UTC
parent
cde9298
signature
unsigned
2 files · +54 -30
M packages/agent/modes/interactive.go +20 -13
@@ -1061,19 +1061,20 @@ func (i *Interactive) redraw() {
10611061 // from the chat below, instead of the diff path leaving stale
10621062 // dialog content behind. Equivalent to the user pressing ctrl+l.
10631063 overlayOpen := len(dialog) > 0 || len(suggest) > 0
1064- if i.rend != nil && i.prevOverlayOpen != overlayOpen {
1065- // VS Code's terminal keeps scrollback (no \x1b[3J), so the
1066- // diff path leaves stale rows behind when the slash/file popup
1067- // opens AND when it closes. Force a full repaint on both
1068- // transitions there, using Invalidate so we don't trigger VS
1069- // Code's viewport-snap the way Clear would. Other terminals
1070- // only need the close-transition Clear (the open paint is
1071- // clean because scrollback was wiped).
1072- if i.rend.KeepsScrollback() {
1073- i.rend.Invalidate()
1074- } else if i.prevOverlayOpen && !overlayOpen {
1075- i.rend.Clear()
1076- }
1064+ if i.rend != nil && i.prevOverlayOpen && !overlayOpen {
1065+ // An overlay (dialog or slash/file popup) just closed, so the
1066+ // bottom band shrinks. On terminals where we can drop
1067+ // scrollback, a full Clear is the simplest way to guarantee
1068+ // the vacated rows are repainted from the chat below.
1069+ //
1070+ // On VS Code's terminal closing a dialog leaves the stale
1071+ // overlay rows in the retained scrollback (we can't drop them
1072+ // with the quiet in-place diff). Run the same full Clear() that
1073+ // Ctrl+L uses so the scrollback is purged and the conversation
1074+ // is repainted clean, matching what the user expects after
1075+ // dismissing a picker. Clear() is keepScrollback-aware and
1076+ // emits \x1b[3J there.
1077+ i.rend.Clear()
10771078 }
10781079 i.prevOverlayOpen = overlayOpen
10791080 if len(suggest) > 0 {
@@ -1651,6 +1652,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
16511652 if act.Select {
16521653 i.applySessionSelection(act.Path)
16531654 }
1655+ // Always request a redraw after handling a key here: when esc
1656+ // closes the picker, the overlay-close detection in the render
1657+ // pass needs to run so the tall dialog rows get repainted from
1658+ // the chat (otherwise VS Code's retained scrollback leaves a
1659+ // duplicate frame on screen).
1660+ i.invalidate()
16541661 return false
16551662 }
16561663 if i.swarmDialog.Active() {
@@ -1061,19 +1061,20 @@ func (i *Interactive) redraw() {
1061 // from the chat below, instead of the diff path leaving stale1061 // from the chat below, instead of the diff path leaving stale
1062 // dialog content behind. Equivalent to the user pressing ctrl+l.1062 // dialog content behind. Equivalent to the user pressing ctrl+l.
1063 overlayOpen := len(dialog) > 0 || len(suggest) > 01063 overlayOpen := len(dialog) > 0 || len(suggest) > 0
1064- if i.rend != nil && i.prevOverlayOpen != overlayOpen {1064+ if i.rend != nil && i.prevOverlayOpen && !overlayOpen {
1065- // VS Code's terminal keeps scrollback (no \x1b[3J), so the1065+ // An overlay (dialog or slash/file popup) just closed, so the
1066- // diff path leaves stale rows behind when the slash/file popup1066+ // bottom band shrinks. On terminals where we can drop
1067- // opens AND when it closes. Force a full repaint on both1067+ // scrollback, a full Clear is the simplest way to guarantee
1068- // transitions there, using Invalidate so we don't trigger VS1068+ // the vacated rows are repainted from the chat below.
1069- // Code's viewport-snap the way Clear would. Other terminals1069+ //
1070- // only need the close-transition Clear (the open paint is1070+ // On VS Code's terminal closing a dialog leaves the stale
1071- // clean because scrollback was wiped).1071+ // overlay rows in the retained scrollback (we can't drop them
1072- if i.rend.KeepsScrollback() {1072+ // with the quiet in-place diff). Run the same full Clear() that
1073- i.rend.Invalidate()1073+ // Ctrl+L uses so the scrollback is purged and the conversation
1074- } else if i.prevOverlayOpen && !overlayOpen {1074+ // is repainted clean, matching what the user expects after
1075- i.rend.Clear()1075+ // dismissing a picker. Clear() is keepScrollback-aware and
1076- }1076+ // emits \x1b[3J there.
1077+ i.rend.Clear()
1077 }1078 }
1078 i.prevOverlayOpen = overlayOpen1079 i.prevOverlayOpen = overlayOpen
1079 if len(suggest) > 0 {1080 if len(suggest) > 0 {
@@ -1651,6 +1652,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1651 if act.Select {1652 if act.Select {
1652 i.applySessionSelection(act.Path)1653 i.applySessionSelection(act.Path)
1653 }1654 }
1655+ // Always request a redraw after handling a key here: when esc
1656+ // closes the picker, the overlay-close detection in the render
1657+ // pass needs to run so the tall dialog rows get repainted from
1658+ // the chat (otherwise VS Code's retained scrollback leaves a
1659+ // duplicate frame on screen).
1660+ i.invalidate()
1654 return false1661 return false
1655 }1662 }
1656 if i.swarmDialog.Active() {1663 if i.swarmDialog.Active() {
M packages/tui/render.go +34 -17
@@ -122,17 +122,25 @@ func (r *Renderer) Resize(cols, rows int) {
122122 r.logViewportTop = 0
123123 r.logHardwareRow = 0
124124 r.logInit = false
125- if r.out != nil && !r.keepScrollback {
126- // Clear both screen and (where safe) scrollback so stale
127- // content from the old width doesn't bleed through. Move
128- // to (1,1) so the next DrawLog/writeFull starts from a
129- // clean slate. Use the no-home variant: the explicit
130- // MoveTo below sets the cursor without triggering VS
131- // Code's viewport-snap. See Renderer.keepScrollback for
132- // why we skip \x1b[3J on VS Code's terminal. On VS Code we
133- // skip the eager wipe entirely and let the next DrawLog
134- // repaint in place (see Clear).
135- _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
125+ if r.out != nil {
126+ if r.keepScrollback {
127+ // A resize is a discrete user action, like Ctrl+L: the
128+ // old-width frame must be fully purged or the in-place
129+ // viewport clear leaves the scrolled-away rows behind
130+ // (the user otherwise has to press Ctrl+L to fix it).
131+ // Emit \x1b[3J to drop the retained scrollback and
132+ // repaint clean, accepting VS Code's viewport-snap the
133+ // same way Clear does.
134+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
135+ } else {
136+ // Clear both screen and (where safe) scrollback so stale
137+ // content from the old width doesn't bleed through. Move
138+ // to (1,1) so the next DrawLog/writeFull starts from a
139+ // clean slate. Use the no-home variant: the explicit
140+ // MoveTo below sets the cursor without triggering VS
141+ // Code's viewport-snap.
142+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
143+ }
136144 }
137145 }
138146 }
@@ -151,12 +159,21 @@ func (r *Renderer) Clear() {
151159 r.logHardwareRow = 0
152160 r.logInit = false
153161 if r.keepScrollback {
154- // Don't eagerly emit \x1b[2J here: on VS Code's xterm.js that
155- // scrolls the current frame up into scrollback, leaving a
156- // duplicate above the repaint. Just drop the diff state; the
157- // next DrawLog's writeFull does an in-place viewport clear
158- // (home + erase-to-end) that overwrites the old frame without
159- // duplicating it.
162+ // On VS Code's xterm.js the transcript is taller than the
163+ // viewport, so an in-place clear (home + erase-to-end) only
164+ // wipes the visible rows: the part that scrolled above the
165+ // viewport stays in the retained buffer and the next full
166+ // repaint stacks a duplicate above the live frame.
167+ //
168+ // Clear() is an explicit user refresh (Ctrl+L), so here we do
169+ // emit \x1b[3J to actually drop that scrollback, then home and
170+ // repaint. This is the one place we accept VS Code's
171+ // viewport-snap, because the user asked for a clean screen.
172+ // (Implicit repaints, e.g. closing a dialog, deliberately
173+ // avoid this path so they never snap the viewport.)
174+ if r.out != nil {
175+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
176+ }
160177 return
161178 }
162179 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
@@ -122,17 +122,25 @@ func (r *Renderer) Resize(cols, rows int) {
122 r.logViewportTop = 0122 r.logViewportTop = 0
123 r.logHardwareRow = 0123 r.logHardwareRow = 0
124 r.logInit = false124 r.logInit = false
125- if r.out != nil && !r.keepScrollback {125+ if r.out != nil {
126- // Clear both screen and (where safe) scrollback so stale126+ if r.keepScrollback {
127- // content from the old width doesn't bleed through. Move127+ // A resize is a discrete user action, like Ctrl+L: the
128- // to (1,1) so the next DrawLog/writeFull starts from a128+ // old-width frame must be fully purged or the in-place
129- // clean slate. Use the no-home variant: the explicit129+ // viewport clear leaves the scrolled-away rows behind
130- // MoveTo below sets the cursor without triggering VS130+ // (the user otherwise has to press Ctrl+L to fix it).
131- // Code's viewport-snap. See Renderer.keepScrollback for131+ // Emit \x1b[3J to drop the retained scrollback and
132- // why we skip \x1b[3J on VS Code's terminal. On VS Code we132+ // repaint clean, accepting VS Code's viewport-snap the
133- // skip the eager wipe entirely and let the next DrawLog133+ // same way Clear does.
134- // repaint in place (see Clear).134+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
135- _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))135+ } else {
136+ // Clear both screen and (where safe) scrollback so stale
137+ // content from the old width doesn't bleed through. Move
138+ // to (1,1) so the next DrawLog/writeFull starts from a
139+ // clean slate. Use the no-home variant: the explicit
140+ // MoveTo below sets the cursor without triggering VS
141+ // Code's viewport-snap.
142+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
143+ }
136 }144 }
137 }145 }
138 }146 }
@@ -151,12 +159,21 @@ func (r *Renderer) Clear() {
151 r.logHardwareRow = 0159 r.logHardwareRow = 0
152 r.logInit = false160 r.logInit = false
153 if r.keepScrollback {161 if r.keepScrollback {
154- // Don't eagerly emit \x1b[2J here: on VS Code's xterm.js that162+ // On VS Code's xterm.js the transcript is taller than the
155- // scrolls the current frame up into scrollback, leaving a163+ // viewport, so an in-place clear (home + erase-to-end) only
156- // duplicate above the repaint. Just drop the diff state; the164+ // wipes the visible rows: the part that scrolled above the
157- // next DrawLog's writeFull does an in-place viewport clear165+ // viewport stays in the retained buffer and the next full
158- // (home + erase-to-end) that overwrites the old frame without166+ // repaint stacks a duplicate above the live frame.
159- // duplicating it.167+ //
168+ // Clear() is an explicit user refresh (Ctrl+L), so here we do
169+ // emit \x1b[3J to actually drop that scrollback, then home and
170+ // repaint. This is the one place we accept VS Code's
171+ // viewport-snap, because the user asked for a clean screen.
172+ // (Implicit repaints, e.g. closing a dialog, deliberately
173+ // avoid this path so they never snap the viewport.)
174+ if r.out != nil {
175+ _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
176+ }
160 return177 return
161 }178 }
162 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))179 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))

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