Preserving Scroll Position While Terminal Output Keeps Arriving
When you’re scrolled up reading earlier output in a terminal and new output arrives, two behaviors are possible: the viewport stays where you are (preserving your reading position), or the viewport jumps to the bottom (following live output). TermOnMac preserves the position when the user is scrolled up, and follows when at the bottom.
userIsScrolledUp Flag
SwiftTerm exposes a userIsScrolledUp boolean that’s true when the viewport is not at the bottom of the buffer:
// ios_remote_dev_ios_app/RemoteDevApp/Views/TerminalView.swift
connection.onTerminalReset = { [weak tv] sessionId in
DispatchQueue.main.async {
guard let tv else { return }
if tv.userIsScrolledUp {
tv.prepareForReplayWhileScrolledUp()
}
tv.clearPendingData()
tv.getTerminal().resetToInitialState()
tv.userIsScrolledUp = false
tv.forceContentSizeRefresh()
}
}
When the connection requests a terminal reset (typically before replay), the code checks userIsScrolledUp and calls prepareForReplayWhileScrolledUp() if the user was scrolled up. This saves state needed to restore the position after the replay completes.
Saved Scroll Ratios in SessionStore
Sessions can save their scroll ratio for restoration after a view is destroyed and recreated:
// ios_remote_dev_ios_app/RemoteDevApp/Models/SessionStore.swift
/// Saved scroll ratios for sessions where the user was scrolled up when the
/// terminal view was destroyed (e.g. during reconnect screen transition).
/// Consumed by `onTerminalReset` to restore position after replay.
var savedScrollRatios: [String: CGFloat] = [:]
A scroll ratio (0.0 = top, 1.0 = bottom) is more meaningful than an absolute pixel offset across view recreations: the buffer might have grown or shrunk during reconnect, but the relative position the user was reading is still meaningful.
Why Reset Before Replay
The terminal reset sequence:
prepareForReplayWhileScrolledUp()— preserve scroll state if neededclearPendingData()— discard any data queued but not yet renderedresetToInitialState()— clear the terminal buffer entirely- Replay data is fed in
- Scroll position is restored
The reset is necessary because after a long disconnect, the iPhone’s view of the terminal is stale. The Mac has accumulated new output in the ring buffer. Trying to merge incremental output into the existing buffer would produce inconsistent state — the cursor position, line counts, and scroll history would all be wrong.
A clean slate followed by a full replay produces a guaranteed-consistent terminal state. The replay includes everything currently in the Mac’s ring buffer, so the user sees exactly what would be visible if they were never disconnected.
clearPendingData
clearPendingData() discards any data that was queued for rendering but not yet drawn. SwiftTerm batches rendering for performance — the call ensures the buffer is fully drained before reset:
tv.clearPendingData()
tv.getTerminal().resetToInitialState()
Without clearPendingData, the reset might happen while a frame of pending data is still in flight, producing visible artifacts.
forceContentSizeRefresh
After reset, forceContentSizeRefresh() updates the scroll view’s content size to match the new buffer state. This is necessary because:
- The terminal buffer is now empty (cleared by reset)
- The scroll view’s
contentSizewas previously sized for the old buffer - Without an explicit refresh, the scroll view might allow scrolling past the actual content
Local Scroll Shortcuts
The shortcut toolbar includes “local scroll” actions that scroll the terminal view itself, not the remote shell:
case .localScroll(let direction):
performLocalScroll(direction)
These bypass the sendInput path. Sending Ctrl-B or Page Up to the shell wouldn’t scroll the terminal — it would either be interpreted by the shell (Ctrl-B in vim moves backward) or ignored. Local scroll buttons manipulate the SwiftTerm view directly without sending anything to the Mac.