| 1 | package cli |
| 2 | |
| 3 | // scrollFollowMode is an explicit transcript tail-follow state machine. |
| 4 | // |
| 5 | // Previously the post-Update path used a single wasAtBottom snapshot taken |
| 6 | // before the message was applied. Opening an approval/chooser/todo overlay |
| 7 | // shrinks the viewport (bottomRows grows) without any user scroll, which made |
| 8 | // AtBottom() flip false and permanently disabled tail-follow until the user |
| 9 | // manually returned to the bottom (#6430). Long sessions with frequent |
| 10 | // height-changing panels then looked like "scroll is broken" (#6978). |
| 11 | // |
| 12 | // Rules: |
| 13 | // - Default is followTail: new content pins the viewport to the bottom. |
| 14 | // - Explicit user scroll away from the bottom → userScrolled (wheel-up, |
| 15 | // PgUp, scrollbar drag, Ctrl+Home, …). |
| 16 | // - Returning to the bottom (wheel/page to end, empty Enter, Ctrl+End, |
| 17 | // forceGotoBottom / session switch) restores followTail. |
| 18 | // - Modal overlays that only change bottomRows never flip the mode. |
| 19 | type scrollFollowMode uint8 |
| 20 | |
| 21 | const ( |
| 22 | scrollFollowTail scrollFollowMode = iota |
| 23 | scrollUserScrolled |
| 24 | ) |
| 25 | |
| 26 | // markUserScrolled records that the user intentionally left the live tail. |
| 27 | func (m *chatTUI) markUserScrolled() { |
| 28 | m.scrollMode = scrollUserScrolled |
| 29 | } |
| 30 | |
| 31 | // markFollowTail restores live tail-follow after the user (or a session |
| 32 | // switch) returns to the newest output. |
| 33 | func (m *chatTUI) markFollowTail() { |
| 34 | m.scrollMode = scrollFollowTail |
| 35 | } |
| 36 | |
| 37 | // shouldFollowTail reports whether new transcript content should pin the |
| 38 | // viewport to the bottom. forceGotoBottom always wins for one frame. |
| 39 | func (m chatTUI) shouldFollowTail() bool { |
| 40 | return m.scrollMode == scrollFollowTail || m.forceGotoBottom |
| 41 | } |
| 42 | |
| 43 | // modalOpen is true while a panel that steals bottom rows (or the whole main |
| 44 | // area) is active. Used so height-only layout changes never mark userScrolled. |
| 45 | func (m chatTUI) modalOpen() bool { |
| 46 | if m.pendingApproval != nil || m.chooser != nil || m.rewind != nil { |
| 47 | return true |
| 48 | } |
| 49 | if m.mcp != nil || m.clearConfirm != nil || m.mcpImport != nil { |
| 50 | return true |
| 51 | } |
| 52 | if m.skillPick != nil || m.resumePick != nil || m.quickPick != nil || m.copyPick != nil { |
| 53 | return true |
| 54 | } |
| 55 | return false |
| 56 | } |
| 57 | |
| 58 | // syncScrollModeAfterGesture updates follow state from the current viewport |
| 59 | // position after an intentional scroll command. Landing on the bottom restores |
| 60 | // followTail; any other offset marks userScrolled. |
| 61 | func (m *chatTUI) syncScrollModeAfterGesture() { |
| 62 | if m.viewport.AtBottom() { |
| 63 | m.markFollowTail() |
| 64 | return |
| 65 | } |
| 66 | m.markUserScrolled() |
| 67 | } |
| 68 |