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() { | |||
| 1061 | 1061 | // from the chat below, instead of the diff path leaving stale | |
| 1062 | 1062 | // dialog content behind. Equivalent to the user pressing ctrl+l. | |
| 1063 | 1063 | 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() | |
| 1077 | 1078 | } | |
| 1078 | 1079 | i.prevOverlayOpen = overlayOpen | |
| 1079 | 1080 | if len(suggest) > 0 { | |
| @@ -1651,6 +1652,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1651 | 1652 | if act.Select { | |
| 1652 | 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 | 1661 | return false | |
| 1655 | 1662 | } | |
| 1656 | 1663 | if i.swarmDialog.Active() { | |
| @@ -1061,19 +1061,20 @@ func (i *Interactive) redraw() { | |||||
| 1061 | // from the chat below, instead of the diff path leaving stale | 1061 | // 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) > 0 | 1063 | 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 the | 1065 | + | // An overlay (dialog or slash/file popup) just closed, so the |
| 1066 | - | // diff path leaves stale rows behind when the slash/file popup | 1066 | + | // bottom band shrinks. On terminals where we can drop |
| 1067 | - | // opens AND when it closes. Force a full repaint on both | 1067 | + | // scrollback, a full Clear is the simplest way to guarantee |
| 1068 | - | // transitions there, using Invalidate so we don't trigger VS | 1068 | + | // the vacated rows are repainted from the chat below. |
| 1069 | - | // Code's viewport-snap the way Clear would. Other terminals | 1069 | + | // |
| 1070 | - | // only need the close-transition Clear (the open paint is | 1070 | + | // 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 = overlayOpen | 1079 | 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 false | 1661 | 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) { | |||
| 122 | 122 | r.logViewportTop = 0 | |
| 123 | 123 | r.logHardwareRow = 0 | |
| 124 | 124 | 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 | + | } | |
| 136 | 144 | } | |
| 137 | 145 | } | |
| 138 | 146 | } | |
| @@ -151,12 +159,21 @@ func (r *Renderer) Clear() { | |||
| 151 | 159 | r.logHardwareRow = 0 | |
| 152 | 160 | r.logInit = false | |
| 153 | 161 | 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 | + | } | |
| 160 | 177 | return | |
| 161 | 178 | } | |
| 162 | 179 | _, _ = 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 = 0 | 122 | r.logViewportTop = 0 | ||
| 123 | r.logHardwareRow = 0 | 123 | r.logHardwareRow = 0 | ||
| 124 | r.logInit = false | 124 | r.logInit = false | ||
| 125 | - | if r.out != nil && !r.keepScrollback { | 125 | + | if r.out != nil { |
| 126 | - | // Clear both screen and (where safe) scrollback so stale | 126 | + | if r.keepScrollback { |
| 127 | - | // content from the old width doesn't bleed through. Move | 127 | + | // A resize is a discrete user action, like Ctrl+L: the |
| 128 | - | // to (1,1) so the next DrawLog/writeFull starts from a | 128 | + | // old-width frame must be fully purged or the in-place |
| 129 | - | // clean slate. Use the no-home variant: the explicit | 129 | + | // viewport clear leaves the scrolled-away rows behind |
| 130 | - | // MoveTo below sets the cursor without triggering VS | 130 | + | // (the user otherwise has to press Ctrl+L to fix it). |
| 131 | - | // Code's viewport-snap. See Renderer.keepScrollback for | 131 | + | // Emit \x1b[3J to drop the retained scrollback and |
| 132 | - | // why we skip \x1b[3J on VS Code's terminal. On VS Code we | 132 | + | // repaint clean, accepting VS Code's viewport-snap the |
| 133 | - | // skip the eager wipe entirely and let the next DrawLog | 133 | + | // 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 = 0 | 159 | r.logHardwareRow = 0 | ||
| 152 | r.logInit = false | 160 | 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 that | 162 | + | // On VS Code's xterm.js the transcript is taller than the |
| 155 | - | // scrolls the current frame up into scrollback, leaving a | 163 | + | // viewport, so an in-place clear (home + erase-to-end) only |
| 156 | - | // duplicate above the repaint. Just drop the diff state; the | 164 | + | // wipes the visible rows: the part that scrolled above the |
| 157 | - | // next DrawLog's writeFull does an in-place viewport clear | 165 | + | // viewport stays in the retained buffer and the next full |
| 158 | - | // (home + erase-to-end) that overwrites the old frame without | 166 | + | // 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 | return | 177 | 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)) | ||