diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 99d70b2c..78121fa0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1132,6 +1132,10 @@ final class TerminalSurface: Identifiable, ObservableObject { private var lastPixelHeight: UInt32 = 0 private var lastXScale: CGFloat = 0 private var lastYScale: CGFloat = 0 + private var pendingTextQueue: [Data] = [] + private var pendingTextBytes: Int = 0 + private let maxPendingTextBytes = 1_048_576 + private var backgroundSurfaceStartQueued = false @Published var searchState: SearchState? = nil { didSet { if let searchState { @@ -1496,6 +1500,8 @@ final class TerminalSurface: Identifiable, ObservableObject { lastXScale = scaleFactors.x lastYScale = scaleFactors.y } + + flushPendingTextIfNeeded() } func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) { @@ -1603,14 +1609,83 @@ final class TerminalSurface: Identifiable, ObservableObject { } func sendText(_ text: String) { - guard let surface = surface else { return } guard let data = text.data(using: .utf8), !data.isEmpty else { return } + guard let surface = surface else { + enqueuePendingText(data) + return + } + writeTextData(data, to: surface) + } + + func requestBackgroundSurfaceStartIfNeeded() { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.requestBackgroundSurfaceStartIfNeeded() + } + return + } + + guard surface == nil, attachedView != nil else { return } + guard !backgroundSurfaceStartQueued else { return } + backgroundSurfaceStartQueued = true + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.backgroundSurfaceStartQueued = false + guard self.surface == nil, let view = self.attachedView else { return } + #if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime + #endif + self.createSurface(for: view) + #if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 + dlog( + "surface.background_start surface=\(self.id.uuidString.prefix(8)) inWindow=\(view.window != nil ? 1 : 0) ready=\(self.surface != nil ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs))" + ) + #endif + } + } + + private func writeTextData(_ data: Data, to surface: ghostty_surface_t) { data.withUnsafeBytes { rawBuffer in guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) else { return } ghostty_surface_text(surface, baseAddress, UInt(rawBuffer.count)) } } + private func enqueuePendingText(_ data: Data) { + let incomingBytes = data.count + while !pendingTextQueue.isEmpty && pendingTextBytes + incomingBytes > maxPendingTextBytes { + let dropped = pendingTextQueue.removeFirst() + pendingTextBytes -= dropped.count + } + + pendingTextQueue.append(data) + pendingTextBytes += incomingBytes + #if DEBUG + dlog( + "surface.send_text.queue surface=\(id.uuidString.prefix(8)) chunks=\(pendingTextQueue.count) bytes=\(pendingTextBytes)" + ) + #endif + } + + private func flushPendingTextIfNeeded() { + guard let surface = surface, !pendingTextQueue.isEmpty else { return } + let queued = pendingTextQueue + let queuedBytes = pendingTextBytes + pendingTextQueue.removeAll(keepingCapacity: false) + pendingTextBytes = 0 + + for chunk in queued { + writeTextData(chunk, to: surface) + } + #if DEBUG + dlog( + "surface.send_text.flush surface=\(id.uuidString.prefix(8)) chunks=\(queued.count) bytes=\(queuedBytes)" + ) + #endif + } + func performBindingAction(_ action: String) -> Bool { guard let surface = surface else { return false } return action.withCString { cString in diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3fa0d18f..5f1a3833 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -515,7 +515,11 @@ class TerminalController { let cmd = parts[0].lowercased() let args = parts.count > 1 ? parts[1] : "" - return withSocketCommandPolicy(commandKey: cmd, isV2: false) { + #if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime + #endif + + let response = withSocketCommandPolicy(commandKey: cmd, isV2: false) { switch cmd { case "ping": return "PONG" @@ -807,6 +811,18 @@ class TerminalController { return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." } } + + #if DEBUG + if cmd == "new_workspace" || cmd == "send" || cmd == "send_surface" { + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 + let status = response.hasPrefix("OK") ? "ok" : "err" + dlog( + "socket.v1 cmd=\(cmd) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" + ) + } + #endif + + return response } // MARK: - V2 JSON Socket Protocol @@ -841,7 +857,11 @@ class TerminalController { v2MainSync { self.v2RefreshKnownRefs() } - return withSocketCommandPolicy(commandKey: method, isV2: true) { + #if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime + #endif + + let response = withSocketCommandPolicy(commandKey: method, isV2: true) { switch method { case "system.ping": return v2Ok(id: id, result: ["pong": true]) @@ -1181,6 +1201,18 @@ class TerminalController { return v2Error(id: id, code: "method_not_found", message: "Unknown method") } } + + #if DEBUG + if method == "workspace.create" || method == "surface.send_text" { + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 + let status = response.contains("\"ok\":true") ? "ok" : "err" + dlog( + "socket.v2 method=\(method) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" + ) + } + #endif + + return response } private func v2Capabilities() -> [String: Any] { @@ -1781,10 +1813,20 @@ class TerminalController { } var newId: UUID? + let shouldFocus = v2FocusAllowed() + #if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime + #endif v2MainSync { - let ws = tabManager.addWorkspace(select: v2FocusAllowed()) + let ws = tabManager.addWorkspace(select: shouldFocus) newId = ws.id } + #if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 + dlog( + "socket.workspace.create focus=\(shouldFocus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" + ) + #endif guard let newId else { return .err(code: "internal_error", message: "Failed to create workspace", data: nil) @@ -3109,24 +3151,38 @@ class TerminalController { result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) return } - guard let surface = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) else { - result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString]) - return + #if DEBUG + let sendStart = ProcessInfo.processInfo.systemUptime + #endif + let queued: Bool + if let surface = terminalPanel.surface.surface { + sendSocketText(text, surface: surface) + // Ensure we present a new frame after injecting input so snapshot-based tests (and + // socket-driven agents) can observe the updated terminal without requiring a focus + // change to trigger a draw. + terminalPanel.surface.forceRefresh() + queued = false + } else { + // Avoid blocking the main actor waiting for view/surface attachment. + terminalPanel.sendText(text) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + queued = true } - - for char in text { - if char.unicodeScalars.count == 1, - let scalar = char.unicodeScalars.first, - handleControlScalar(scalar, surface: surface) { - continue - } - sendTextEvent(surface: surface, text: String(char)) - } - // Ensure we present a new frame after injecting input so snapshot-based tests (and - // socket-driven agents) can observe the updated terminal without requiring a focus - // change to trigger a draw. - terminalPanel.surface.forceRefresh() - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) + #if DEBUG + let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0 + dlog( + "socket.surface.send_text workspace=\(ws.id.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) queued=\(queued ? 1 : 0) chars=\(text.count) ms=\(String(format: "%.2f", sendMs))" + ) + #endif + result = .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "queued": queued, + "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager)) + ]) } return result } @@ -3154,7 +3210,7 @@ class TerminalController { result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) return } - guard let surface = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) else { + guard let surface = terminalPanel.surface.surface else { result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString]) return } @@ -8753,10 +8809,19 @@ class TerminalController { var newTabId: UUID? let focus = socketCommandAllowsInAppFocusMutations() + #if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime + #endif DispatchQueue.main.sync { let workspace = tabManager.addTab(select: focus) newTabId = workspace.id } + #if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 + dlog( + "socket.new_workspace focus=\(focus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" + ) + #endif return "OK \(newTabId?.uuidString ?? "unknown")" } @@ -9741,6 +9806,69 @@ class TerminalController { sendKeyEvent(surface: surface, keycode: 0, text: text) } + enum SocketTextChunk: Equatable { + case text(String) + case control(UnicodeScalar) + } + + nonisolated static func socketTextChunks(_ text: String) -> [SocketTextChunk] { + guard !text.isEmpty else { return [] } + + var chunks: [SocketTextChunk] = [] + chunks.reserveCapacity(8) + var bufferedText = "" + bufferedText.reserveCapacity(text.count) + + func flushBufferedText() { + guard !bufferedText.isEmpty else { return } + chunks.append(.text(bufferedText)) + bufferedText.removeAll(keepingCapacity: true) + } + + for scalar in text.unicodeScalars { + if isSocketControlScalar(scalar) { + flushBufferedText() + chunks.append(.control(scalar)) + } else { + bufferedText.unicodeScalars.append(scalar) + } + } + flushBufferedText() + return chunks + } + + private nonisolated static func isSocketControlScalar(_ scalar: UnicodeScalar) -> Bool { + switch scalar.value { + case 0x0A, 0x0D, 0x09, 0x1B, 0x7F: + return true + default: + return false + } + } + + private func sendSocketText(_ text: String, surface: ghostty_surface_t) { + let chunks = Self.socketTextChunks(text) + #if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime + #endif + for chunk in chunks { + switch chunk { + case .text(let value): + sendTextEvent(surface: surface, text: value) + case .control(let scalar): + _ = handleControlScalar(scalar, surface: surface) + } + } + #if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 + if elapsedMs >= 8 || chunks.count > 1 { + dlog( + "socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))" + ) + } + #endif + } + private func handleControlScalar(_ scalar: UnicodeScalar, surface: ghostty_surface_t) -> Bool { switch scalar.value { case 0x0A, 0x0D: @@ -9843,15 +9971,6 @@ class TerminalController { return } - guard let surface = resolveTerminalSurface( - from: terminalPanel.id.uuidString, - tabManager: tabManager, - waitUpTo: 2.0 - ) else { - error = "ERROR: Surface not ready" - return - } - // Unescape common escape sequences // Note: \n is converted to \r for terminal (Enter key sends \r) let unescaped = text @@ -9859,13 +9978,11 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - for char in unescaped { - if char.unicodeScalars.count == 1, - let scalar = char.unicodeScalars.first, - handleControlScalar(scalar, surface: surface) { - continue - } - sendTextEvent(surface: surface, text: String(char)) + if let surface = terminalPanel.surface.surface { + sendSocketText(unescaped, surface: surface) + } else { + terminalPanel.sendText(unescaped) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() } success = true } @@ -9883,20 +10000,18 @@ class TerminalController { var success = false DispatchQueue.main.sync { - guard let surface = resolveSurface(from: target, tabManager: tabManager) else { return } + guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { return } let unescaped = text .replacingOccurrences(of: "\\n", with: "\r") .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - for char in unescaped { - if char.unicodeScalars.count == 1, - let scalar = char.unicodeScalars.first, - handleControlScalar(scalar, surface: surface) { - continue - } - sendTextEvent(surface: surface, text: String(char)) + if let surface = terminalPanel.surface.surface { + sendSocketText(unescaped, surface: surface) + } else { + terminalPanel.sendText(unescaped) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() } success = true } @@ -9917,11 +10032,7 @@ class TerminalController { return } - guard let surface = resolveTerminalSurface( - from: terminalPanel.id.uuidString, - tabManager: tabManager, - waitUpTo: 2.0 - ) else { + guard let surface = terminalPanel.surface.surface else { error = "ERROR: Surface not ready" return } @@ -9943,11 +10054,11 @@ class TerminalController { var success = false var error: String? DispatchQueue.main.sync { - guard resolveTerminalPanel(from: target, tabManager: tabManager) != nil else { + guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { error = "ERROR: Surface not found" return } - guard let surface = resolveTerminalSurface(from: target, tabManager: tabManager, waitUpTo: 2.0) else { + guard let surface = terminalPanel.surface.surface else { error = "ERROR: Surface not ready" return } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index d839dd75..17d9654c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -335,7 +335,9 @@ final class Workspace: Identifiable, ObservableObject { // Configure bonsplit with keepAllAlive to preserve terminal state // and keep split entry instantaneous. - let appearance = Self.bonsplitAppearance(from: GhosttyConfig.load()) + // Avoid re-reading/parsing Ghostty config on every new workspace; this hot path + // runs for socket/CLI workspace creation and can cause visible typing lag. + let appearance = Self.bonsplitAppearance(from: GhosttyApp.shared.defaultBackgroundColor) let config = BonsplitConfiguration( allowSplits: true, allowCloseTabs: true, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 27e86aec..376f1eca 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -3596,3 +3596,36 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase { ) } } + +final class TerminalControllerSocketTextChunkTests: XCTestCase { + func testSocketTextChunksReturnsSingleChunkForPlainText() { + XCTAssertEqual( + TerminalController.socketTextChunks("echo hello"), + [.text("echo hello")] + ) + } + + func testSocketTextChunksSplitsControlScalars() { + XCTAssertEqual( + TerminalController.socketTextChunks("abc\rdef\tghi"), + [ + .text("abc"), + .control("\r".unicodeScalars.first!), + .text("def"), + .control("\t".unicodeScalars.first!), + .text("ghi") + ] + ) + } + + func testSocketTextChunksDoesNotEmitEmptyTextChunksAroundConsecutiveControls() { + XCTAssertEqual( + TerminalController.socketTextChunks("\r\n\t"), + [ + .control("\r".unicodeScalars.first!), + .control("\n".unicodeScalars.first!), + .control("\t".unicodeScalars.first!) + ] + ) + } +} diff --git a/tests_v2/test_cli_new_workspace_command_queue.py b/tests_v2/test_cli_new_workspace_command_queue.py new file mode 100644 index 00000000..da7523c2 --- /dev/null +++ b/tests_v2/test_cli_new_workspace_command_queue.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Regression: `new-workspace --command` should execute without selecting the workspace.""" + +from __future__ import annotations + +import glob +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: list[str]) -> tuple[subprocess.CompletedProcess[str], float]: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + started = time.monotonic() + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH] + args, + capture_output=True, + text=True, + check=False, + env=env, + ) + elapsed = time.monotonic() - started + return proc, elapsed + + +def main() -> int: + cli = _find_cli_binary() + marker = Path(tempfile.gettempdir()) / f"cmux_new_workspace_command_{os.getpid()}.txt" + created_ws_id: str | None = None + + try: + marker.unlink(missing_ok=True) + except OSError: + pass + + with cmux(SOCKET_PATH) as c: + try: + baseline_ws_id = c.current_workspace() + token = f"queued-{os.getpid()}-{int(time.time() * 1000)}" + cmd_text = f"echo {token} > {marker}" + + proc, elapsed = _run_cli(cli, ["new-workspace", "--command", cmd_text]) + combined = f"{proc.stdout}\n{proc.stderr}".strip() + _must(proc.returncode == 0, f"CLI failed ({proc.returncode}): {combined}") + _must(elapsed < 1.5, f"new-workspace --command should return quickly, took {elapsed:.2f}s") + + output = (proc.stdout or "").strip() + _must(output.startswith("OK "), f"Expected OK response, got: {output!r}") + _must("Surface not ready" not in combined, f"Unexpected surface readiness error: {combined}") + created_ws_id = output[3:].strip() + _must(bool(created_ws_id), f"Missing workspace id in output: {output!r}") + + # Creation with --command should not steal focus. + _must(c.current_workspace() == baseline_ws_id, "new-workspace --command should preserve selected workspace") + + observed = "" + deadline = time.time() + 12.0 + while time.time() < deadline: + if marker.exists(): + try: + observed = marker.read_text(encoding="utf-8").strip() + except OSError: + observed = "" + if observed: + break + time.sleep(0.05) + + _must(marker.exists(), f"Command marker file was not created: {marker}") + _must(observed == token, f"Queued command did not execute as expected: expected={token!r} observed={observed!r}") + _must(c.current_workspace() == baseline_ws_id, "Command execution should not switch selected workspace") + finally: + if created_ws_id: + try: + c.close_workspace(created_ws_id) + except Exception: + pass + + try: + marker.unlink(missing_ok=True) + except OSError: + pass + + print("PASS: new-workspace --command executes without opening the created workspace") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())