Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Sources/SwiftTerm/Apple/AppleTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1925,6 +1925,16 @@ extension TerminalView {
public func scrollTo (row: Int, notifyAccessibility: Bool = true)
{
let displayBuffer = terminal.displayBuffer
// Drive the feed-follow gate from the real scroll position: parked above
// the bottom ⇒ userScrolling, so streaming output no longer yanks the
// viewport down (Terminal honors userScrolling in its scroll path); back
// at the bottom ⇒ resume following the tail. On macOS this was never set,
// so every feed forced yDisp = yBase — the long-standing "scrolls while
// I'm reading" bug. Typing routes through ensureCaretIsVisible →
// scrollTo(yBase), which clears it and snaps back to the prompt.
if !terminal.isCurrentBufferAlternate {
terminal.userScrolling = row < displayBuffer.yBase
}
if row != displayBuffer.yDisp {
terminal.setViewYDisp (row)

Expand Down
21 changes: 21 additions & 0 deletions Sources/SwiftTerm/SyncDebug.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// SyncDebug.swift
// A lightweight, no-op tracing hook for the synchronized-output / DEC-2026
// buffering path. The upstream "Simplifies the DEC 2026 buffering support"
// commit added `SyncDebug.log(...)` call sites in Terminal.swift and
// AppleTerminalView.swift but never committed the definition, which broke the
// build. This restores compilation; logging is off by default (flip `enabled`
// to trace the BSU/ESU/paint flow). `@autoclosure` keeps the message cost zero
// when disabled.
//

import Foundation

enum SyncDebug {
static let enabled = false

@inline(__always)
static func log(_ message: @autoclosure () -> String) {
if enabled { print("[sync] \(message())") }
}
}
55 changes: 55 additions & 0 deletions Tests/SwiftTermTests/TerminalFollowTailTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// TerminalFollowTailTests.swift
// Regression for the long-standing macOS "terminal scrolls to the bottom while
// I'm scrolled up reading" bug. Root cause: the macOS view never set
// Terminal.userScrolling, the only flag the feed consults, so every feed forced
// yDisp = yBase. Fix: scrollTo(row:) drives userScrolling from the scroll
// position. Typing routes through ensureCaretIsVisible → scrollTo(yBase), which
// clears it and resumes following.
//

import Foundation
import Testing

@testable import SwiftTerm

#if os(macOS)
@Suite struct TerminalFollowTailTests {

@Test func scrolledUpHoldsPositionWhileOutputStreams() {
let view = TerminalView(frame: CGRect(x: 0, y: 0, width: 240, height: 120))
let t = view.getTerminal()
for i in 0..<200 { t.feed(text: "line\(i)\r\n") }
let yBase0 = t.buffer.yBase
#expect(yBase0 > 20) // real scrollback accumulated

// User scrolls UP off the bottom.
let parked = yBase0 - 8
view.scrollTo(row: parked)
#expect(t.buffer.yDisp == parked)
#expect(t.userScrolling == true) // pre-fix: false

// Output streams in — the viewport must HOLD, not yank to the new bottom.
for i in 200..<220 { t.feed(text: "line\(i)\r\n") }
#expect(t.buffer.yDisp == parked) // pre-fix: == new yBase (the bug)

// Typing returns to the bottom and resumes following the tail.
view.send(data: Array("x".utf8)[...]) // → ensureCaretIsVisible → scrollTo(yBase)
#expect(t.userScrolling == false)
let yBase1 = t.buffer.yBase
t.feed(text: "tail\r\n")
#expect(t.buffer.yDisp == t.buffer.yBase) // followed the tail again
#expect(t.buffer.yBase == yBase1 + 1)
}

@Test func atBottomFollowsTailNormally() {
let view = TerminalView(frame: CGRect(x: 0, y: 0, width: 240, height: 120))
let t = view.getTerminal()
for i in 0..<50 { t.feed(text: "row\(i)\r\n") }
#expect(t.buffer.yDisp == t.buffer.yBase) // at the bottom
#expect(t.userScrolling == false)
t.feed(text: "more\r\n")
#expect(t.buffer.yDisp == t.buffer.yBase) // still following
}
}
#endif