tui: suppress \x1b[3J scrollback-clear on VS Code's terminal
VS Code's integrated terminal (xterm.js) interprets the erase-in-display-3 escape (\x1b[3J) as "drop scrollback rows AND snap the viewport to the top of the remaining buffer." Once the user has reopened a terminal with VS Code's persistent-sessions feature on, there is real replayed scrollback above the live cursor, so the snap is visible: the host scrollbar yanks to the top on every full repaint — first paint, Ctrl+L (Renderer.Clear), and any writeFull(true) shrink. Every other terminal we tested (iTerm, Ghostty, Kitty, Alacritty, Apple Terminal) treats \x1b[3J as "drop scrollback rows without moving the viewport," which is what we want. Detect VS Code (and Cursor, which shares xterm.js) via $TERM_PROGRAM == "vscode" in NewRenderer and stash the result on the Renderer as keepScrollback. Gate all three emission sites (resize handler, Clear(), writeFull(true)) through a single helper clearScrollbackSeq() that returns "" when keepScrollback is true and SeqClearScrollback otherwise. Trade-off on VS Code: stale zot frames remain visible if you scroll up in the terminal's scrollback. Strictly less disruptive than the scrollbar yanking on every Ctrl+L, and limited to the one terminal that actually has the bug.
- commit
- 8b4b62f240dff7d7b941b2cd0875d9d9d5d5b192
- author
- patriceckhart · · 2026-05-13 08:37 UTC
- parent
- f47996b
- signature
- unsigned
1 file · +57 -9
M internal/tui/render.go +57 -9
| @@ -2,6 +2,7 @@ package tui | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "io" | |
| 5 | + | "os" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/mattn/go-runewidth" | |
| @@ -45,11 +46,44 @@ type Renderer struct { | |||
| 45 | 46 | logViewportTop int | |
| 46 | 47 | logHardwareRow int | |
| 47 | 48 | logInit bool | |
| 49 | + | ||
| 50 | + | // keepScrollback is true when we must NOT emit \x1b[3J | |
| 51 | + | // (erase-in-display 3, "clear scrollback"). | |
| 52 | + | // | |
| 53 | + | // VS Code's integrated terminal (xterm.js) interprets \x1b[3J | |
| 54 | + | // as "also snap the viewport to the top of the remaining | |
| 55 | + | // buffer." Once the user has reopened a terminal with VS | |
| 56 | + | // Code's persistent-sessions feature on, there is real | |
| 57 | + | // replayed scrollback above the live cursor, and the snap is | |
| 58 | + | // visible: the host scrollbar jumps to the top every time we | |
| 59 | + | // do a full repaint (first paint, Ctrl+L via Renderer.Clear, | |
| 60 | + | // any writeFull(true) shrink). On every other terminal we | |
| 61 | + | // tested (iTerm, Ghostty, Kitty, Alacritty, Apple Terminal) | |
| 62 | + | // \x1b[3J just drops scrollback rows without moving the | |
| 63 | + | // viewport, which is what we actually want. | |
| 64 | + | // | |
| 65 | + | // The trade-off when keepScrollback is true: stale zot frames | |
| 66 | + | // remain in scrollback above the live view, so scrolling up | |
| 67 | + | // in VS Code's terminal shows old (already-superseded) zot | |
| 68 | + | // output. That is strictly less disruptive than the | |
| 69 | + | // scrollbar yanking to top on every Ctrl+L, and it is a | |
| 70 | + | // limitation specific to VS Code's terminal that we have no | |
| 71 | + | // way to work around without breaking other terminals. | |
| 72 | + | keepScrollback bool | |
| 48 | 73 | } | |
| 49 | 74 | ||
| 50 | 75 | // NewRenderer returns a renderer that writes to out. | |
| 76 | + | // | |
| 77 | + | // Detects VS Code's integrated terminal via $TERM_PROGRAM and, when | |
| 78 | + | // detected, disables emission of \x1b[3J for the reasons documented | |
| 79 | + | // on Renderer.keepScrollback. The env var is set by VS Code itself | |
| 80 | + | // (and by Cursor, which forks VS Code's terminal — same xterm.js, | |
| 81 | + | // same bug), so no user configuration is required. | |
| 51 | 82 | func NewRenderer(out io.Writer) *Renderer { | |
| 52 | - | return &Renderer{out: out} | |
| 83 | + | return &Renderer{ | |
| 84 | + | out: out, | |
| 85 | + | keepScrollback: os.Getenv("TERM_PROGRAM") == "vscode", | |
| 86 | + | } | |
| 53 | 87 | } | |
| 54 | 88 | ||
| 55 | 89 | // Resize tells the renderer the current terminal size. | |
| @@ -76,12 +110,14 @@ func (r *Renderer) Resize(cols, rows int) { | |||
| 76 | 110 | r.logHardwareRow = 0 | |
| 77 | 111 | r.logInit = false | |
| 78 | 112 | if r.out != nil { | |
| 79 | - | // Clear both screen and scrollback so stale content from | |
| 80 | - | // the old width doesn't bleed through. Move to (1,1) so | |
| 81 | - | // the next DrawLog/writeFull starts from a clean slate. | |
| 82 | - | // Use the no-home variant: the explicit MoveTo below sets | |
| 83 | - | // the cursor without triggering VS Code's viewport-snap. | |
| 84 | - | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+SeqClearScrollback+MoveTo(1, 1)) | |
| 113 | + | // Clear both screen and (where safe) scrollback so stale | |
| 114 | + | // content from the old width doesn't bleed through. Move | |
| 115 | + | // to (1,1) so the next DrawLog/writeFull starts from a | |
| 116 | + | // clean slate. Use the no-home variant: the explicit | |
| 117 | + | // MoveTo below sets the cursor without triggering VS | |
| 118 | + | // Code's viewport-snap. See Renderer.keepScrollback for | |
| 119 | + | // why we skip \x1b[3J on VS Code's terminal. | |
| 120 | + | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1)) | |
| 85 | 121 | } | |
| 86 | 122 | } | |
| 87 | 123 | } | |
| @@ -99,7 +135,19 @@ func (r *Renderer) Clear() { | |||
| 99 | 135 | r.logViewportTop = 0 | |
| 100 | 136 | r.logHardwareRow = 0 | |
| 101 | 137 | r.logInit = false | |
| 102 | - | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+SeqClearScrollback+MoveTo(1, 1)) | |
| 138 | + | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1)) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // clearScrollbackSeq returns the scrollback-clear escape, or the | |
| 142 | + | // empty string when we are running under a terminal where emitting | |
| 143 | + | // it has user-visible side effects (see Renderer.keepScrollback). | |
| 144 | + | // Callers concatenate this into a larger control sequence; an empty | |
| 145 | + | // return value is a no-op there. | |
| 146 | + | func (r *Renderer) clearScrollbackSeq() string { | |
| 147 | + | if r.keepScrollback { | |
| 148 | + | return "" | |
| 149 | + | } | |
| 150 | + | return SeqClearScrollback | |
| 103 | 151 | } | |
| 104 | 152 | ||
| 105 | 153 | // Invalidate forces a full repaint on the next Draw without clearing the | |
| @@ -342,7 +390,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 342 | 390 | if clear { | |
| 343 | 391 | w.WriteString(SeqDeleteKittyImages) | |
| 344 | 392 | w.WriteString(SeqClearScreenNoHome) | |
| 345 | - | w.WriteString(SeqClearScrollback) | |
| 393 | + | w.WriteString(r.clearScrollbackSeq()) | |
| 346 | 394 | w.WriteString(MoveTo(1, 1)) | |
| 347 | 395 | } | |
| 348 | 396 | for idx, line := range lines { | |
| @@ -2,6 +2,7 @@ package tui | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "io" | 4 | "io" | ||
| 5 | + | "os" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | 7 | ||||
| 7 | "github.com/mattn/go-runewidth" | 8 | "github.com/mattn/go-runewidth" | ||
| @@ -45,11 +46,44 @@ type Renderer struct { | |||||
| 45 | logViewportTop int | 46 | logViewportTop int | ||
| 46 | logHardwareRow int | 47 | logHardwareRow int | ||
| 47 | logInit bool | 48 | logInit bool | ||
| 49 | + | ||||
| 50 | + | // keepScrollback is true when we must NOT emit \x1b[3J | |||
| 51 | + | // (erase-in-display 3, "clear scrollback"). | |||
| 52 | + | // | |||
| 53 | + | // VS Code's integrated terminal (xterm.js) interprets \x1b[3J | |||
| 54 | + | // as "also snap the viewport to the top of the remaining | |||
| 55 | + | // buffer." Once the user has reopened a terminal with VS | |||
| 56 | + | // Code's persistent-sessions feature on, there is real | |||
| 57 | + | // replayed scrollback above the live cursor, and the snap is | |||
| 58 | + | // visible: the host scrollbar jumps to the top every time we | |||
| 59 | + | // do a full repaint (first paint, Ctrl+L via Renderer.Clear, | |||
| 60 | + | // any writeFull(true) shrink). On every other terminal we | |||
| 61 | + | // tested (iTerm, Ghostty, Kitty, Alacritty, Apple Terminal) | |||
| 62 | + | // \x1b[3J just drops scrollback rows without moving the | |||
| 63 | + | // viewport, which is what we actually want. | |||
| 64 | + | // | |||
| 65 | + | // The trade-off when keepScrollback is true: stale zot frames | |||
| 66 | + | // remain in scrollback above the live view, so scrolling up | |||
| 67 | + | // in VS Code's terminal shows old (already-superseded) zot | |||
| 68 | + | // output. That is strictly less disruptive than the | |||
| 69 | + | // scrollbar yanking to top on every Ctrl+L, and it is a | |||
| 70 | + | // limitation specific to VS Code's terminal that we have no | |||
| 71 | + | // way to work around without breaking other terminals. | |||
| 72 | + | keepScrollback bool | |||
| 48 | } | 73 | } | ||
| 49 | 74 | ||||
| 50 | // NewRenderer returns a renderer that writes to out. | 75 | // NewRenderer returns a renderer that writes to out. | ||
| 76 | + | // | |||
| 77 | + | // Detects VS Code's integrated terminal via $TERM_PROGRAM and, when | |||
| 78 | + | // detected, disables emission of \x1b[3J for the reasons documented | |||
| 79 | + | // on Renderer.keepScrollback. The env var is set by VS Code itself | |||
| 80 | + | // (and by Cursor, which forks VS Code's terminal — same xterm.js, | |||
| 81 | + | // same bug), so no user configuration is required. | |||
| 51 | func NewRenderer(out io.Writer) *Renderer { | 82 | func NewRenderer(out io.Writer) *Renderer { | ||
| 52 | - | return &Renderer{out: out} | 83 | + | return &Renderer{ |
| 84 | + | out: out, | |||
| 85 | + | keepScrollback: os.Getenv("TERM_PROGRAM") == "vscode", | |||
| 86 | + | } | |||
| 53 | } | 87 | } | ||
| 54 | 88 | ||||
| 55 | // Resize tells the renderer the current terminal size. | 89 | // Resize tells the renderer the current terminal size. | ||
| @@ -76,12 +110,14 @@ func (r *Renderer) Resize(cols, rows int) { | |||||
| 76 | r.logHardwareRow = 0 | 110 | r.logHardwareRow = 0 | ||
| 77 | r.logInit = false | 111 | r.logInit = false | ||
| 78 | if r.out != nil { | 112 | if r.out != nil { | ||
| 79 | - | // Clear both screen and scrollback so stale content from | 113 | + | // Clear both screen and (where safe) scrollback so stale |
| 80 | - | // the old width doesn't bleed through. Move to (1,1) so | 114 | + | // content from the old width doesn't bleed through. Move |
| 81 | - | // the next DrawLog/writeFull starts from a clean slate. | 115 | + | // to (1,1) so the next DrawLog/writeFull starts from a |
| 82 | - | // Use the no-home variant: the explicit MoveTo below sets | 116 | + | // clean slate. Use the no-home variant: the explicit |
| 83 | - | // the cursor without triggering VS Code's viewport-snap. | 117 | + | // MoveTo below sets the cursor without triggering VS |
| 84 | - | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+SeqClearScrollback+MoveTo(1, 1)) | 118 | + | // Code's viewport-snap. See Renderer.keepScrollback for |
| 119 | + | // why we skip \x1b[3J on VS Code's terminal. | |||
| 120 | + | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1)) | |||
| 85 | } | 121 | } | ||
| 86 | } | 122 | } | ||
| 87 | } | 123 | } | ||
| @@ -99,7 +135,19 @@ func (r *Renderer) Clear() { | |||||
| 99 | r.logViewportTop = 0 | 135 | r.logViewportTop = 0 | ||
| 100 | r.logHardwareRow = 0 | 136 | r.logHardwareRow = 0 | ||
| 101 | r.logInit = false | 137 | r.logInit = false | ||
| 102 | - | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+SeqClearScrollback+MoveTo(1, 1)) | 138 | + | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1)) |
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // clearScrollbackSeq returns the scrollback-clear escape, or the | |||
| 142 | + | // empty string when we are running under a terminal where emitting | |||
| 143 | + | // it has user-visible side effects (see Renderer.keepScrollback). | |||
| 144 | + | // Callers concatenate this into a larger control sequence; an empty | |||
| 145 | + | // return value is a no-op there. | |||
| 146 | + | func (r *Renderer) clearScrollbackSeq() string { | |||
| 147 | + | if r.keepScrollback { | |||
| 148 | + | return "" | |||
| 149 | + | } | |||
| 150 | + | return SeqClearScrollback | |||
| 103 | } | 151 | } | ||
| 104 | 152 | ||||
| 105 | // Invalidate forces a full repaint on the next Draw without clearing the | 153 | // Invalidate forces a full repaint on the next Draw without clearing the | ||
| @@ -342,7 +390,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 342 | if clear { | 390 | if clear { | ||
| 343 | w.WriteString(SeqDeleteKittyImages) | 391 | w.WriteString(SeqDeleteKittyImages) | ||
| 344 | w.WriteString(SeqClearScreenNoHome) | 392 | w.WriteString(SeqClearScreenNoHome) | ||
| 345 | - | w.WriteString(SeqClearScrollback) | 393 | + | w.WriteString(r.clearScrollbackSeq()) |
| 346 | w.WriteString(MoveTo(1, 1)) | 394 | w.WriteString(MoveTo(1, 1)) | ||
| 347 | } | 395 | } | ||
| 348 | for idx, line := range lines { | 396 | for idx, line := range lines { | ||