Merge pull request #8156 from SivanCola/fix/tui-scroll-renderer-boundary

Limit legacy TUI scroll clears to Warp / 仅在 Warp 保留 TUI 滚动清屏
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-10 14:51:27 +08:00
commit b8a37d4ba2
4 files changed
+65 -33

No files matched your search

+7 -7
View File
@@ -117,9 +117,9 @@ type chatTUI struct {
// marker rides in outgoing user messages so the cache-stable prompt prefix is
// left untouched.
planMode bool
// sessionSwitch is set by replayActiveBranch to suppress the ClearScreen
// flicker when the viewport content is completely rebuilt during a session
// switch (#5441). Cleared after one Update cycle.
// legacyScrollClear keeps the per-offset ClearScreen workaround only for Warp.
legacyScrollClear bool
// sessionSwitch suppresses that workaround during a transcript rebuild (#5441).
sessionSwitch bool
// yoloRestoreToolApprovalMode remembers the Ask/Auto base mode that Ctrl+Y
// should restore after a desktop-style YOLO toggle.
@@ -628,6 +628,7 @@ func newChatTUI(ctrl control.SessionAPI, missing string, eventCh chan event.Even
modelRef: ctrl.ModelRef(),
missing: missing,
nativeScrollback: nativeScrollback,
legacyScrollClear: useLegacyViewportScrollClear(runtime.GOOS, os.Environ()),
mouseCaptureOff: mouseCaptureOffByDefault(),
input: ti,
spinner: sp,
@@ -976,10 +977,9 @@ func (m chatTUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
// Keep the legacy full redraw where Warp's scroll optimization can strand
// stale rows. Bubble Tea already disables that optimization on Windows,
// where an extra asynchronous ClearScreen visibly flickers (#8090).
if useLegacyViewportScrollClear(runtime.GOOS) && cm.viewport.YOffset() != prevYOff && !cm.nativeScrollback && !cm.sessionSwitch {
// Keep the legacy full redraw only where Warp's scroll optimization can
// strand stale rows. Every other terminal relies on Bubble Tea's renderer.
if cm.legacyScrollClear && cm.viewport.YOffset() != prevYOff && !cm.nativeScrollback && !cm.sessionSwitch {
cm.sessionSwitch = false
return cm, batchCmds(tea.ClearScreen, mouseCmd, cmd)
}
+13 -13
View File
@@ -1540,10 +1540,14 @@ func TestMouseWheelAndPageKeysScrollTranscript(t *testing.T) {
ch := make(chan event.Event, 1)
notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
adv := func(m chatTUI, msg tea.Msg) chatTUI {
n, _ := m.Update(msg)
n, cmd := m.Update(msg)
_, wheel := msg.(tea.MouseWheelMsg)
_, key := msg.(tea.KeyPressMsg)
if cmd != nil && (wheel || key) {
t.Fatalf("viewport update %T should rely on the renderer diff, got command %T", msg, cmd)
}
return n.(chatTUI)
}
cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 10})
for range 40 {
cur = adv(cur, notice)
@@ -1555,23 +1559,20 @@ func TestMouseWheelAndPageKeysScrollTranscript(t *testing.T) {
if bottom <= cur.viewport.Height()+3 {
t.Fatalf("test transcript did not overflow enough: bottom=%d height=%d", bottom, cur.viewport.Height())
}
cur.legacyScrollClear = false
cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
if got, want := cur.viewport.YOffset(), bottom-3; got != want {
t.Fatalf("wheel-up YOffset = %d, want %d", got, want)
}
cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelDown})
if got := cur.viewport.YOffset(); got != bottom {
t.Fatalf("wheel-down should return by one wheel step, YOffset=%d want bottom=%d", got, bottom)
}
cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyPgUp})
pageUp := cur.viewport.YOffset()
if got, want := pageUp, bottom-cur.viewport.Height(); got != want {
t.Fatalf("PageUp YOffset = %d, want %d", got, want)
}
cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyPgDown})
if got := cur.viewport.YOffset(); got != bottom {
t.Fatalf("PageDown should return to bottom from one page up, YOffset=%d want %d", got, bottom)
@@ -3103,7 +3104,6 @@ func TestForceGotoBottomScrollsWithoutTranscriptChange(t *testing.T) {
n, _ := adv(m, msg)
return n
}
cur := next(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8})
for range 12 {
cur = next(cur, notice)
@@ -3119,6 +3119,7 @@ func TestForceGotoBottomScrollsWithoutTranscriptChange(t *testing.T) {
cur.forceGotoBottom = true
cur.transcriptDirty = false
cur.legacyScrollClear = false
cur, cmd := adv(cur, tea.WindowSizeMsg{Width: 80, Height: 8})
if !cur.viewport.AtBottom() {
@@ -3127,10 +3128,10 @@ func TestForceGotoBottomScrollsWithoutTranscriptChange(t *testing.T) {
if cur.forceGotoBottom {
t.Fatal("forceGotoBottom should be cleared after scrolling")
}
assertLegacyViewportClearCmd(t, cmd)
assertLegacyViewportClearCmd(t, cmd, false)
}
func TestSessionSwitchSuppressesOneClearScreen(t *testing.T) {
func TestSessionSwitchSuppressesOneWarpClearScreen(t *testing.T) {
ctrl := control.New(control.Options{})
ch := make(chan event.Event, 1)
notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
@@ -3151,14 +3152,13 @@ func TestSessionSwitchSuppressesOneClearScreen(t *testing.T) {
if cur.viewport.AtBottom() {
t.Fatal("wheel-up should break the bottom pin")
}
cur.legacyScrollClear = true
cur.sessionSwitch = true
cur.forceGotoBottom = true
cur.transcriptDirty = false
cur, cmd := adv(cur, tea.WindowSizeMsg{Width: 80, Height: 8})
if cmd != nil {
t.Fatal("session switch rebuild should suppress the ClearScreen scroll-jump workaround once")
t.Fatal("session switch rebuild should suppress the Warp ClearScreen workaround once")
}
if cur.sessionSwitch {
t.Fatal("sessionSwitch should be cleared after one Update")
@@ -3170,7 +3170,7 @@ func TestSessionSwitchSuppressesOneClearScreen(t *testing.T) {
cur = next(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
cur.forceGotoBottom = true
cur, cmd = adv(cur, tea.WindowSizeMsg{Width: 80, Height: 8})
assertLegacyViewportClearCmd(t, cmd)
assertLegacyViewportClearCmd(t, cmd, true)
if cur.sessionSwitch {
t.Fatal("sessionSwitch should remain false after the suppressed cycle")
}
+19 -3
View File
@@ -1,5 +1,7 @@
package cli
import "strings"
// scrollFollowMode is an explicit transcript tail-follow state machine.
//
// Previously the post-Update path used a single wasAtBottom snapshot taken
@@ -66,6 +68,20 @@ func (m *chatTUI) syncScrollModeAfterGesture() {
m.markUserScrolled()
}
// useLegacyViewportScrollClear excludes Windows, where Bubble Tea already
// disables the scroll optimization and an extra ClearScreen flickers (#8090).
func useLegacyViewportScrollClear(goos string) bool { return goos != "windows" }
// useLegacyViewportScrollClear limits the application-level redraw workaround
// to Warp, where Bubble Tea's scroll optimization can strand stale rows. It is
// disabled on Windows even if Warp identifies itself there because Bubble Tea
// already avoids the incompatible optimization on that platform, and an extra
// asynchronous ClearScreen visibly flickers (#8090).
func useLegacyViewportScrollClear(goos string, environ []string) bool {
if goos == "windows" {
return false
}
for _, entry := range environ {
key, value, ok := strings.Cut(entry, "=")
if ok && key == "TERM_PROGRAM" && strings.EqualFold(strings.TrimSpace(value), "WarpTerminal") {
return true
}
}
return false
}
+26 -10
View File
@@ -2,7 +2,6 @@ package cli
import (
"fmt"
"runtime"
"strings"
"testing"
"time"
@@ -15,19 +14,36 @@ import (
func TestUseLegacyViewportScrollClear(t *testing.T) {
for _, tt := range []struct {
goos string
want bool
}{{"windows", false}, {"linux", true}, {"darwin", true}} {
if got := useLegacyViewportScrollClear(tt.goos); got != tt.want {
t.Errorf("useLegacyViewportScrollClear(%q) = %v, want %v", tt.goos, got, tt.want)
}
name string
goos string
environ []string
want bool
}{
{name: "windows", goos: "windows", environ: []string{"TERM_PROGRAM=Windows_Terminal"}},
{name: "windows Warp", goos: "windows", environ: []string{"TERM_PROGRAM=WarpTerminal"}},
{name: "macOS Warp", goos: "darwin", environ: []string{"TERM_PROGRAM=WarpTerminal"}, want: true},
{name: "Linux Warp case and whitespace", goos: "linux", environ: []string{"TERM_PROGRAM= warPterminal "}, want: true},
{name: "SSH forwarded Warp", goos: "linux", environ: []string{"SSH_CONNECTION=client", "TERM_PROGRAM=WarpTerminal"}, want: true},
{name: "Apple Terminal", goos: "darwin", environ: []string{"TERM_PROGRAM=Apple_Terminal"}},
{name: "iTerm", goos: "darwin", environ: []string{"TERM_PROGRAM=iTerm.app"}},
{name: "Ghostty", goos: "darwin", environ: []string{"TERM_PROGRAM=ghostty"}},
{name: "Kitty", goos: "linux", environ: []string{"TERM=xterm-kitty"}},
{name: "empty environment", goos: "linux"},
{name: "lookalike variable", goos: "linux", environ: []string{"OTHER_TERM_PROGRAM=WarpTerminal"}},
} {
t.Run(tt.name, func(t *testing.T) {
got := useLegacyViewportScrollClear(tt.goos, tt.environ)
if got != tt.want {
t.Fatalf("useLegacyViewportScrollClear(%q, %q) = %v, want %v", tt.goos, tt.environ, got, tt.want)
}
})
}
}
func assertLegacyViewportClearCmd(t *testing.T, cmd tea.Cmd) {
func assertLegacyViewportClearCmd(t *testing.T, cmd tea.Cmd, want bool) {
t.Helper()
if got, want := cmd != nil, useLegacyViewportScrollClear(runtime.GOOS); got != want {
t.Fatalf("viewport ClearScreen command = %v, want %v on %s", got, want, runtime.GOOS)
if got := cmd != nil; got != want {
t.Fatalf("viewport ClearScreen command = %v, want %v", got, want)
}
}