@@ -142,10 +142,19 @@ actor DaemonAttachSession {
142142 lastFullRefreshAtNanos = now
143143 }
144144 let bytes = [ UInt8] ( data)
145+ let termRows = await MainActor . run { tv? . getTerminal ( ) . rows ?? 24 }
146+ let filtered = Self . filterTerminalOutput ( bytes, maxRows: termRows)
145147 await MainActor . run {
146- tv? . feed ( byteArray: ArraySlice ( bytes) )
147- if shouldForceFullRefresh {
148- tv? . getTerminal ( ) . updateFullScreen ( )
148+ tv? . feed ( byteArray: ArraySlice ( filtered) )
149+ // DEBUG: Log terminal buffer state to find desync
150+ if let term = tv? . getTerminal ( ) {
151+ let buf = term. buffer
152+ if buf. y == 0 && buf. yDisp > 0 {
153+ // swiftlint:disable:next line_length
154+ print (
155+ " [TermDBG] CURSOR AT TOP: y= \( buf. y) x= \( buf. x) yDisp= \( buf. yDisp) scrollTop= \( buf. scrollTop) scrollBottom= \( buf. scrollBottom) rows= \( term. rows) cols= \( term. cols) "
156+ )
157+ }
149158 }
150159 tv? . needsDisplay = true
151160 }
@@ -159,6 +168,56 @@ actor DaemonAttachSession {
159168 }
160169 }
161170 }
171+
172+ // MARK: - Terminal Output Filter
173+
174+ /// Filter terminal output to work around SwiftTerm rendering issues:
175+ /// 1. Strip DEC 2026 synchronized output sequences (SwiftTerm#203 — sync buffer
176+ /// snapshot mistracking causes cursor/scroll desync).
177+ /// 2. Clamp CSI n A (cursor-up) sequences so n never exceeds viewport rows.
178+ /// Ink's eraseLines() emits cursor-up counts that can exceed viewport height,
179+ /// causing the cursor to overshoot row 0 and desync the buffer.
180+ private static func filterTerminalOutput( _ bytes: [ UInt8 ] , maxRows: Int ) -> [ UInt8 ] {
181+ let syncBegin : [ UInt8 ] = [ 0x1b , 0x5b , 0x3f , 0x32 , 0x30 , 0x32 , 0x36 , 0x68 ]
182+ let syncEnd : [ UInt8 ] = [ 0x1b , 0x5b , 0x3f , 0x32 , 0x30 , 0x32 , 0x36 , 0x6c ]
183+ let maxUp = max ( maxRows - 1 , 1 )
184+
185+ var result : [ UInt8 ] = [ ]
186+ result. reserveCapacity ( bytes. count)
187+ var i = 0
188+ while i < bytes. count {
189+ // Strip DEC 2026 begin/end (8 bytes each)
190+ if i + 8 <= bytes. count {
191+ let slice = Array ( bytes [ i..< i + 8 ] )
192+ if slice == syncBegin || slice == syncEnd {
193+ i += 8
194+ continue
195+ }
196+ }
197+ // Clamp CSI n A (cursor up): \x1b [ <digits> A
198+ if bytes [ i] == 0x1b , i + 2 < bytes. count, bytes [ i + 1 ] == 0x5b {
199+ var j = i + 2
200+ var digits = 0
201+ var hasDigits = false
202+ while j < bytes. count, bytes [ j] >= 0x30 , bytes [ j] <= 0x39 {
203+ digits = digits * 10 + Int( bytes [ j] - 0x30 )
204+ hasDigits = true
205+ j += 1
206+ }
207+ if j < bytes. count, bytes [ j] == 0x41 , hasDigits {
208+ // It's CSI <n> A — clamp n
209+ let clamped = min ( digits, maxUp)
210+ let replacement = Array ( " \u{1b} [ \( clamped) A " . utf8)
211+ result. append ( contentsOf: replacement)
212+ i = j + 1
213+ continue
214+ }
215+ }
216+ result. append ( bytes [ i] )
217+ i += 1
218+ }
219+ return result
220+ }
162221}
163222
164223enum DaemonAttachError : Error , LocalizedError {
0 commit comments