返回 DeepSeek-Reasonix
scroll_state.go
根目录 / internal / cli / scroll_state.go
1 package cli
2
3 import "strings"
4
5 // scrollFollowMode is an explicit transcript tail-follow state machine.
6 //
7 // Previously the post-Update path used a single wasAtBottom snapshot taken
8 // before the message was applied. Opening an approval/chooser/todo overlay
9 // shrinks the viewport (bottomRows grows) without any user scroll, which made
10 // AtBottom() flip false and permanently disabled tail-follow until the user
11 // manually returned to the bottom (#6430). Long sessions with frequent
12 // height-changing panels then looked like "scroll is broken" (#6978).
13 //
14 // Rules:
15 // - Default is followTail: new content pins the viewport to the bottom.
16 // - Explicit user scroll away from the bottom → userScrolled (wheel-up,
17 // PgUp, scrollbar drag, Ctrl+Home, …).
18 // - Returning to the bottom (wheel/page to end, empty Enter, Ctrl+End,
19 // forceGotoBottom / session switch) restores followTail.
20 // - Modal overlays that only change bottomRows never flip the mode.
21 type scrollFollowMode uint8
22
23 const (
24 scrollFollowTail scrollFollowMode = iota
25 scrollUserScrolled
26 )
27
28 // markUserScrolled records that the user intentionally left the live tail.
29 func (m *chatTUI) markUserScrolled() {
30 m.scrollMode = scrollUserScrolled
31 }
32
33 // markFollowTail restores live tail-follow after the user (or a session
34 // switch) returns to the newest output.
35 func (m *chatTUI) markFollowTail() {
36 m.scrollMode = scrollFollowTail
37 }
38
39 // shouldFollowTail reports whether new transcript content should pin the
40 // viewport to the bottom. forceGotoBottom always wins for one frame.
41 func (m chatTUI) shouldFollowTail() bool {
42 return m.scrollMode == scrollFollowTail || m.forceGotoBottom
43 }
44
45 // modalOpen is true while a panel that steals bottom rows (or the whole main
46 // area) is active. Used so height-only layout changes never mark userScrolled.
47 func (m chatTUI) modalOpen() bool {
48 if m.pendingApproval != nil || m.chooser != nil || m.rewind != nil {
49 return true
50 }
51 if m.mcp != nil || m.clearConfirm != nil || m.mcpImport != nil {
52 return true
53 }
54 if m.skillPick != nil || m.resumePick != nil || m.quickPick != nil || m.copyPick != nil {
55 return true
56 }
57 return false
58 }
59
60 // syncScrollModeAfterGesture updates follow state from the current viewport
61 // position after an intentional scroll command. Landing on the bottom restores
62 // followTail; any other offset marks userScrolled.
63 func (m *chatTUI) syncScrollModeAfterGesture() {
64 if m.viewport.AtBottom() {
65 m.markFollowTail()
66 return
67 }
68 m.markUserScrolled()
69 }
70
71 // useLegacyViewportScrollClear limits the application-level redraw workaround
72 // to Warp, where Bubble Tea's scroll optimization can strand stale rows. It is
73 // disabled on Windows even if Warp identifies itself there because Bubble Tea
74 // already avoids the incompatible optimization on that platform, and an extra
75 // asynchronous ClearScreen visibly flickers (#8090).
76 func useLegacyViewportScrollClear(goos string, environ []string) bool {
77 if goos == "windows" {
78 return false
79 }
80 for _, entry := range environ {
81 key, value, ok := strings.Cut(entry, "=")
82 if ok && key == "TERM_PROGRAM" && strings.EqualFold(strings.TrimSpace(value), "WarpTerminal") {
83 return true
84 }
85 }
86 return false
87 }
88
88 lines GO