diff --git a/ios/VibeTunnel/Services/BufferWebSocketClient.swift b/ios/VibeTunnel/Services/BufferWebSocketClient.swift index df32ff67..7ec6fbca 100644 --- a/ios/VibeTunnel/Services/BufferWebSocketClient.swift +++ b/ios/VibeTunnel/Services/BufferWebSocketClient.swift @@ -6,6 +6,26 @@ enum TerminalWebSocketEvent { case output(timestamp: Double, data: String) case resize(timestamp: Double, dimensions: String) case exit(code: Int) + case bufferUpdate(snapshot: BufferSnapshot) +} + +/// Binary buffer snapshot data +struct BufferSnapshot { + let cols: Int + let rows: Int + let viewportY: Int + let cursorX: Int + let cursorY: Int + let cells: [[BufferCell]] +} + +/// Individual cell data +struct BufferCell { + let char: String + let width: Int + let fg: Int? + let bg: Int? + let attributes: Int? } /// Errors that can occur during WebSocket operations. @@ -234,30 +254,12 @@ class BufferWebSocketClient: NSObject { return nil } - // Convert buffer snapshot to terminal output - let outputData = convertBufferToANSI(bufferSnapshot) - print("[BufferWebSocket] Decoded buffer: \(bufferSnapshot.cols)x\(bufferSnapshot.rows), \(outputData.count) bytes output") + print("[BufferWebSocket] Decoded buffer: \(bufferSnapshot.cols)x\(bufferSnapshot.rows)") - // Return as output event with current timestamp - return .output(timestamp: Date().timeIntervalSince1970, data: outputData) + // Return buffer update event + return .bufferUpdate(snapshot: bufferSnapshot) } - private struct BufferSnapshot { - let cols: Int - let rows: Int - let viewportY: Int - let cursorX: Int - let cursorY: Int - let cells: [[BufferCell]] - } - - private struct BufferCell { - let char: String - let width: Int - let fg: Int? - let bg: Int? - let attributes: Int? - } private func decodeBinaryBuffer(_ data: Data) -> BufferSnapshot? { var offset = 0 @@ -475,93 +477,6 @@ class BufferWebSocketClient: NSObject { return (BufferCell(char: char, width: width, fg: fg, bg: bg, attributes: attributes), currentOffset) } - private func convertBufferToANSI(_ snapshot: BufferSnapshot) -> String { - var output = "" - - // Clear screen and move cursor to top - output += "\u{001B}[2J\u{001B}[H" - - // Render each row - for (rowIndex, row) in snapshot.cells.enumerated() { - if rowIndex > 0 { - output += "\n" - } - - var currentFg: Int? - var currentBg: Int? - var currentAttrs: Int = 0 - - for cell in row { - // Handle attributes - if let attrs = cell.attributes, attrs != currentAttrs { - // Reset all attributes - output += "\u{001B}[0m" - currentAttrs = attrs - currentFg = nil - currentBg = nil - - // Apply new attributes - if (attrs & 0x01) != 0 { output += "\u{001B}[1m" } // Bold - if (attrs & 0x02) != 0 { output += "\u{001B}[3m" } // Italic - if (attrs & 0x04) != 0 { output += "\u{001B}[4m" } // Underline - if (attrs & 0x08) != 0 { output += "\u{001B}[2m" } // Dim - if (attrs & 0x10) != 0 { output += "\u{001B}[7m" } // Inverse - if (attrs & 0x40) != 0 { output += "\u{001B}[9m" } // Strikethrough - } - - // Handle foreground color - if cell.fg != currentFg { - currentFg = cell.fg - if let fg = cell.fg { - if fg & 0xFF000000 != 0 { - // RGB color - let r = (fg >> 16) & 0xFF - let g = (fg >> 8) & 0xFF - let b = fg & 0xFF - output += "\u{001B}[38;2;\(r);\(g);\(b)m" - } else if fg <= 255 { - // Palette color - output += "\u{001B}[38;5;\(fg)m" - } - } else { - // Default foreground - output += "\u{001B}[39m" - } - } - - // Handle background color - if cell.bg != currentBg { - currentBg = cell.bg - if let bg = cell.bg { - if bg & 0xFF000000 != 0 { - // RGB color - let r = (bg >> 16) & 0xFF - let g = (bg >> 8) & 0xFF - let b = bg & 0xFF - output += "\u{001B}[48;2;\(r);\(g);\(b)m" - } else if bg <= 255 { - // Palette color - output += "\u{001B}[48;5;\(bg)m" - } - } else { - // Default background - output += "\u{001B}[49m" - } - } - - // Add the character - output += cell.char - } - } - - // Reset attributes at the end - output += "\u{001B}[0m" - - // Position cursor - output += "\u{001B}[\(snapshot.cursorY + 1);\(snapshot.cursorX + 1)H" - - return output - } func subscribe(to sessionId: String, handler: @escaping (TerminalWebSocketEvent) -> Void) { subscriptions[sessionId] = handler diff --git a/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift b/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift index 11913d87..3958e54b 100644 --- a/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift +++ b/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift @@ -129,6 +129,25 @@ struct TerminalHostingView: UIViewRepresentable { terminal.font = font } + // MARK: - Buffer Types + + struct BufferSnapshot { + let cols: Int + let rows: Int + let viewportY: Int + let cursorX: Int + let cursorY: Int + let cells: [[BufferCell]] + } + + struct BufferCell { + let char: String + let width: Int + let fg: Int? + let bg: Int? + let attributes: Int? + } + @MainActor class Coordinator: NSObject { let onInput: (String) -> Void @@ -151,6 +170,133 @@ struct TerminalHostingView: UIViewRepresentable { viewModel.terminalCoordinator = self } } + + /// Update terminal buffer from binary buffer data using optimized ANSI sequences + func updateBuffer(from snapshot: BufferSnapshot) { + guard let terminal = terminal else { return } + + // Update terminal dimensions if needed + let currentCols = terminal.getTerminal().cols + let currentRows = terminal.getTerminal().rows + + if currentCols != snapshot.cols || currentRows != snapshot.rows { + terminal.resize(cols: snapshot.cols, rows: snapshot.rows) + } + + // Convert buffer to optimized ANSI sequences + let ansiData = convertBufferToOptimizedANSI(snapshot) + + // Feed the ANSI data to the terminal + feedData(ansiData) + } + + private func convertBufferToOptimizedANSI(_ snapshot: BufferSnapshot) -> String { + var output = "" + + // Clear screen and reset cursor + output += "\u{001B}[2J\u{001B}[H" + + // Track current attributes to minimize escape sequences + var currentFg: Int? + var currentBg: Int? + var currentAttrs: Int = 0 + + // Render each row + for (rowIndex, row) in snapshot.cells.enumerated() { + if rowIndex > 0 { + output += "\r\n" + } + + var lastNonSpaceIndex = -1 + for (index, cell) in row.enumerated() { + if cell.char != " " || cell.bg != nil { + lastNonSpaceIndex = index + } + } + + // Only render up to the last non-space character + for (colIndex, cell) in row.enumerated() { + if colIndex > lastNonSpaceIndex { + break + } + + // Handle attributes efficiently + var needsReset = false + if let attrs = cell.attributes, attrs != currentAttrs { + needsReset = true + currentAttrs = attrs + } + + // Handle colors efficiently + if cell.fg != currentFg || cell.bg != currentBg || needsReset { + if needsReset { + output += "\u{001B}[0m" + currentFg = nil + currentBg = nil + + // Apply attributes + if let attrs = cell.attributes { + if (attrs & 0x01) != 0 { output += "\u{001B}[1m" } // Bold + if (attrs & 0x02) != 0 { output += "\u{001B}[3m" } // Italic + if (attrs & 0x04) != 0 { output += "\u{001B}[4m" } // Underline + if (attrs & 0x08) != 0 { output += "\u{001B}[2m" } // Dim + if (attrs & 0x10) != 0 { output += "\u{001B}[7m" } // Inverse + if (attrs & 0x40) != 0 { output += "\u{001B}[9m" } // Strikethrough + } + } + + // Apply foreground color + if cell.fg != currentFg { + currentFg = cell.fg + if let fg = cell.fg { + if fg & 0xFF000000 != 0 { + // RGB color + let r = (fg >> 16) & 0xFF + let g = (fg >> 8) & 0xFF + let b = fg & 0xFF + output += "\u{001B}[38;2;\(r);\(g);\(b)m" + } else if fg <= 255 { + // Palette color + output += "\u{001B}[38;5;\(fg)m" + } + } else { + output += "\u{001B}[39m" + } + } + + // Apply background color + if cell.bg != currentBg { + currentBg = cell.bg + if let bg = cell.bg { + if bg & 0xFF000000 != 0 { + // RGB color + let r = (bg >> 16) & 0xFF + let g = (bg >> 8) & 0xFF + let b = bg & 0xFF + output += "\u{001B}[48;2;\(r);\(g);\(b)m" + } else if bg <= 255 { + // Palette color + output += "\u{001B}[48;5;\(bg)m" + } + } else { + output += "\u{001B}[49m" + } + } + } + + // Add the character + output += cell.char + } + } + + // Reset attributes + output += "\u{001B}[0m" + + // Position cursor + output += "\u{001B}[\(snapshot.cursorY + 1);\(snapshot.cursorX + 1)H" + + return output + } func feedData(_ data: String) { Task { @MainActor in diff --git a/ios/VibeTunnel/Views/Terminal/TerminalView.swift b/ios/VibeTunnel/Views/Terminal/TerminalView.swift index c5ee84a6..bbad308a 100644 --- a/ios/VibeTunnel/Views/Terminal/TerminalView.swift +++ b/ios/VibeTunnel/Views/Terminal/TerminalView.swift @@ -506,6 +506,32 @@ class TerminalViewModel { if castRecorder.isRecording { stopRecording() } + + case .bufferUpdate(let snapshot): + // Update terminal buffer directly + if let coordinator = terminalCoordinator { + coordinator.updateBuffer(from: TerminalHostingView.BufferSnapshot( + cols: snapshot.cols, + rows: snapshot.rows, + viewportY: snapshot.viewportY, + cursorX: snapshot.cursorX, + cursorY: snapshot.cursorY, + cells: snapshot.cells.map { row in + row.map { cell in + TerminalHostingView.BufferCell( + char: cell.char, + width: cell.width, + fg: cell.fg, + bg: cell.bg, + attributes: cell.attributes + ) + } + } + )) + } else { + // Fallback: buffer updates not available yet + print("Warning: Direct buffer update not available") + } } }