Skip to content

fix(renderer): eagerly erase the previous frame on resize in inline mode#1696

Open
TaraTheStar wants to merge 1 commit into
charmbracelet:mainfrom
TaraTheStar:fix/eager-erase-on-resize
Open

fix(renderer): eagerly erase the previous frame on resize in inline mode#1696
TaraTheStar wants to merge 1 commit into
charmbracelet:mainfrom
TaraTheStar:fix/eager-erase-on-resize

Conversation

@TaraTheStar
Copy link
Copy Markdown

@TaraTheStar TaraTheStar commented May 11, 2026

After tea.Println, subsequent terminal resizes can leave the previous live region stranded above the new one and accumulate copies of stale frames in scrollback on each resize event.

insertAbove ends with s.scr.SetPosition(0, 0) to declare the live region's new anchor. resize then only sets ultraviolet's deferred clear flag; the physical erase is deferred to the next Render, which takes the clearUpdate path and emits move(newbuf, 0, 0) followed by EraseScreenBelow. With s.cur == (0, 0), move() short-circuits to zero bytes (see ultraviolet/terminal_renderer.go:439), so ED-0 fires at whatever the physical cursor's position actually is — not necessarily the renderer's logical anchor — and the old frame above is never erased.

Fix: in cursedRenderer.resize, before flipping the deferred clear, write \r + CUU(s.cur.Y) + ED-0 directly to the writer (matching insertAbove's direct-write pattern), then set s.cur = (-1, -1) so moveCursor's first-move safety (terminal_renderer.go:362-368) emits a defensive \r on the next cursor move. Alt-screen is guarded out — it uses absolute positioning and its own clear contract.

Reproducer:

package main

import (
    "fmt"
    "strings"
    tea "charm.land/bubbletea/v2"
)

type m struct{ w, h, n int }

func (m m) Init() tea.Cmd { return nil }
func (m m) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
            m.w, m.h = msg.Width, msg.Height
    case tea.KeyPressMsg:
            switch msg.String() {
            case "q":
                    return m, tea.Quit
            case "p":
                    m.n++
                    return m, tea.Println(fmt.Sprintf("--- println #%d ---", m.n))
            }
    }
    return m, nil
}
func (m m) View() tea.View {
    rows := make([]string, 5)
    for i := range rows { rows[i] = strings.Repeat("=", 30) }
    return tea.NewView(strings.Join(rows, "\n") +
            fmt.Sprintf("\nstatus: %dx%d n=%d\n> _", m.w, m.h, m.n))
}
func main() { tea.NewProgram(m{}).Run() }

Run, press p, then drag-resize the terminal. Pre-fix: old frames stack in scrollback. Post-fix: clean.

A note on reproducibility: I could not reproduce the visual artifact on a Linux PTY with xterm-256color; the wire bytes happen to align there. The mechanism is unambiguous from the source, and the bug has been reported in adjacent contexts (e.g. anthropics/claude-code#49086). Reviewers on iTerm2 / Terminal.app / Windows Terminal
would be best positioned to confirm the visual fix.

When tea.Println triggers insertAbove, the cursed renderer resets its
tracked cursor position to (0, 0). The next WindowSizeMsg only flips
ultraviolet's deferred clear flag; the physical erase happens later
inside Render's clearUpdate path, which calls move(newbuf, 0, 0) before
EraseScreenBelow. Because move() short-circuits to a zero-byte no-op
when source and target are both (0, 0), ED-0 fires at whatever the
physical cursor's position happens to be — which can differ from the
renderer's tracked anchor after terminal reflow. The previous frame is
then stranded above the new one and accumulates in scrollback on
subsequent resizes.

Eagerly emit \r + CUU(y) + ED-0 at the top of resize() using the
renderer's tracked cursor, then invalidate the cursor sentinel so the
next moveCursor call emits its first-move \r. Guarded on inline mode.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant