From 7e1517a5b1d85d61adb41b1e68fff8f7dcb06e66 Mon Sep 17 00:00:00 2001 From: Evert Junior Date: Tue, 19 May 2026 19:28:34 -0300 Subject: [PATCH 1/2] Render local input responses immediately When output arrives within 150ms of a keystroke and the terminal isn't inside a DEC 2026 sync block, paint synchronously on the main runloop instead of going through queuePendingDisplay's 16.67ms frame throttle. Without this, input echo waits one 60Hz tick. When output bursts coincide with typing, pendingDisplay stays armed and subsequent keystrokes are coalesced into the next tick, producing a perceptible "skipped characters appear together" feel. Complementary to the recent sync-output simplification on main. --- .../SwiftTerm/Apple/AppleTerminalView.swift | 25 ++++++++++++++++++- Sources/SwiftTerm/Mac/MacTerminalView.swift | 4 +++ Sources/SwiftTerm/iOS/iOSTerminalView.swift | 4 +++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftTerm/Apple/AppleTerminalView.swift b/Sources/SwiftTerm/Apple/AppleTerminalView.swift index 2cfbda2f3..9499eda70 100644 --- a/Sources/SwiftTerm/Apple/AppleTerminalView.swift +++ b/Sources/SwiftTerm/Apple/AppleTerminalView.swift @@ -1989,9 +1989,31 @@ extension TerminalView { { SyncDebug.log("feedFinish sync=\(terminal.synchronizedOutputActive)") suspendDisplayUpdates () + if shouldDisplayImmediatelyAfterUserInput() { + displayImmediately() + return + } queuePendingDisplay() } - + + private func shouldDisplayImmediatelyAfterUserInput() -> Bool { + guard !terminal.synchronizedOutputActive else { return false } + guard lastUserInputUptimeNs > 0 else { return false } + let now = DispatchTime.now().uptimeNanoseconds + guard now >= lastUserInputUptimeNs else { return false } + return now - lastUserInputUptimeNs <= interactiveInputDisplayWindowNs + } + + private func displayImmediately() { + guard !Thread.isMainThread else { + updateDisplay() + return + } + DispatchQueue.main.async { [weak self] in + self?.updateDisplay() + } + } + /// Sends data to the terminal emulator for interpretation, this can be invoked from a background thread public func feed (byteArray: ArraySlice) { @@ -2037,6 +2059,7 @@ extension TerminalView { */ public func send(data: ArraySlice) { + lastUserInputUptimeNs = DispatchTime.now().uptimeNanoseconds ensureCaretIsVisible () #if os(iOS) || os(visionOS) if TerminalView.textInputDebugEnabled { diff --git a/Sources/SwiftTerm/Mac/MacTerminalView.swift b/Sources/SwiftTerm/Mac/MacTerminalView.swift index 93eac10cf..e703c7f8c 100644 --- a/Sources/SwiftTerm/Mac/MacTerminalView.swift +++ b/Sources/SwiftTerm/Mac/MacTerminalView.swift @@ -116,6 +116,10 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, private var findBarOptions: SearchOptions = SearchOptions() var debug: TerminalDebugView? var pendingDisplay: Bool = false + /// Output received shortly after local input is likely echo or prompt redraw; + /// render it without the 16.67ms frame-rate throttle so typing feels responsive. + var lastUserInputUptimeNs: UInt64 = 0 + let interactiveInputDisplayWindowNs: UInt64 = 150_000_000 #if canImport(MetalKit) var metalView: MTKView? var metalRenderer: MetalTerminalRenderer? diff --git a/Sources/SwiftTerm/iOS/iOSTerminalView.swift b/Sources/SwiftTerm/iOS/iOSTerminalView.swift index ccc2048d1..3fc56df1d 100644 --- a/Sources/SwiftTerm/iOS/iOSTerminalView.swift +++ b/Sources/SwiftTerm/iOS/iOSTerminalView.swift @@ -191,6 +191,10 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi var search: SearchService! var debug: UIView? var pendingDisplay: Bool = false + /// Output received shortly after local input is likely echo or prompt redraw; + /// render it without the 16.67ms frame-rate throttle so typing feels responsive. + var lastUserInputUptimeNs: UInt64 = 0 + let interactiveInputDisplayWindowNs: UInt64 = 150_000_000 #if canImport(MetalKit) var metalView: MTKView? var metalRenderer: MetalTerminalRenderer? From 196bf1effada0c4e857cdb497495b2d9847a55ae Mon Sep 17 00:00:00 2001 From: Evert Junior Date: Tue, 19 May 2026 19:36:19 -0300 Subject: [PATCH 2/2] Drop SyncDebug.log scaffolding to fix the build The SyncDebug type is not defined in the tree, so main currently fails to compile. Stripping the call sites in Terminal.swift and AppleTerminalView.swift unblocks CI on this PR. Easy to revert if SyncDebug is reintroduced later. --- Sources/SwiftTerm/Apple/AppleTerminalView.swift | 8 -------- Sources/SwiftTerm/Terminal.swift | 4 ---- 2 files changed, 12 deletions(-) diff --git a/Sources/SwiftTerm/Apple/AppleTerminalView.swift b/Sources/SwiftTerm/Apple/AppleTerminalView.swift index 9499eda70..96bb57197 100644 --- a/Sources/SwiftTerm/Apple/AppleTerminalView.swift +++ b/Sources/SwiftTerm/Apple/AppleTerminalView.swift @@ -355,7 +355,6 @@ extension TerminalView { public func synchronizedOutputChanged (source: Terminal, active: Bool) { - SyncDebug.log("delegate active=\(active)") if !active { updateScroller() queuePendingDisplay() @@ -1613,12 +1612,10 @@ extension TerminalView { { defer { pendingDisplay = false } if terminal.synchronizedOutputActive { - SyncDebug.log("paint-blocked sync=true") return } updateCursorPosition() guard let (rowStart, rowEnd) = terminal.getUpdateRange () else { - SyncDebug.log("paint-norange (cursor-only)") if notifyUpdateChanges { let buffer = terminal.displayBuffer let y = buffer.yDisp+buffer.y @@ -1646,7 +1643,6 @@ extension TerminalView { terminalDelegate?.rangeChanged (source: self, startY: rowStart, endY: rowEnd) } - SyncDebug.log("paint rows=\(rowStart)-\(rowEnd)") terminal.clearUpdateRange () #if os(macOS) @@ -1775,7 +1771,6 @@ extension TerminalView { func queuePendingDisplay () { if terminal.synchronizedOutputActive { - SyncDebug.log("queue-blocked sync=true") return } // throttle @@ -1784,12 +1779,10 @@ extension TerminalView { // let fps30 = 16670000*2 let fpsDelay = fps60 pendingDisplay = true - SyncDebug.log("queue-scheduled (+16.67ms)") DispatchQueue.main.asyncAfter( deadline: DispatchTime (uptimeNanoseconds: DispatchTime.now().uptimeNanoseconds + UInt64 (fpsDelay)), execute: updateDisplay) } else { - SyncDebug.log("queue-noop (already pending)") } } @@ -1987,7 +1980,6 @@ extension TerminalView { func feedFinish () { - SyncDebug.log("feedFinish sync=\(terminal.synchronizedOutputActive)") suspendDisplayUpdates () if shouldDisplayImmediatelyAfterUserInput() { displayImmediately() diff --git a/Sources/SwiftTerm/Terminal.swift b/Sources/SwiftTerm/Terminal.swift index 5a6b84e4a..909c094cb 100644 --- a/Sources/SwiftTerm/Terminal.swift +++ b/Sources/SwiftTerm/Terminal.swift @@ -5548,7 +5548,6 @@ open class Terminal { private func beginSynchronizedOutput () { let wasActive = synchronizedOutputActive - SyncDebug.log("BSU wasActive=\(wasActive)") synchronizedOutputActive = true scheduleSynchronizedOutputTimeout() if !wasActive { @@ -5559,10 +5558,8 @@ open class Terminal { private func endSynchronizedOutput () { guard synchronizedOutputActive else { - SyncDebug.log("ESU ignored (not active)") return } - SyncDebug.log("ESU") synchronizedOutputActive = false synchronizedOutputTimeoutItem?.cancel() synchronizedOutputTimeoutItem = nil @@ -5577,7 +5574,6 @@ open class Terminal { guard let self, self.synchronizedOutputActive else { return } - SyncDebug.log("safety-timer-fired (missing ESU)") self.endSynchronizedOutput() } synchronizedOutputTimeoutItem = workItem