diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 4af64492e..27d212eee 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1655,6 +1655,21 @@ class TerminalController { continue } + // Detect streaming command before normal processing. + // surface.stream takes over the socket connection for bidirectional + // PTY relay, so it must bypass the normal request/response loop. + if trimmed.contains("\"surface.stream\"") { + if let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let method = json["method"] as? String, + method == "surface.stream" { + let params = json["params"] as? [String: Any] ?? [:] + let id = json["id"] + v2SurfaceStream(id: id, params: params, socket: socket) + return // stream took over this connection + } + } + let response = processCommand(trimmed) writeSocketResponse(response, to: socket) } @@ -2387,6 +2402,11 @@ class TerminalController { case "surface.read_text": return v2Result(id: id, self.v2SurfaceReadText(params: params)) + case "surface.stream": + // surface.stream is handled in handleClient before processCommand; + // if we reach here the caller used the wrong transport path. + return v2Error(id: id, code: "invalid_request", + message: "surface.stream must be sent as the first command on a dedicated connection") #if DEBUG // Debug / test-only @@ -2515,6 +2535,7 @@ class TerminalController { "surface.read_text", "surface.clear_history", "surface.trigger_flash", + "surface.stream", "pane.list", "pane.focus", "pane.surfaces", @@ -6127,6 +6148,216 @@ class TerminalController { return result } + // MARK: - V2 Surface Stream + + /// Resolves the target surface, sends a stream-start acknowledgement, and enters + /// a bidirectional PTY relay loop that owns the socket until the client disconnects. + /// Called from `handleClient` *before* the normal command dispatch so it can take + /// over the connection. + private func v2SurfaceStream(id: Any?, params: [String: Any], socket: Int32) { + guard let tabManager = v2ResolveTabManager(params: params) else { + let err = v2Error(id: id, code: "unavailable", message: "TabManager not available") + writeSocketResponse(err, to: socket) + return + } + + var surfacePtr: ghostty_surface_t? = nil + var errorResponse: String? = nil + var initialSnapshot: String? = nil + + // Resolve surface AND read snapshot on main thread (ghostty API requirement) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + errorResponse = v2Error(id: id, code: "not_found", message: "Workspace not found") + return + } + let surfaceId: UUID? + if params["surface_id"] != nil { + surfaceId = v2UUID(params, "surface_id") + guard surfaceId != nil else { + errorResponse = v2Error(id: id, code: "not_found", + message: "Surface not found for the given surface_id") + return + } + } else { + surfaceId = ws.focusedPanelId + } + guard let surfaceId else { + errorResponse = v2Error(id: id, code: "not_found", message: "No focused surface") + return + } + guard let terminalPanel = ws.terminalPanel(for: surfaceId) else { + errorResponse = v2Error(id: id, code: "invalid_params", + message: "Surface is not a terminal", + data: ["surface_id": surfaceId.uuidString]) + return + } + surfacePtr = terminalPanel.surface.surface + // Read initial screen snapshot while on main thread + if let surface = surfacePtr { + initialSnapshot = readScreenSnapshot(surface: surface) + } + } + + if let errorResponse { + writeSocketResponse(errorResponse, to: socket) + return + } + + guard let surface = surfacePtr else { + let err = v2Error(id: id, code: "not_found", message: "Terminal surface not ready") + writeSocketResponse(err, to: socket) + return + } + + // Send stream-start acknowledgement + let startMsg = v2Ok(id: id, result: ["stream": true]) + writeSocketResponse(startMsg, to: socket) + + // Enter the blocking relay loop (runs on the current background thread) + enterStreamRelay(socket: socket, surface: surface, initialSnapshot: initialSnapshot) + } + + /// Read the current visible terminal screen as plain text. + /// Must be called on the main thread (ghostty_surface_read_text requires it). + private func readScreenSnapshot(surface: ghostty_surface_t) -> String? { + let topLeft = ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, y: 0 + ) + let bottomRight = ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, y: 0 + ) + let selection = ghostty_selection_s( + top_left: topLeft, + bottom_right: bottomRight, + rectangle: false + ) + var text = ghostty_text_s() + guard ghostty_surface_read_text(surface, selection, &text) else { return nil } + defer { ghostty_surface_free_text(surface, &text) } + guard let ptr = text.text, text.text_len > 0 else { return nil } + return String(decoding: Data(bytes: ptr, count: Int(text.text_len)), as: UTF8.self) + } + + /// Writes all bytes to the socket, retrying on partial writes and EINTR. + private nonisolated func writeAll(_ socket: Int32, _ data: String) { + data.withCString { ptr in + var total = 0 + let len = strlen(ptr) + while total < len { + let n = Darwin.write(socket, ptr.advanced(by: total), len - total) + if n < 0 { + if errno == EINTR { continue } + break // Real error + } + total += n + } + } + } + + /// Bidirectional relay between a Unix socket and a Ghostty PTY tap. + /// Sends an initial screen snapshot, then streams live PTY output as + /// base64-encoded JSON frames. Reads JSON input from the socket and writes + /// it into the PTY via `ghostty_surface_pty_write`. + /// Blocks the calling thread until the client disconnects or an error occurs. + private nonisolated func enterStreamRelay(socket: Int32, surface: ghostty_surface_t, initialSnapshot: String?) { + // Open PTY tap with a 64 KB ring buffer + guard ghostty_surface_pty_tap_open(surface, 65536) else { + writeAll(socket, "{\"type\":\"error\",\"message\":\"Failed to open PTY tap\"}\n") + return + } + + // Send initial screen snapshot so the client sees existing content + if let snapshot = initialSnapshot { + let snapshotData = Data(snapshot.utf8) + let b64 = snapshotData.base64EncodedString() + writeAll(socket, "{\"type\":\"snapshot\",\"data\":\"\(b64)\"}\n") + } + + // Set socket non-blocking so we can interleave reads from both directions + let origFlags = fcntl(socket, F_GETFL, 0) + _ = fcntl(socket, F_SETFL, origFlags | O_NONBLOCK) + + var readBuf = [UInt8](repeating: 0, count: 65536) + var inputBuffer = "" + let maxInputBufferSize = 65536 + + // Bidirectional relay loop + while true { + // Poll socket for input with 16 ms timeout (~60 fps) + var pfd = pollfd(fd: socket, events: Int16(POLLIN), revents: 0) + poll(&pfd, 1, 16) + + // Check for disconnect + if pfd.revents & Int16(POLLHUP) != 0 { break } + if pfd.revents & Int16(POLLERR) != 0 { break } + + // --- PTY → socket --- + let n = ghostty_surface_pty_tap_read(surface, &readBuf, UInt32(readBuf.count)) + if n > 0 { + let data = Data(bytes: readBuf, count: Int(n)) + let b64 = data.base64EncodedString() + writeAll(socket, "{\"type\":\"output\",\"data\":\"\(b64)\"}\n") + } + + // --- socket → PTY --- + if pfd.revents & Int16(POLLIN) != 0 { + var inBuf = [UInt8](repeating: 0, count: 4096) + let bytesRead = Darwin.read(socket, &inBuf, inBuf.count) + if bytesRead < 0 { + // Handle transient errors: EAGAIN/EWOULDBLOCK/EINTR are normal + // for non-blocking sockets — just continue the loop. + if errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR { continue } + break // Real error + } + if bytesRead == 0 { break } // EOF — client disconnected + + if let str = String(bytes: inBuf[0.. maxInputBufferSize { + inputBuffer = String(inputBuffer.suffix(maxInputBufferSize)) + } + while let newlineIdx = inputBuffer.firstIndex(of: "\n") { + let line = String(inputBuffer[.. V2CallResult {