Fix focus-on-scroll and harden reload scripts

This commit is contained in:
Lawrence Chen 2026-01-30 16:15:46 -08:00
parent 46eefc733e
commit 4b77b1117d
6 changed files with 240 additions and 38 deletions

View file

@ -113,6 +113,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
@objc func clearUpdatePillOverride(_ sender: Any?) {
updateViewModel.overrideState = nil
}
#endif
@objc func copyUpdateLogs(_ sender: Any?) {
let logText = UpdateLogStore.shared.snapshot()
@ -126,7 +127,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
#endif
@objc func copyFocusLogs(_ sender: Any?) {
let logText = FocusLogStore.shared.snapshot()
let payload: String
if logText.isEmpty {
payload = "No focus logs captured.\nLog file: \(FocusLogStore.shared.logPath())"
} else {
payload = logText + "\nLog file: \(FocusLogStore.shared.logPath())"
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
#if DEBUG
@objc func openDebugScrollbackTab(_ sender: Any?) {

View file

@ -5,6 +5,7 @@ import Metal
import QuartzCore
import Combine
import Darwin
import Sentry
private enum GhosttyPasteboardHelper {
private static let selectionPasteboard = NSPasteboard(
@ -89,6 +90,64 @@ class GhosttyApp {
private var displayLinkUsers = 0
private let displayLinkLock = NSLock()
// Scroll lag tracking
private(set) var isScrolling = false
private var scrollLagSampleCount = 0
private var scrollLagTotalMs: Double = 0
private var scrollLagMaxMs: Double = 0
private let scrollLagThresholdMs: Double = 25 // Alert if tick takes >25ms during scroll
private var scrollEndTimer: DispatchWorkItem?
func markScrollActivity(hasMomentum: Bool, momentumEnded: Bool) {
// Cancel any pending scroll-end timer
scrollEndTimer?.cancel()
scrollEndTimer = nil
if momentumEnded {
// Trackpad momentum ended - scrolling is done
endScrollSession()
} else if hasMomentum {
// Trackpad scrolling with momentum - wait for momentum to end
isScrolling = true
} else {
// Mouse wheel or non-momentum scroll - use timeout
isScrolling = true
let timer = DispatchWorkItem { [weak self] in
self?.endScrollSession()
}
scrollEndTimer = timer
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: timer)
}
}
private func endScrollSession() {
guard isScrolling else { return }
isScrolling = false
// Report accumulated lag stats if any exceeded threshold
if scrollLagSampleCount > 0 {
let avgLag = scrollLagTotalMs / Double(scrollLagSampleCount)
let maxLag = scrollLagMaxMs
let samples = scrollLagSampleCount
let threshold = scrollLagThresholdMs
if maxLag > threshold {
SentrySDK.capture(message: "Scroll lag detected") { scope in
scope.setLevel(.warning)
scope.setContext(value: [
"samples": samples,
"avg_ms": String(format: "%.2f", avgLag),
"max_ms": String(format: "%.2f", maxLag),
"threshold_ms": threshold
], key: "scroll_lag")
}
}
// Reset stats
scrollLagSampleCount = 0
scrollLagTotalMs = 0
scrollLagMaxMs = 0
}
}
private init() {
initializeGhostty()
}
@ -232,8 +291,18 @@ class GhosttyApp {
func tick() {
guard let app = app else { return }
let start = CACurrentMediaTime()
ghostty_app_tick(app)
AppDelegate.shared?.tabManager?.tickRender()
let elapsedMs = (CACurrentMediaTime() - start) * 1000
// Track lag during scrolling
if isScrolling {
scrollLagSampleCount += 1
scrollLagTotalMs += elapsedMs
scrollLagMaxMs = max(scrollLagMaxMs, elapsedMs)
}
}
func retainDisplayLink() {
@ -696,14 +765,8 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
private func scaleFactors(for view: GhosttyNSView) -> (x: CGFloat, y: CGFloat, layer: CGFloat) {
let layerScale = view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0
guard view.bounds.width > 0 && view.bounds.height > 0 else {
return (layerScale, layerScale, layerScale)
}
let backingBounds = view.convertToBacking(view.bounds)
let xScale = backingBounds.width / view.bounds.width
let yScale = backingBounds.height / view.bounds.height
return (xScale, yScale, layerScale)
let layerScale = view.window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0
return (layerScale, layerScale, layerScale)
}
func attachToView(_ view: GhosttyNSView) {
@ -907,6 +970,18 @@ final class TerminalSurface: Identifiable, ObservableObject {
// MARK: - Ghostty Surface View
class GhosttyNSView: NSView, NSUserInterfaceValidations {
private static let focusDebugEnabled: Bool = {
if ProcessInfo.processInfo.environment["CMUX_FOCUS_DEBUG"] == "1" {
return true
}
return UserDefaults.standard.bool(forKey: "cmuxFocusDebug")
}()
fileprivate static func focusLog(_ message: String) {
guard focusDebugEnabled else { return }
FocusLogStore.shared.append(message)
NSLog("[FOCUSDBG] %@", message)
}
weak var terminalSurface: TerminalSurface?
private var surfaceAttached = false
var scrollbar: GhosttyScrollbar?
@ -921,6 +996,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private var eventMonitor: Any?
private var trackingArea: NSTrackingArea?
private var windowObserver: NSObjectProtocol?
private var lastSurfaceSize: CGSize = .zero
private var lastContentScale: CGSize = .zero
private var lastLayerScale: CGFloat = 0
private var hasSurfaceMetrics = false
private var lastScrollEventTime: CFTimeInterval = 0
override func makeBackingLayer() -> CALayer {
let metalLayer = CAMetalLayer()
@ -1003,10 +1083,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
let location = convert(event.locationInWindow, from: nil)
guard hitTest(location) == self else { return event }
if window.firstResponder !== self {
window.makeFirstResponder(self)
}
Self.focusLog("localEventScrollWheel: window=\(ObjectIdentifier(window)) firstResponder=\(String(describing: window.firstResponder))")
return event
}
@ -1014,6 +1091,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
terminalSurface = surface
tabId = surface.tabId
surfaceAttached = false
hasSurfaceMetrics = false
attachSurfaceIfNeeded()
}
@ -1076,10 +1154,23 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private func updateSurfaceSize() {
guard let terminalSurface = terminalSurface else { return }
guard bounds.width > 0 && bounds.height > 0 else { return }
let backingBounds = convertToBacking(bounds)
let xScale = backingBounds.width / bounds.width
let yScale = backingBounds.height / bounds.height
let layerScale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0
let layerScale = window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0
let xScale = layerScale
let yScale = layerScale
if hasSurfaceMetrics {
let sameSize = nearlyEqual(lastSurfaceSize.width, bounds.width, epsilon: 0.01)
&& nearlyEqual(lastSurfaceSize.height, bounds.height, epsilon: 0.01)
let sameScale = nearlyEqual(lastContentScale.width, xScale)
&& nearlyEqual(lastContentScale.height, yScale)
&& nearlyEqual(lastLayerScale, layerScale)
if sameSize && sameScale {
return
}
}
lastSurfaceSize = bounds.size
lastContentScale = CGSize(width: xScale, height: yScale)
lastLayerScale = layerScale
hasSurfaceMetrics = true
terminalSurface.updateSize(
width: bounds.width,
height: bounds.height,
@ -1089,6 +1180,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
)
}
private func nearlyEqual(_ lhs: CGFloat, _ rhs: CGFloat, epsilon: CGFloat = 0.0001) -> Bool {
abs(lhs - rhs) <= epsilon
}
// Convenience accessor for the ghostty surface
private var surface: ghostty_surface_t? {
terminalSurface?.surface
@ -1132,6 +1227,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
if result, let surface = surface {
let now = CACurrentMediaTime()
let deltaMs = (now - lastScrollEventTime) * 1000
Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))")
onFocus?()
#if DEBUG
if let terminalSurface {
@ -1603,7 +1701,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
override func scrollWheel(with event: NSEvent) {
guard let surface = surface else { return }
terminalSurface?.setFocus(true)
lastScrollEventTime = CACurrentMediaTime()
Self.focusLog("scrollWheel: surface=\(terminalSurface?.id.uuidString ?? "nil") firstResponder=\(String(describing: window?.firstResponder))")
var x = event.scrollingDeltaX
var y = event.scrollingDeltaY
let precision = event.hasPreciseScrollingDeltas
@ -1636,6 +1735,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
mods |= momentum << 1
// Track scroll state for lag detection
let hasMomentum = event.momentumPhase != [] && event.momentumPhase != .mayBegin
let momentumEnded = event.momentumPhase == .ended || event.momentumPhase == .cancelled
GhosttyApp.shared.markScrollActivity(hasMomentum: hasMomentum, momentumEnded: momentumEnded)
ghostty_surface_mouse_scroll(
surface,
x,
@ -1735,14 +1839,15 @@ private final class GhosttyScrollView: NSScrollView {
return
}
if window?.firstResponder !== surfaceView {
window?.makeFirstResponder(surfaceView)
}
if let surface = surfaceView.terminalSurface?.surface,
ghostty_surface_mouse_captured(surface) {
GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: mouseCaptured -> surface scroll")
if window?.firstResponder !== surfaceView {
window?.makeFirstResponder(surfaceView)
}
surfaceView.scrollWheel(with: event)
} else {
GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: super scroll")
super.scrollWheel(with: event)
}
}
@ -1897,17 +2002,8 @@ final class GhosttySurfaceScrollView: NSView {
override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero }
override var acceptsFirstResponder: Bool { true }
override func becomeFirstResponder() -> Bool {
window?.makeFirstResponder(surfaceView)
return true
}
override func resignFirstResponder() -> Bool {
_ = surfaceView.resignFirstResponder()
return true
}
// Avoid stealing focus on scroll; focus is managed explicitly by the surface view.
override var acceptsFirstResponder: Bool { false }
override func layout() {
super.layout()

View file

@ -63,3 +63,66 @@ final class UpdateLogStore {
}
}
}
final class FocusLogStore {
static let shared = FocusLogStore()
private let queue = DispatchQueue(label: "cmuxterm.focus.log")
private var entries: [String] = []
private let maxEntries = 400
private let logURL: URL
private let formatter: ISO8601DateFormatter
private init() {
formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let logsDir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
logURL = logsDir.appendingPathComponent("Logs/cmuxterm-focus.log")
ensureLogFile()
}
func append(_ message: String) {
#if DEBUG
let timestamp = formatter.string(from: Date())
let line = "[\(timestamp)] \(message)"
queue.async { [weak self] in
guard let self else { return }
entries.append(line)
if entries.count > maxEntries {
entries.removeFirst(entries.count - maxEntries)
}
appendToFile(line: line)
}
#endif
}
func snapshot() -> String {
queue.sync {
entries.joined(separator: "\n")
}
}
func logPath() -> String {
logURL.path
}
private func ensureLogFile() {
let directory = logURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
if !FileManager.default.fileExists(atPath: logURL.path) {
try? Data().write(to: logURL)
}
}
private func appendToFile(line: String) {
let data = Data((line + "\n").utf8)
if let handle = try? FileHandle(forWritingTo: logURL) {
try? handle.seekToEnd()
try? handle.write(contentsOf: data)
try? handle.close()
} else {
try? data.write(to: logURL, options: .atomic)
}
}
}

View file

@ -138,13 +138,16 @@ struct cmuxApp: App {
appDelegate.clearUpdatePillOverride(nil)
}
}
#endif
CommandMenu("Update Logs") {
Button("Copy Update Logs") {
appDelegate.copyUpdateLogs(nil)
}
Button("Copy Focus Logs") {
appDelegate.copyFocusLogs(nil)
}
}
#endif
#if DEBUG
CommandMenu("Debug") {
@ -258,6 +261,16 @@ struct cmuxApp: App {
}
.keyboardShortcut("[", modifiers: [.command, .shift])
Button("Back") {
tabManager.navigateBack()
}
.keyboardShortcut("[", modifiers: .command)
Button("Forward") {
tabManager.navigateForward()
}
.keyboardShortcut("]", modifiers: .command)
Button("Next Tab") {
tabManager.selectNextTab()
}

View file

@ -1,10 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
APP_PATH="/Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Debug/cmuxterm DEV.app"
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build
pkill -x "cmuxterm DEV" || true
sleep 0.2
APP_PATH="$(
find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Debug/cmuxterm DEV.app" -print0 \
| xargs -0 /usr/bin/stat -f "%m %N" 2>/dev/null \
| sort -nr \
| head -n 1 \
| cut -d' ' -f2-
)"
if [[ -z "${APP_PATH}" ]]; then
echo "cmuxterm DEV.app not found in DerivedData" >&2
exit 1
fi
open "$APP_PATH"
osascript -e 'tell application "cmuxterm DEV" to activate' || true

View file

@ -1,10 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
APP_PATH="/Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Release/cmuxterm.app"
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Release -destination 'platform=macOS' build
pkill -x cmuxterm || true
sleep 0.2
APP_PATH="$(
find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Release/cmuxterm.app" -print0 \
| xargs -0 /usr/bin/stat -f "%m %N" 2>/dev/null \
| sort -nr \
| head -n 1 \
| cut -d' ' -f2-
)"
if [[ -z "${APP_PATH}" ]]; then
echo "cmuxterm.app not found in DerivedData" >&2
exit 1
fi
open "$APP_PATH"
osascript -e 'tell application "cmuxterm" to activate' || true