From ea4e27c4edc8a2fa73490e2da7ef15dfd7b68d1b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 18:10:29 -0700 Subject: [PATCH 01/31] Add dead terminal input regression --- Sources/GhosttyTerminalView.swift | 11 +++++ .../GhosttyCommandShiftForwardingTests.swift | 45 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 985de1b6f957..ea43021553cd 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4418,7 +4418,13 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + private enum TerminalInputLifecycleState: Equatable { + case acceptingInput + case childExited(reason: String) + } + private(set) var surface: ghostty_surface_t? + private var inputLifecycleState: TerminalInputLifecycleState = .acceptingInput private weak var attachedView: GhosttyNSView? /// Whether the runtime Ghostty surface exists and has not begun teardown. @@ -5943,6 +5949,11 @@ final class TerminalSurface: Identifiable, ObservableObject { needsConfirmCloseOverrideForTesting = value } + @MainActor + func markChildProcessExitedForTesting(reason: String = "test") { + inputLifecycleState = .childExited(reason: reason) + } + /// Test-only helper to deterministically simulate a released runtime surface. @MainActor func releaseSurfaceForTesting() { diff --git a/cmuxTests/GhosttyCommandShiftForwardingTests.swift b/cmuxTests/GhosttyCommandShiftForwardingTests.swift index 919e553b9244..231658862615 100644 --- a/cmuxTests/GhosttyCommandShiftForwardingTests.swift +++ b/cmuxTests/GhosttyCommandShiftForwardingTests.swift @@ -9,6 +9,7 @@ import AppKit @MainActor final class GhosttyCommandShiftForwardingTests: XCTestCase { + private static let keyCodeANSIH: UInt16 = 4 private static let keyCodeANSIK: UInt16 = 40 private struct HostedTerminal { @@ -108,4 +109,48 @@ final class GhosttyCommandShiftForwardingTests: XCTestCase { XCTAssertEqual(keyEvent.mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue, GHOSTTY_MODS_SHIFT.rawValue) XCTAssertEqual(keyEvent.unshifted_codepoint, "k".unicodeScalars.first?.value) } + + func testDeadTerminalPaneKeyDownDoesNotForwardInputToGhostty() throws { + let hostedTerminal = try makeHostedTerminal() + let window = hostedTerminal.window + let surfaceView = hostedTerminal.surfaceView + defer { window.orderOut(nil) } + + XCTAssertTrue(window.makeFirstResponder(surfaceView), "Expected Ghostty surface view to accept first responder") + XCTAssertNotNil(surfaceView.terminalSurface) + + let observedKeyCode = UInt32(Self.keyCodeANSIH) + var forwardedPressCount = 0 + let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + previousKeyEventObserver?(keyEvent) + guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == observedKeyCode else { return } + forwardedPressCount += 1 + } + defer { GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver } + + let previousTextInputHandler = GhosttyNSView.debugTextInputEventHandler + GhosttyNSView.debugTextInputEventHandler = { _, _ in false } + defer { GhosttyNSView.debugTextInputEventHandler = previousTextInputHandler } + + hostedTerminal.surface.markChildProcessExitedForTesting(reason: "unit-test-child-exited") + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "h", + charactersIgnoringModifiers: "h", + isARepeat: false, + keyCode: Self.keyCodeANSIH + )) + + withExtendedLifetime(hostedTerminal.surface) { + surfaceView.keyDown(with: event) + } + + XCTAssertEqual(forwardedPressCount, 0, "Dead terminal panes must discard key input before it reaches Ghostty") + } } From 9305c65539e27b91ad6486cb5c89901b6ff3f072 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 18:17:05 -0700 Subject: [PATCH 02/31] Discard input after terminal child exit --- Sources/GhosttyTerminalView.swift | 80 ++++++++++++++++++++++++++++++- Sources/TabManager.swift | 1 + Sources/TerminalController.swift | 56 +++++++++------------- 3 files changed, 102 insertions(+), 35 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ea43021553cd..ccdafbdb4322 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2056,6 +2056,7 @@ class GhosttyApp { guard let callbackContext = GhosttyApp.callbackContext(from: userdata) else { return } let callbackSurfaceId = callbackContext.surfaceId let callbackTabId = callbackContext.tabId + let callbackTerminalSurface = callbackContext.terminalSurface #if DEBUG cmuxWriteChildExitProbe( @@ -2069,6 +2070,9 @@ class GhosttyApp { #endif DispatchQueue.main.async { + if !needsConfirmClose { + callbackTerminalSurface?.markChildProcessExited(reason: "closeSurfaceCallback") + } guard let app = AppDelegate.shared else { return } // Close requests must be resolved by the callback's workspace/surface IDs only. // If the mapping is already gone (duplicate/stale callback), ignore it. @@ -3733,6 +3737,7 @@ class GhosttyApp { let callbackContext = Self.callbackContext(from: ghostty_surface_userdata(target.target.surface)) let callbackTabId = callbackContext?.tabId let callbackSurfaceId = callbackContext?.surfaceId + let callbackTerminalSurface = callbackContext?.terminalSurface if action.tag == GHOSTTY_ACTION_SHOW_CHILD_EXITED { // The child (shell) exited. Ghostty will fall back to printing @@ -3757,6 +3762,7 @@ class GhosttyApp { // Keep host-close async to avoid re-entrant close/deinit while Ghostty is still // dispatching this action callback. DispatchQueue.main.async { + callbackTerminalSurface?.markChildProcessExited(reason: "showChildExitedAction") guard let app = AppDelegate.shared else { return } if let callbackTabId, let callbackSurfaceId, @@ -4436,6 +4442,10 @@ final class TerminalSurface: Identifiable, ObservableObject { /// rejected and quarantined. var hasLiveSurface: Bool { surface != nil && portalLifecycleState == .live } + var acceptsTerminalInput: Bool { + terminalInputBlockReason() == nil + } + /// Whether the terminal surface view is currently attached to a window. /// /// Use the hosted view rather than the inner surface view, since the surface can be @@ -4706,6 +4716,63 @@ final class TerminalSurface: Identifiable, ObservableObject { return surface } + private func terminalInputBlockReason() -> String? { + guard portalLifecycleState == .live else { + return "lifecycle.\(portalLifecycleState.rawValue)" + } + switch inputLifecycleState { + case .acceptingInput: + return nil + case .childExited(let reason): + return "childExited.\(reason)" + } + } + + private func shouldForwardTerminalInput(reason: String) -> Bool { + guard let blockReason = terminalInputBlockReason() else { return true } + discardPendingSocketInput(reason: "\(reason).\(blockReason)") +#if DEBUG + cmuxDebugLog( + "surface.input.discard surface=\(id.uuidString.prefix(5)) " + + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) blocked=\(blockReason)" + ) +#endif + return false + } + + private func discardPendingSocketInput(reason: String) { + guard !pendingSocketInputQueue.isEmpty || pendingSocketInputBytes > 0 else { return } +#if DEBUG + let discardedItems = pendingSocketInputQueue.count + let discardedBytes = pendingSocketInputBytes +#endif + pendingSocketInputQueue.removeAll(keepingCapacity: false) + pendingSocketInputBytes = 0 +#if DEBUG + cmuxDebugLog( + "surface.socket_input.discard surface=\(id.uuidString.prefix(5)) " + + "workspace=\(tabId.uuidString.prefix(5)) items=\(discardedItems) " + + "bytes=\(discardedBytes) reason=\(reason)" + ) +#endif + } + + func markChildProcessExited(reason: String) { + switch inputLifecycleState { + case .childExited: + return + case .acceptingInput: + inputLifecycleState = .childExited(reason: reason) + discardPendingSocketInput(reason: reason) +#if DEBUG + cmuxDebugLog( + "surface.lifecycle.childExited surface=\(id.uuidString.prefix(5)) " + + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason)" + ) +#endif + } + } + private static let portalHostAreaThreshold: CGFloat = 4 private static func portalHostArea(for bounds: CGRect) -> CGFloat { @@ -4853,7 +4920,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } private func allowsRuntimeSurfaceCreation() -> Bool { - portalLifecycleState == .live + terminalInputBlockReason() == nil } func beginPortalCloseLifecycle(reason: String) { @@ -5591,6 +5658,7 @@ final class TerminalSurface: Identifiable, ObservableObject { func sendText(_ text: String) { guard let data = text.data(using: .utf8), !data.isEmpty else { return } + guard shouldForwardTerminalInput(reason: "sendText") else { return } guard let surface = surface else { enqueuePendingSocketInput(.text(data)) requestBackgroundSurfaceStartIfNeeded() @@ -5602,6 +5670,7 @@ final class TerminalSurface: Identifiable, ObservableObject { @discardableResult func sendNamedKey(_ keyName: String) -> Bool { guard let event = pendingKeyEvent(for: keyName) else { return false } + guard shouldForwardTerminalInput(reason: "sendNamedKey.\(event.label)") else { return true } if let surface = surface { sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) } else { @@ -5615,6 +5684,7 @@ final class TerminalSurface: Identifiable, ObservableObject { /// events so the shell processes them, while regular text is sent via the /// normal key-text path. Mirrors `TerminalController.sendSocketText`. func sendInput(_ text: String) { + guard shouldForwardTerminalInput(reason: "sendInput") else { return } guard let surface = surface else { return } var bufferedText = "" var previousWasCR = false @@ -5638,6 +5708,10 @@ final class TerminalSurface: Identifiable, ObservableObject { flushText(&bufferedText, surface: surface) sendKeyEvent(surface: surface, keycode: 0x35) // kVK_Escape previousWasCR = false + case 0x7F: + flushText(&bufferedText, surface: surface) + sendKeyEvent(surface: surface, keycode: 0x33) // kVK_Delete + previousWasCR = false default: bufferedText.unicodeScalars.append(scalar) previousWasCR = false @@ -5885,6 +5959,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private func flushPendingSocketInputIfNeeded() { guard let surface = surface, !pendingSocketInputQueue.isEmpty else { return } + guard shouldForwardTerminalInput(reason: "flushPendingSocketInput") else { return } let queued = pendingSocketInputQueue let queuedBytes = pendingSocketInputBytes pendingSocketInputQueue.removeAll(keepingCapacity: false) @@ -5951,7 +6026,7 @@ final class TerminalSurface: Identifiable, ObservableObject { @MainActor func markChildProcessExitedForTesting(reason: String = "test") { - inputLifecycleState = .childExited(reason: reason) + markChildProcessExited(reason: reason) } /// Test-only helper to deterministically simulate a released runtime surface. @@ -6790,6 +6865,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { @discardableResult private func ensureSurfaceReadyForInput() -> ghostty_surface_t? { + guard terminalSurface?.acceptsTerminalInput != false else { return nil } if let surface = surface { return surface } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 966e91129700..820e8fe4cf86 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -4729,6 +4729,7 @@ class TabManager: ObservableObject { func closePanelAfterChildExited(tabId: UUID, surfaceId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard tab.panels[surfaceId] != nil else { return } + tab.terminalPanel(for: surfaceId)?.surface.markChildProcessExited(reason: "closePanelAfterChildExited") let keepsRemoteWorkspaceOpen = tab.panels.count <= 1 && tab.shouldDemoteWorkspaceAfterChildExit(surfaceId: surfaceId) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 4f2a966c7a22..5da641c2a23f 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -6786,18 +6786,21 @@ class TerminalController { let sendStart = ProcessInfo.processInfo.systemUptime #endif let queued: Bool - if let surface = terminalPanel.surface.surface { - sendSocketText(text, surface: surface) + if terminalPanel.surface.surface != nil { + let acceptsInput = terminalPanel.surface.acceptsTerminalInput + terminalPanel.surface.sendInput(text) // 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(reason: "terminalController.v2SurfaceSendText") + if acceptsInput { + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendText") + } queued = false } else { // Avoid blocking the main actor waiting for view/surface attachment. terminalPanel.sendText(text) terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - queued = true + queued = terminalPanel.surface.acceptsTerminalInput } #if DEBUG let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0 @@ -6843,11 +6846,12 @@ class TerminalController { return } let surfaceWasReady = terminalPanel.surface.surface != nil + let acceptsInput = terminalPanel.surface.acceptsTerminalInput guard terminalPanel.surface.sendNamedKey(key) else { result = .err(code: "invalid_params", message: "Unknown key", data: ["key": key]) return } - if surfaceWasReady { + if surfaceWasReady && acceptsInput { terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey") } 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))]) @@ -15222,15 +15226,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 @@ -15238,13 +15233,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 terminalPanel.surface.surface != nil { + terminalPanel.surface.sendInput(unescaped) + } else { + terminalPanel.sendText(unescaped) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() } success = true } @@ -15312,10 +15305,9 @@ class TerminalController { // This DEBUG-only command is used by UI tests to enqueue shell work in an // existing workspace. Return once the input is queued on main so a long // payload does not hold the control-socket response open in CI. - TerminalMutationBus.shared.enqueueMainActorMutation { [weak self] in - guard let self else { return } - if let surface = terminalPanel.surface.surface { - self.sendSocketText(unescaped, surface: surface) + TerminalMutationBus.shared.enqueueMainActorMutation { + if terminalPanel.surface.surface != nil { + terminalPanel.surface.sendInput(unescaped) } else { terminalPanel.sendText(unescaped) terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -15376,20 +15368,18 @@ class TerminalController { var success = false v2MainSync { - 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 terminalPanel.surface.surface != nil { + terminalPanel.surface.sendInput(unescaped) + } else { + terminalPanel.sendText(unescaped) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() } success = true } From 8a2ee18f7088570e9c20b07c200d71a82408c414 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 21:11:30 -0700 Subject: [PATCH 03/31] Add stale keyboard garble regression --- cmuxTests/CJKIMEInputTests.swift | 208 ++++++++++++++++++++++++++++++- 1 file changed, 206 insertions(+), 2 deletions(-) diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 1a42d9e6032e..345fdb58b845 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -1532,14 +1532,15 @@ final class GhosttyKeyEquivalentRegressionTests: XCTestCase { let surfaceView: GhosttyNSView } - private func makeHostedTerminalWindow() throws -> HostedTerminalWindow { + private func makeHostedTerminalWindow(initialCommand: String? = nil) throws -> HostedTerminalWindow { _ = NSApplication.shared let surface = TerminalSurface( tabId: UUID(), context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: nil, - workingDirectory: nil + workingDirectory: nil, + initialCommand: initialCommand ) let hostedView = surface.hostedView @@ -1571,6 +1572,118 @@ final class GhosttyKeyEquivalentRegressionTests: XCTestCase { ) } + private func readTerminalText(from terminal: HostedTerminalWindow) throws -> String { + let runtimeSurface = try XCTUnwrap(terminal.surface.surface) + let topLeft = ghostty_point_s( + tag: GHOSTTY_POINT_SURFACE, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0 + ) + let bottomRight = ghostty_point_s( + tag: GHOSTTY_POINT_SURFACE, + 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(runtimeSurface, selection, &text) else { + return "" + } + defer { ghostty_surface_free_text(runtimeSurface, &text) } + guard let ptr = text.text, text.text_len > 0 else { return "" } + let data = Data(bytes: ptr, count: Int(text.text_len)) + return String(decoding: data, as: UTF8.self) + } + + private func waitForTerminalText( + from terminal: HostedTerminalWindow, + timeout: TimeInterval = 5, + matching predicate: (String) -> Bool + ) throws -> String { + let deadline = Date().addingTimeInterval(timeout) + var latest = try readTerminalText(from: terminal) + while Date() < deadline { + if predicate(latest) { return latest } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + latest = try readTerminalText(from: terminal) + } + return latest + } + + private func shellSingleQuoted(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" + } + + private func cmuxZshTerminalKeyboardResetSequence() throws -> Data { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let integrationPath = repoRoot + .appendingPathComponent("Resources/shell-integration/cmux-zsh-integration.zsh") + .path + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = [ + "-f", + "-c", + """ + source \(shellSingleQuoted(integrationPath)) >/dev/null 2>&1 || true + if (( $+functions[_cmux_reset_terminal_keyboard_protocols] )); then + _cmux_reset_terminal_keyboard_protocols + fi + """ + ] + process.environment = [ + "CMUX_TEST_FORCE_KEYBOARD_RESET": "1" + ] + let output = Pipe() + process.standardOutput = output + process.standardError = Pipe() + + try process.run() + process.waitUntilExit() + return output.fileHandleForReading.readDataToEndOfFile() + } + + private func processTerminalOutput(_ data: Data, in terminal: HostedTerminalWindow) throws { + guard !data.isEmpty else { return } + let runtimeSurface = try XCTUnwrap(terminal.surface.surface) + data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) else { return } + ghostty_surface_process_output(runtimeSurface, baseAddress, UInt(rawBuffer.count)) + } + } + + private func sendSyntheticText(_ text: String, in terminal: HostedTerminalWindow) -> Bool { + let keyCodes: [Character: UInt16] = [ + "a": 0, + "c": 8, + "h": 4, + "r": 15, + "t": 17 + ] + + for character in text { + guard let keyCode = keyCodes[character] else { return false } + let string = String(character) + let sent = terminal.hostedView.debugSendSyntheticKeyPressAndReleaseForUITest( + characters: string, + charactersIgnoringModifiers: string, + keyCode: keyCode + ) + guard sent else { return false } + } + return true + } + private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] { guard let items = pasteboard.pasteboardItems else { return [] } return items.map { item in @@ -1679,6 +1792,97 @@ final class GhosttyKeyEquivalentRegressionTests: XCTestCase { } } + func testStaleKittyKeyboardAfterClearHistoryDoesNotEncodeTypedTextAsCSIU() throws { + let captureReadyMarker = "CMUX_KBD_READY_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" + let captureMarker = "CMUX_KBD_HEX_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" + let scriptURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-kbd-capture-\(UUID().uuidString).py") + let script = """ + import os + import select + import sys + import termios + import time + import tty + + fd = 0 + sys.stdout.write("\\x1b[>3u\(captureReadyMarker)\\n") + sys.stdout.flush() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + data = bytearray() + deadline = time.monotonic() + 3.0 + idle_deadline = None + while time.monotonic() < deadline: + timeout = 0.05 + if idle_deadline is not None: + timeout = max(0.0, min(timeout, idle_deadline - time.monotonic())) + if select.select([sys.stdin], [], [], timeout)[0]: + data.extend(os.read(fd, 64)) + idle_deadline = time.monotonic() + 0.35 + if data == b"chart": + break + elif idle_deadline is not None and time.monotonic() >= idle_deadline: + break + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + print("\\r\\n\(captureMarker)=" + data.hex(), flush=True) + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: scriptURL) } + + let hostedTerminal = try makeHostedTerminalWindow( + initialCommand: "/usr/bin/python3 \(shellSingleQuoted(scriptURL.path))" + ) + let window = hostedTerminal.window + defer { window.orderOut(nil) } + + let readyText = try waitForTerminalText(from: hostedTerminal) { + $0.contains(captureReadyMarker) + } + XCTAssertTrue(readyText.contains(captureReadyMarker), "Expected Kitty enable marker before clear-history") + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + + try processTerminalOutput(cmuxZshTerminalKeyboardResetSequence(), in: hostedTerminal) + + // Mirrors the surface.clear_history socket handler path: clear_screen binding, then refresh. + XCTAssertTrue(hostedTerminal.surface.performBindingAction("clear_screen")) + hostedTerminal.surface.forceRefresh(reason: "unit.clearHistory") + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + + XCTAssertTrue( + sendSyntheticText("chart", in: hostedTerminal), + "Expected ordinary chart keyDown events to be dispatched through ghostty_surface_key" + ) + + let captureText = try waitForTerminalText(from: hostedTerminal, timeout: 5) { + $0.contains(captureMarker) + } + guard let markerRange = captureText.range(of: "\(captureMarker)=") else { + XCTFail("Expected raw PTY byte capture marker in terminal output: \(captureText)") + return + } + let hexCharacters = Set("0123456789abcdefABCDEF") + let capturedHex = captureText[markerRange.upperBound...] + .prefix { hexCharacters.contains($0) } + + XCTAssertEqual( + String(capturedHex), + "6368617274", + "Typing chart at a recovered shell prompt must write plain bytes, not Kitty CSI-u sequences" + ) + XCTAssertFalse( + captureText.contains("c9;1:3u") + || captureText.contains("h04;1:3u") + || captureText.contains("a7;1:3u") + || captureText.contains("r14;1:3u") + || captureText.contains("t16;1:3u"), + "CSI-u response bodies must not land in terminal output as printable text" + ) + } + // MARK: - Terminal Paste Fallback func testCommandVPasteStillInvokesTerminalPasteWhenMainMenuMisses() throws { From b5d349667b9d5c635d5497677df8501e07a0beae Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 21:12:03 -0700 Subject: [PATCH 04/31] Reset terminal keyboard protocols at prompt --- .../shell-integration/cmux-bash-integration.bash | 13 +++++++++++++ .../shell-integration/cmux-zsh-integration.zsh | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 6218299b070f..da26b568622b 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -144,6 +144,18 @@ _cmux_restore_scrollback_once() { fi } _cmux_restore_scrollback_once + +_cmux_reset_terminal_keyboard_protocols() { + [[ -t 1 || -n "${CMUX_TEST_FORCE_KEYBOARD_RESET:-}${CMUX_TEST_FORCE_KITTY_RESET:-}" ]] || return 0 + # A crashed TUI may leave keyboard protocol state pushed. At a fresh shell + # prompt, return terminal input encoding to plain readline bytes. + printf '\033[>m\033[<8u' +} + +_cmux_reset_kitty_keyboard_protocol() { + _cmux_reset_terminal_keyboard_protocols +} + _CMUX_CLAUDE_WRAPPER="${_CMUX_CLAUDE_WRAPPER:-}" _cmux_install_claude_wrapper() { local integration_dir="${CMUX_SHELL_INTEGRATION_DIR:-}" @@ -949,6 +961,7 @@ _cmux_bash_preexec_hook() { _cmux_prompt_command() { local last_status=$? _cmux_tmux_sync_cmux_environment + _cmux_reset_terminal_keyboard_protocols local cmux_has_unix_socket=0 _cmux_socket_is_unix && cmux_has_unix_socket=1 diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 7a6c73188872..05a7bf70dea9 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -152,6 +152,17 @@ _cmux_restore_scrollback_once() { } _cmux_restore_scrollback_once +_cmux_reset_terminal_keyboard_protocols() { + [[ -t 1 || -n "${CMUX_TEST_FORCE_KEYBOARD_RESET:-}${CMUX_TEST_FORCE_KITTY_RESET:-}" ]] || return 0 + # A crashed TUI may leave keyboard protocol state pushed. At a fresh shell + # prompt, return terminal input encoding to plain readline bytes. + printf '\033[>m\033[<8u' +} + +_cmux_reset_kitty_keyboard_protocol() { + _cmux_reset_terminal_keyboard_protocols +} + _cmux_now() { print -r -- "${EPOCHSECONDS:-$SECONDS}" } @@ -1135,6 +1146,7 @@ _cmux_precmd() { fi _cmux_stop_git_head_watch _cmux_tmux_sync_cmux_environment + _cmux_reset_terminal_keyboard_protocols local cmux_has_unix_socket=0 _cmux_socket_is_unix && cmux_has_unix_socket=1 From fadf469f4593ecbc26727b32a5f95322c391530c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 21:51:40 -0700 Subject: [PATCH 05/31] Address terminal input review feedback --- Sources/GhosttyTerminalView.swift | 261 +++++++++++++++++++++++------- Sources/TerminalController.swift | 65 ++++---- cmuxTests/CJKIMEInputTests.swift | 41 ++++- 3 files changed, 268 insertions(+), 99 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ccdafbdb4322..b72b4f492442 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4424,6 +4424,23 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + private struct PendingSocketInputDiscard { + let items: Int + let bytes: Int + } + + private struct PendingSocketInputQueueSnapshot { + let items: Int + let keys: Int + let bytes: Int + } + + private enum PendingSocketInputDrain { + case empty + case blocked(blockReason: String, discarded: PendingSocketInputDiscard?) + case ready(items: [PendingSocketInput], bytes: Int) + } + private enum TerminalInputLifecycleState: Equatable { case acceptingInput case childExited(reason: String) @@ -4482,6 +4499,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private var lastXScale: CGFloat = 0 private var lastYScale: CGFloat = 0 private let debugMetadataLock = NSLock() + private let inputLifecycleLock = NSLock() private let createdAt: Date = Date() private var runtimeSurfaceCreatedAt: Date? private var teardownRequestedAt: Date? @@ -4645,6 +4663,12 @@ final class TerminalSurface: Identifiable, ObservableObject { return body() } + private func withInputLifecycleLock(_ body: () -> T) -> T { + inputLifecycleLock.lock() + defer { inputLifecycleLock.unlock() } + return body() + } + func debugCreatedAt() -> Date { withDebugMetadataLock { createdAt } } @@ -4716,7 +4740,7 @@ final class TerminalSurface: Identifiable, ObservableObject { return surface } - private func terminalInputBlockReason() -> String? { + private func terminalInputBlockReasonLocked() -> String? { guard portalLifecycleState == .live else { return "lifecycle.\(portalLifecycleState.rawValue)" } @@ -4728,49 +4752,73 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - private func shouldForwardTerminalInput(reason: String) -> Bool { - guard let blockReason = terminalInputBlockReason() else { return true } - discardPendingSocketInput(reason: "\(reason).\(blockReason)") + private func terminalInputBlockReason() -> String? { + withInputLifecycleLock { + terminalInputBlockReasonLocked() + } + } + + private func clearPendingSocketInputLocked() -> PendingSocketInputDiscard? { + guard !pendingSocketInputQueue.isEmpty || pendingSocketInputBytes > 0 else { return nil } + let discarded = PendingSocketInputDiscard( + items: pendingSocketInputQueue.count, + bytes: pendingSocketInputBytes + ) + pendingSocketInputQueue.removeAll(keepingCapacity: false) + pendingSocketInputBytes = 0 + return discarded + } + + private func logPendingSocketInputDiscard(_ discarded: PendingSocketInputDiscard?, reason: String) { + guard let discarded else { return } #if DEBUG cmuxDebugLog( - "surface.input.discard surface=\(id.uuidString.prefix(5)) " + - "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) blocked=\(blockReason)" + "surface.socket_input.discard surface=\(id.uuidString.prefix(5)) " + + "workspace=\(tabId.uuidString.prefix(5)) items=\(discarded.items) " + + "bytes=\(discarded.bytes) reason=\(reason)" ) #endif - return false } - private func discardPendingSocketInput(reason: String) { - guard !pendingSocketInputQueue.isEmpty || pendingSocketInputBytes > 0 else { return } -#if DEBUG - let discardedItems = pendingSocketInputQueue.count - let discardedBytes = pendingSocketInputBytes -#endif - pendingSocketInputQueue.removeAll(keepingCapacity: false) - pendingSocketInputBytes = 0 + private func consumeTerminalInputIfAllowed(reason: String) -> Bool { + let result = withInputLifecycleLock { () -> (allowed: Bool, blockReason: String?, discarded: PendingSocketInputDiscard?) in + guard let blockReason = terminalInputBlockReasonLocked() else { + return (true, nil, nil) + } + return (false, blockReason, clearPendingSocketInputLocked()) + } + + guard !result.allowed, let blockReason = result.blockReason else { return true } + logPendingSocketInputDiscard(result.discarded, reason: "\(reason).\(blockReason)") #if DEBUG cmuxDebugLog( - "surface.socket_input.discard surface=\(id.uuidString.prefix(5)) " + - "workspace=\(tabId.uuidString.prefix(5)) items=\(discardedItems) " + - "bytes=\(discardedBytes) reason=\(reason)" + "surface.input.discard surface=\(id.uuidString.prefix(5)) " + + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) blocked=\(blockReason)" ) #endif + return false } + @MainActor func markChildProcessExited(reason: String) { - switch inputLifecycleState { - case .childExited: - return - case .acceptingInput: - inputLifecycleState = .childExited(reason: reason) - discardPendingSocketInput(reason: reason) + let transition = withInputLifecycleLock { () -> (changed: Bool, discarded: PendingSocketInputDiscard?) in + switch inputLifecycleState { + case .childExited: + return (false, nil) + case .acceptingInput: + inputLifecycleState = .childExited(reason: reason) + return (true, clearPendingSocketInputLocked()) + } + } + + guard transition.changed else { return } + logPendingSocketInputDiscard(transition.discarded, reason: reason) #if DEBUG - cmuxDebugLog( - "surface.lifecycle.childExited surface=\(id.uuidString.prefix(5)) " + - "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason)" - ) + cmuxDebugLog( + "surface.lifecycle.childExited surface=\(id.uuidString.prefix(5)) " + + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason)" + ) #endif - } } private static let portalHostAreaThreshold: CGFloat = 4 @@ -5116,11 +5164,11 @@ final class TerminalSurface: Identifiable, ObservableObject { // If surface doesn't exist yet, create it once the view is in a real window so // content scale and pixel geometry are derived from the actual backing context. if surface == nil { - guard allowsRuntimeSurfaceCreation() else { + if let blockReason = terminalInputBlockReason() { #if DEBUG cmuxDebugLog( "surface.attach.skip surface=\(id.uuidString.prefix(5)) " + - "reason=lifecycle.\(portalLifecycleState.rawValue)" + "reason=\(blockReason)" ) #endif return @@ -5154,14 +5202,14 @@ final class TerminalSurface: Identifiable, ObservableObject { } private func createSurface(for view: GhosttyNSView) { - guard allowsRuntimeSurfaceCreation() else { + if let blockReason = terminalInputBlockReason() { #if DEBUG cmuxDebugLog( "surface.create.skip surface=\(id.uuidString.prefix(5)) " + - "reason=lifecycle.\(portalLifecycleState.rawValue)" + "reason=\(blockReason)" ) Self.surfaceLog( - "createSurface SKIPPED surface=\(id.uuidString) tab=\(tabId.uuidString) lifecycle=\(portalLifecycleState.rawValue)" + "createSurface SKIPPED surface=\(id.uuidString) tab=\(tabId.uuidString) reason=\(blockReason)" ) #endif return @@ -5658,23 +5706,23 @@ final class TerminalSurface: Identifiable, ObservableObject { func sendText(_ text: String) { guard let data = text.data(using: .utf8), !data.isEmpty else { return } - guard shouldForwardTerminalInput(reason: "sendText") else { return } guard let surface = surface else { - enqueuePendingSocketInput(.text(data)) + guard enqueuePendingSocketInputIfAllowed(.text(data), reason: "sendText.queue") else { return } requestBackgroundSurfaceStartIfNeeded() return } + guard consumeTerminalInputIfAllowed(reason: "sendText") else { return } writeTextData(data, to: surface) } @discardableResult func sendNamedKey(_ keyName: String) -> Bool { guard let event = pendingKeyEvent(for: keyName) else { return false } - guard shouldForwardTerminalInput(reason: "sendNamedKey.\(event.label)") else { return true } if let surface = surface { + guard consumeTerminalInputIfAllowed(reason: "sendNamedKey.\(event.label)") else { return true } sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) } else { - enqueuePendingSocketInput(.key(event)) + guard enqueuePendingSocketInputIfAllowed(.key(event), reason: "sendNamedKey.\(event.label).queue") else { return true } requestBackgroundSurfaceStartIfNeeded() } return true @@ -5684,8 +5732,8 @@ final class TerminalSurface: Identifiable, ObservableObject { /// events so the shell processes them, while regular text is sent via the /// normal key-text path. Mirrors `TerminalController.sendSocketText`. func sendInput(_ text: String) { - guard shouldForwardTerminalInput(reason: "sendInput") else { return } guard let surface = surface else { return } + guard consumeTerminalInputIfAllowed(reason: "sendInput") else { return } var bufferedText = "" var previousWasCR = false for scalar in text.unicodeScalars { @@ -5934,7 +5982,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - private func enqueuePendingSocketInput(_ input: PendingSocketInput) { + private func enqueuePendingSocketInputLocked(_ input: PendingSocketInput) -> PendingSocketInputQueueSnapshot { let incomingBytes = input.estimatedBytes while !pendingSocketInputQueue.isEmpty, pendingSocketInputBytes + incomingBytes > maxPendingSocketInputBytes { @@ -5944,26 +5992,81 @@ final class TerminalSurface: Identifiable, ObservableObject { pendingSocketInputQueue.append(input) pendingSocketInputBytes += incomingBytes -#if DEBUG let pendingKeys = pendingSocketInputQueue.reduce(into: 0) { count, item in if case .key = item { count += 1 } } - cmuxDebugLog( - "surface.socket_input.queue surface=\(id.uuidString.prefix(8)) items=\(pendingSocketInputQueue.count) " + - "keys=\(pendingKeys) bytes=\(pendingSocketInputBytes)" + return PendingSocketInputQueueSnapshot( + items: pendingSocketInputQueue.count, + keys: pendingKeys, + bytes: pendingSocketInputBytes ) + } + + private func enqueuePendingSocketInputIfAllowed(_ input: PendingSocketInput, reason: String) -> Bool { + let result = withInputLifecycleLock { () -> (queued: PendingSocketInputQueueSnapshot?, blockReason: String?, discarded: PendingSocketInputDiscard?) in + guard let blockReason = terminalInputBlockReasonLocked() else { + return (enqueuePendingSocketInputLocked(input), nil, nil) + } + return (nil, blockReason, clearPendingSocketInputLocked()) + } + + if let queued = result.queued { +#if DEBUG + cmuxDebugLog( + "surface.socket_input.queue surface=\(id.uuidString.prefix(8)) items=\(queued.items) " + + "keys=\(queued.keys) bytes=\(queued.bytes)" + ) #endif + return true + } + + if let blockReason = result.blockReason { + logPendingSocketInputDiscard(result.discarded, reason: "\(reason).\(blockReason)") +#if DEBUG + cmuxDebugLog( + "surface.input.discard surface=\(id.uuidString.prefix(5)) " + + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) blocked=\(blockReason)" + ) +#endif + } + + return false } private func flushPendingSocketInputIfNeeded() { - guard let surface = surface, !pendingSocketInputQueue.isEmpty else { return } - guard shouldForwardTerminalInput(reason: "flushPendingSocketInput") else { return } - let queued = pendingSocketInputQueue - let queuedBytes = pendingSocketInputBytes - pendingSocketInputQueue.removeAll(keepingCapacity: false) - pendingSocketInputBytes = 0 + guard let surface = surface else { return } + let drain = withInputLifecycleLock { () -> PendingSocketInputDrain in + guard !pendingSocketInputQueue.isEmpty else { return .empty } + if let blockReason = terminalInputBlockReasonLocked() { + return .blocked(blockReason: blockReason, discarded: clearPendingSocketInputLocked()) + } + let queued = pendingSocketInputQueue + let queuedBytes = pendingSocketInputBytes + pendingSocketInputQueue.removeAll(keepingCapacity: false) + pendingSocketInputBytes = 0 + return .ready(items: queued, bytes: queuedBytes) + } + + let queued: [PendingSocketInput] + let queuedBytes: Int + switch drain { + case .empty: + return + case .blocked(let blockReason, let discarded): + logPendingSocketInputDiscard(discarded, reason: "flushPendingSocketInput.\(blockReason)") +#if DEBUG + cmuxDebugLog( + "surface.input.discard surface=\(id.uuidString.prefix(5)) " + + "workspace=\(tabId.uuidString.prefix(5)) reason=flushPendingSocketInput blocked=\(blockReason)" + ) +#endif + return + case .ready(let items, let bytes): + queued = items + queuedBytes = bytes + } var queuedKeys = 0 for item in queued { @@ -6838,6 +6941,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { terminalSurface?.surface } + private enum SurfaceInputReadiness { + case ready(ghostty_surface_t) + case blocked + case unavailable + } + private func applySurfaceColorScheme(force: Bool = false) { guard let surface else { return } let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) @@ -6864,16 +6973,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } @discardableResult - private func ensureSurfaceReadyForInput() -> ghostty_surface_t? { - guard terminalSurface?.acceptsTerminalInput != false else { return nil } + private func ensureSurfaceReadyForInput() -> SurfaceInputReadiness { + guard terminalSurface?.acceptsTerminalInput != false else { return .blocked } if let surface = surface { - return surface + return .ready(surface) } - guard window != nil else { return nil } + guard window != nil else { return .unavailable } terminalSurface?.attachToView(self) updateSurfaceSize(size: bounds.size) applySurfaceColorScheme(force: true) - return surface + guard let surface else { return .unavailable } + return .ready(surface) } private func requestInputRecoveryAfterSurfaceMiss(reason: String) { @@ -6888,11 +6998,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { @discardableResult func prepareSurfaceForPaste(reason: String) -> Bool { - guard ensureSurfaceReadyForInput() != nil else { + switch ensureSurfaceReadyForInput() { + case .ready: + return true + case .blocked: + return false + case .unavailable: requestInputRecoveryAfterSurfaceMiss(reason: reason) return false } - return true } func performBindingAction(_ action: String) -> Bool { @@ -7334,7 +7448,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif } } - if result, shouldApplySurfaceFocus, let surface = ensureSurfaceReadyForInput() { + if result, + shouldApplySurfaceFocus, + case .ready(let surface) = ensureSurfaceReadyForInput() { let now = CACurrentMediaTime() let deltaMs = (now - lastScrollEventTime) * 1000 Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))") @@ -7469,7 +7585,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard event.type == .keyDown else { return false } guard let fr = window?.firstResponder as? NSView, fr === self || fr.isDescendant(of: self) else { return false } - guard let surface = ensureSurfaceReadyForInput() else { return false } + let surface: ghostty_surface_t + switch ensureSurfaceReadyForInput() { + case .ready(let readySurface): + surface = readySurface + case .blocked: + return true + case .unavailable: + return false + } // Let non-Cmd keys flow to keyDown while IME is composing; Cmd shortcuts still work. if hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) { @@ -7638,7 +7762,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } let ensureSurfaceStart = ProcessInfo.processInfo.systemUptime #endif - guard let surface = ensureSurfaceReadyForInput() else { + let surface: ghostty_surface_t + switch ensureSurfaceReadyForInput() { + case .ready(let readySurface): + surface = readySurface + case .blocked: +#if DEBUG + ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0 +#endif + return + case .unavailable: requestInputRecoveryAfterSurfaceMiss(reason: "keyDown.missingSurface") #if DEBUG ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0 @@ -8106,7 +8239,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return } - guard let surface = ensureSurfaceReadyForInput() else { + let surface: ghostty_surface_t + switch ensureSurfaceReadyForInput() { + case .ready(let readySurface): + surface = readySurface + case .blocked: + return + case .unavailable: super.keyUp(with: event) return } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 9f8e3c8b41fd..23ab5c007cef 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -6814,27 +6814,17 @@ class TerminalController { #if DEBUG let sendStart = ProcessInfo.processInfo.systemUptime #endif - let queued: Bool - if terminalPanel.surface.surface != nil { - let acceptsInput = terminalPanel.surface.acceptsTerminalInput - terminalPanel.surface.sendInput(text) - // 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. - if acceptsInput { - terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendText") - } - queued = false - } else { - // Avoid blocking the main actor waiting for view/surface attachment. - terminalPanel.sendText(text) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - queued = terminalPanel.surface.acceptsTerminalInput + let routed = Self.routeUnescapedInput(text, to: terminalPanel) + // 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. + if routed.shouldForceRefresh { + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendText") } #if DEBUG let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0 cmuxDebugLog( - "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))" + "socket.surface.send_text workspace=\(ws.id.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) queued=\(routed.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), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) @@ -15454,6 +15444,26 @@ class TerminalController { } } + private struct RoutedTerminalInputResult { + let queued: Bool + let shouldForceRefresh: Bool + } + + private static func routeUnescapedInput(_ text: String, to terminalPanel: TerminalPanel) -> RoutedTerminalInputResult { + if terminalPanel.surface.surface != nil { + let acceptsInput = terminalPanel.surface.acceptsTerminalInput + terminalPanel.surface.sendInput(text) + return RoutedTerminalInputResult(queued: false, shouldForceRefresh: acceptsInput) + } + + terminalPanel.sendText(text) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + return RoutedTerminalInputResult( + queued: terminalPanel.surface.acceptsTerminalInput, + shouldForceRefresh: false + ) + } + private func sendInput(_ text: String) -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } @@ -15474,12 +15484,7 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - if terminalPanel.surface.surface != nil { - terminalPanel.surface.sendInput(unescaped) - } else { - terminalPanel.sendText(unescaped) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - } + _ = Self.routeUnescapedInput(unescaped, to: terminalPanel) success = true } if let error { return error } @@ -15547,12 +15552,7 @@ class TerminalController { // existing workspace. Return once the input is queued on main so a long // payload does not hold the control-socket response open in CI. TerminalMutationBus.shared.enqueueMainActorMutation { - if terminalPanel.surface.surface != nil { - terminalPanel.surface.sendInput(unescaped) - } else { - terminalPanel.sendText(unescaped) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - } + _ = Self.routeUnescapedInput(unescaped, to: terminalPanel) } success = true } @@ -15616,12 +15616,7 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - if terminalPanel.surface.surface != nil { - terminalPanel.surface.sendInput(unescaped) - } else { - terminalPanel.sendText(unescaped) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - } + _ = Self.routeUnescapedInput(unescaped, to: terminalPanel) success = true } diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 345fdb58b845..2f8b167cded0 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -1635,9 +1635,15 @@ final class GhosttyKeyEquivalentRegressionTests: XCTestCase { "-f", "-c", """ - source \(shellSingleQuoted(integrationPath)) >/dev/null 2>&1 || true + if ! source \(shellSingleQuoted(integrationPath)) >/dev/null; then + print -u2 "failed to source cmux zsh integration" + exit 1 + fi if (( $+functions[_cmux_reset_terminal_keyboard_protocols] )); then _cmux_reset_terminal_keyboard_protocols + else + print -u2 "_cmux_reset_terminal_keyboard_protocols is missing" + exit 2 fi """ ] @@ -1645,12 +1651,41 @@ final class GhosttyKeyEquivalentRegressionTests: XCTestCase { "CMUX_TEST_FORCE_KEYBOARD_RESET": "1" ] let output = Pipe() + let standardError = Pipe() process.standardOutput = output - process.standardError = Pipe() + process.standardError = standardError try process.run() process.waitUntilExit() - return output.fileHandleForReading.readDataToEndOfFile() + let outputData = output.fileHandleForReading.readDataToEndOfFile() + let errorData = standardError.fileHandleForReading.readDataToEndOfFile() + let outputText = String(decoding: outputData, as: UTF8.self) + let errorText = String(decoding: errorData, as: UTF8.self) + + guard process.terminationStatus == 0 else { + throw NSError( + domain: "CJKIMEInputTests", + code: Int(process.terminationStatus), + userInfo: [ + NSLocalizedDescriptionKey: + "cmux zsh keyboard reset helper failed with status \(process.terminationStatus); stdout=\(outputText.debugDescription) stderr=\(errorText.debugDescription)" + ] + ) + } + + let expected = Data("\u{1B}[>m\u{1B}[<8u".utf8) + guard outputData == expected else { + throw NSError( + domain: "CJKIMEInputTests", + code: 3, + userInfo: [ + NSLocalizedDescriptionKey: + "cmux zsh keyboard reset helper emitted \(outputData as NSData), expected \(expected as NSData); stdout=\(outputText.debugDescription) stderr=\(errorText.debugDescription)" + ] + ) + } + + return outputData } private func processTerminalOutput(_ data: Data, in terminal: HostedTerminalWindow) throws { From 9fc629dbfc09762731c2132aedbdd983ed9f4bac Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 22:00:00 -0700 Subject: [PATCH 06/31] Address follow-up input review feedback --- Sources/GhosttyTerminalView.swift | 66 +++++++++++++++++++++---------- Sources/TerminalController.swift | 15 +++++-- cmuxTests/CJKIMEInputTests.swift | 5 ++- 3 files changed, 60 insertions(+), 26 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index b72b4f492442..cc7287973e3b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4441,7 +4441,7 @@ final class TerminalSurface: Identifiable, ObservableObject { case ready(items: [PendingSocketInput], bytes: Int) } - private enum TerminalInputLifecycleState: Equatable { + private enum TerminalInputLifecycleState { case acceptingInput case childExited(reason: String) } @@ -4457,7 +4457,11 @@ final class TerminalSurface: Identifiable, ObservableObject { /// `ghostty_surface_inherited_config`, `ghostty_surface_quicklook_font`), /// call `liveSurfaceForGhosttyAccess(reason:)` so stale freed pointers are /// rejected and quarantined. - var hasLiveSurface: Bool { surface != nil && portalLifecycleState == .live } + var hasLiveSurface: Bool { + withInputLifecycleLock { + surface != nil && portalLifecycleState == .live + } + } var acceptsTerminalInput: Bool { terminalInputBlockReason() == nil @@ -4650,11 +4654,15 @@ final class TerminalSurface: Identifiable, ObservableObject { } func portalBindingGeneration() -> UInt64 { - portalLifecycleGeneration + withInputLifecycleLock { + portalLifecycleGeneration + } } func portalBindingStateLabel() -> String { - portalLifecycleState.rawValue + withInputLifecycleLock { + portalLifecycleState.rawValue + } } private func withDebugMetadataLock(_ body: () -> T) -> T { @@ -4702,14 +4710,16 @@ final class TerminalSurface: Identifiable, ObservableObject { } func canAcceptPortalBinding(expectedSurfaceId: UUID?, expectedGeneration: UInt64?) -> Bool { - guard portalLifecycleState == .live else { return false } - if let expectedSurfaceId, expectedSurfaceId != id { - return false - } - if let expectedGeneration, expectedGeneration != portalLifecycleGeneration { - return false + withInputLifecycleLock { + guard portalLifecycleState == .live else { return false } + if let expectedSurfaceId, expectedSurfaceId != id { + return false + } + if let expectedGeneration, expectedGeneration != portalLifecycleGeneration { + return false + } + return true } - return true } @MainActor @@ -4972,29 +4982,37 @@ final class TerminalSurface: Identifiable, ObservableObject { } func beginPortalCloseLifecycle(reason: String) { - guard portalLifecycleState != .closed else { return } - guard portalLifecycleState != .closing else { return } recordTeardownRequest(reason: reason) - portalLifecycleState = .closing - portalLifecycleGeneration &+= 1 + let generation = withInputLifecycleLock { () -> UInt64? in + guard portalLifecycleState != .closed else { return nil } + guard portalLifecycleState != .closing else { return nil } + portalLifecycleState = .closing + portalLifecycleGeneration &+= 1 + return portalLifecycleGeneration + } + guard let generation else { return } #if DEBUG cmuxDebugLog( "surface.lifecycle.close.begin surface=\(id.uuidString.prefix(5)) " + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " + - "generation=\(portalLifecycleGeneration)" + "generation=\(generation)" ) #endif } private func markPortalLifecycleClosed(reason: String) { - guard portalLifecycleState != .closed else { return } - portalLifecycleState = .closed - portalLifecycleGeneration &+= 1 + let generation = withInputLifecycleLock { () -> UInt64? in + guard portalLifecycleState != .closed else { return nil } + portalLifecycleState = .closed + portalLifecycleGeneration &+= 1 + return portalLifecycleGeneration + } + guard let generation else { return } #if DEBUG cmuxDebugLog( "surface.lifecycle.close.sealed surface=\(id.uuidString.prefix(5)) " + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " + - "generation=\(portalLifecycleGeneration)" + "generation=\(generation)" ) #endif } @@ -8275,7 +8293,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func flagsChanged(with event: NSEvent) { - guard let surface = surface else { + let surface: ghostty_surface_t + switch ensureSurfaceReadyForInput() { + case .ready(let readySurface): + surface = readySurface + case .blocked: + return + case .unavailable: super.flagsChanged(with: event) return } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 23ab5c007cef..2ef73a5d6f67 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -15484,7 +15484,10 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - _ = Self.routeUnescapedInput(unescaped, to: terminalPanel) + let routed = Self.routeUnescapedInput(unescaped, to: terminalPanel) + if routed.shouldForceRefresh { + terminalPanel.surface.forceRefresh(reason: "terminalController.sendInput") + } success = true } if let error { return error } @@ -15552,7 +15555,10 @@ class TerminalController { // existing workspace. Return once the input is queued on main so a long // payload does not hold the control-socket response open in CI. TerminalMutationBus.shared.enqueueMainActorMutation { - _ = Self.routeUnescapedInput(unescaped, to: terminalPanel) + let routed = Self.routeUnescapedInput(unescaped, to: terminalPanel) + if routed.shouldForceRefresh { + terminalPanel.surface.forceRefresh(reason: "terminalController.sendInputToWorkspace") + } } success = true } @@ -15616,7 +15622,10 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - _ = Self.routeUnescapedInput(unescaped, to: terminalPanel) + let routed = Self.routeUnescapedInput(unescaped, to: terminalPanel) + if routed.shouldForceRefresh { + terminalPanel.surface.forceRefresh(reason: "terminalController.sendInputToSurface") + } success = true } diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 2f8b167cded0..c826b8410b35 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -1880,13 +1880,14 @@ final class GhosttyKeyEquivalentRegressionTests: XCTestCase { XCTAssertTrue(readyText.contains(captureReadyMarker), "Expected Kitty enable marker before clear-history") RunLoop.current.run(until: Date().addingTimeInterval(0.2)) - try processTerminalOutput(cmuxZshTerminalKeyboardResetSequence(), in: hostedTerminal) - // Mirrors the surface.clear_history socket handler path: clear_screen binding, then refresh. XCTAssertTrue(hostedTerminal.surface.performBindingAction("clear_screen")) hostedTerminal.surface.forceRefresh(reason: "unit.clearHistory") RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + try processTerminalOutput(cmuxZshTerminalKeyboardResetSequence(), in: hostedTerminal) + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + XCTAssertTrue( sendSyntheticText("chart", in: hostedTerminal), "Expected ordinary chart keyDown events to be dispatched through ghostty_surface_key" From 25dd3da8d339aa93632813ddaa8bfefd761628dd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 22:13:05 -0700 Subject: [PATCH 07/31] Address child exit input feedback --- Sources/GhosttyTerminalView.swift | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index cc7287973e3b..90b0fd8b09da 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2069,6 +2069,10 @@ class GhosttyApp { ) #endif + if !needsConfirmClose { + callbackTerminalSurface?.markChildProcessInputExited(reason: "closeSurfaceCallback") + } + DispatchQueue.main.async { if !needsConfirmClose { callbackTerminalSurface?.markChildProcessExited(reason: "closeSurfaceCallback") @@ -3761,6 +3765,7 @@ class GhosttyApp { #endif // Keep host-close async to avoid re-entrant close/deinit while Ghostty is still // dispatching this action callback. + callbackTerminalSurface?.markChildProcessInputExited(reason: "showChildExitedAction") DispatchQueue.main.async { callbackTerminalSurface?.markChildProcessExited(reason: "showChildExitedAction") guard let app = AppDelegate.shared else { return } @@ -4809,8 +4814,8 @@ final class TerminalSurface: Identifiable, ObservableObject { return false } - @MainActor - func markChildProcessExited(reason: String) { + @discardableResult + func markChildProcessInputExited(reason: String) -> Bool { let transition = withInputLifecycleLock { () -> (changed: Bool, discarded: PendingSocketInputDiscard?) in switch inputLifecycleState { case .childExited: @@ -4821,7 +4826,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - guard transition.changed else { return } + guard transition.changed else { return false } logPendingSocketInputDiscard(transition.discarded, reason: reason) #if DEBUG cmuxDebugLog( @@ -4829,6 +4834,12 @@ final class TerminalSurface: Identifiable, ObservableObject { "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason)" ) #endif + return true + } + + @MainActor + func markChildProcessExited(reason: String) { + _ = markChildProcessInputExited(reason: reason) } private static let portalHostAreaThreshold: CGFloat = 4 @@ -6010,14 +6021,20 @@ final class TerminalSurface: Identifiable, ObservableObject { pendingSocketInputQueue.append(input) pendingSocketInputBytes += incomingBytes +#if DEBUG let pendingKeys = pendingSocketInputQueue.reduce(into: 0) { count, item in if case .key = item { count += 1 } } +#endif return PendingSocketInputQueueSnapshot( items: pendingSocketInputQueue.count, +#if DEBUG keys: pendingKeys, +#else + keys: 0, +#endif bytes: pendingSocketInputBytes ) } From 153a8bfa406b3f68cff15f45dace14b8e4fca2b5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 22:16:15 -0700 Subject: [PATCH 08/31] Serialize terminal socket writes with child exit --- Sources/GhosttyTerminalView.swift | 120 ++++++++++++++++-------------- 1 file changed, 64 insertions(+), 56 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index c0f793e72a4e..faae44226a92 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4429,7 +4429,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private enum PendingSocketInputDrain { case empty case blocked(blockReason: String, discarded: PendingSocketInputDiscard?) - case ready(items: [PendingSocketInput], bytes: Int) + case flushed(PendingSocketInputQueueSnapshot) } private enum TerminalInputLifecycleState { @@ -4781,9 +4781,10 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif } - private func consumeTerminalInputIfAllowed(reason: String) -> Bool { + private func performTerminalInputIfAllowed(reason: String, operation: () -> Void) -> Bool { let result = withInputLifecycleLock { () -> (allowed: Bool, blockReason: String?, discarded: PendingSocketInputDiscard?) in guard let blockReason = terminalInputBlockReasonLocked() else { + operation() return (true, nil, nil) } return (false, blockReason, clearPendingSocketInputLocked()) @@ -5726,16 +5727,18 @@ final class TerminalSurface: Identifiable, ObservableObject { requestBackgroundSurfaceStartIfNeeded() return } - guard consumeTerminalInputIfAllowed(reason: "sendText") else { return } - writeTextData(data, to: surface) + _ = performTerminalInputIfAllowed(reason: "sendText") { + writeTextData(data, to: surface) + } } @discardableResult func sendNamedKey(_ keyName: String) -> Bool { guard let event = pendingKeyEvent(for: keyName) else { return false } if let surface = surface { - guard consumeTerminalInputIfAllowed(reason: "sendNamedKey.\(event.label)") else { return true } - sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) + _ = performTerminalInputIfAllowed(reason: "sendNamedKey.\(event.label)") { + sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) + } } else { guard enqueuePendingSocketInputIfAllowed(.key(event), reason: "sendNamedKey.\(event.label).queue") else { return true } requestBackgroundSurfaceStartIfNeeded() @@ -5748,39 +5751,40 @@ final class TerminalSurface: Identifiable, ObservableObject { /// normal key-text path. Mirrors `TerminalController.sendSocketText`. func sendInput(_ text: String) { guard let surface = surface else { return } - guard consumeTerminalInputIfAllowed(reason: "sendInput") else { return } - var bufferedText = "" - var previousWasCR = false - for scalar in text.unicodeScalars { - switch scalar.value { - case 0x0A: // \n — skip if preceded by \r (already sent Return) - if !previousWasCR { + _ = performTerminalInputIfAllowed(reason: "sendInput") { + var bufferedText = "" + var previousWasCR = false + for scalar in text.unicodeScalars { + switch scalar.value { + case 0x0A: // \n — skip if preceded by \r (already sent Return) + if !previousWasCR { + flushText(&bufferedText, surface: surface) + sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return + } + previousWasCR = false + case 0x0D: flushText(&bufferedText, surface: surface) sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return + previousWasCR = true + case 0x09: + flushText(&bufferedText, surface: surface) + sendKeyEvent(surface: surface, keycode: 0x30) // kVK_Tab + previousWasCR = false + case 0x1B: + flushText(&bufferedText, surface: surface) + sendKeyEvent(surface: surface, keycode: 0x35) // kVK_Escape + previousWasCR = false + case 0x7F: + flushText(&bufferedText, surface: surface) + sendKeyEvent(surface: surface, keycode: 0x33) // kVK_Delete + previousWasCR = false + default: + bufferedText.unicodeScalars.append(scalar) + previousWasCR = false } - previousWasCR = false - case 0x0D: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return - previousWasCR = true - case 0x09: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x30) // kVK_Tab - previousWasCR = false - case 0x1B: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x35) // kVK_Escape - previousWasCR = false - case 0x7F: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x33) // kVK_Delete - previousWasCR = false - default: - bufferedText.unicodeScalars.append(scalar) - previousWasCR = false } + flushText(&bufferedText, surface: surface) } - flushText(&bufferedText, surface: surface) } private func flushText(_ buffer: inout String, surface: ghostty_surface_t) { @@ -6067,11 +6071,27 @@ final class TerminalSurface: Identifiable, ObservableObject { let queuedBytes = pendingSocketInputBytes pendingSocketInputQueue.removeAll(keepingCapacity: false) pendingSocketInputBytes = 0 - return .ready(items: queued, bytes: queuedBytes) +#if DEBUG + var queuedKeys = 0 +#endif + for item in queued { + switch item { + case .text(let chunk): + writeTextData(chunk, to: surface) + case .key(let event): +#if DEBUG + queuedKeys += 1 +#endif + sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) + } + } +#if DEBUG + return .flushed(PendingSocketInputQueueSnapshot(items: queued.count, keys: queuedKeys, bytes: queuedBytes)) +#else + return .flushed(PendingSocketInputQueueSnapshot(items: queued.count, keys: 0, bytes: queuedBytes)) +#endif } - let queued: [PendingSocketInput] - let queuedBytes: Int switch drain { case .empty: return @@ -6084,27 +6104,15 @@ final class TerminalSurface: Identifiable, ObservableObject { ) #endif return - case .ready(let items, let bytes): - queued = items - queuedBytes = bytes - } - - var queuedKeys = 0 - for item in queued { - switch item { - case .text(let chunk): - writeTextData(chunk, to: surface) - case .key(let event): - queuedKeys += 1 - sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) - } - } + case .flushed(let summary): #if DEBUG - cmuxDebugLog( - "surface.socket_input.flush surface=\(id.uuidString.prefix(8)) items=\(queued.count) " + - "keys=\(queuedKeys) bytes=\(queuedBytes)" - ) + cmuxDebugLog( + "surface.socket_input.flush surface=\(id.uuidString.prefix(8)) items=\(summary.items) " + + "keys=\(summary.keys) bytes=\(summary.bytes)" + ) #endif + return + } } func performBindingAction(_ action: String) -> Bool { From c574fb3ce7c5077fb4e1ebbb3eda88c4bcf625a3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 22:51:25 -0700 Subject: [PATCH 09/31] Fix terminal input CI blockers --- .../cmux-zsh-integration.zsh | 1 - Sources/GhosttyTerminalView.swift | 127 +++++++----------- 2 files changed, 50 insertions(+), 78 deletions(-) diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 901ce2991d37..efc2e59c01b2 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -1160,7 +1160,6 @@ _cmux_precmd() { (( cmux_has_unix_socket )) || _cmux_has_port_scan_transport || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 if [[ -n "$CMUX_PANEL_ID" ]]; then - _cmux_reset_terminal_keyboard_protocols _cmux_report_shell_activity_state prompt fi diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index faae44226a92..2123cdbd3a6d 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4429,7 +4429,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private enum PendingSocketInputDrain { case empty case blocked(blockReason: String, discarded: PendingSocketInputDiscard?) - case flushed(PendingSocketInputQueueSnapshot) + case ready(items: [PendingSocketInput], bytes: Int) } private enum TerminalInputLifecycleState { @@ -4781,10 +4781,9 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif } - private func performTerminalInputIfAllowed(reason: String, operation: () -> Void) -> Bool { + private func consumeTerminalInputIfAllowed(reason: String) -> Bool { let result = withInputLifecycleLock { () -> (allowed: Bool, blockReason: String?, discarded: PendingSocketInputDiscard?) in guard let blockReason = terminalInputBlockReasonLocked() else { - operation() return (true, nil, nil) } return (false, blockReason, clearPendingSocketInputLocked()) @@ -5727,18 +5726,16 @@ final class TerminalSurface: Identifiable, ObservableObject { requestBackgroundSurfaceStartIfNeeded() return } - _ = performTerminalInputIfAllowed(reason: "sendText") { - writeTextData(data, to: surface) - } + guard consumeTerminalInputIfAllowed(reason: "sendText") else { return } + writeTextData(data, to: surface) } @discardableResult func sendNamedKey(_ keyName: String) -> Bool { guard let event = pendingKeyEvent(for: keyName) else { return false } if let surface = surface { - _ = performTerminalInputIfAllowed(reason: "sendNamedKey.\(event.label)") { - sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) - } + guard consumeTerminalInputIfAllowed(reason: "sendNamedKey.\(event.label)") else { return true } + sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) } else { guard enqueuePendingSocketInputIfAllowed(.key(event), reason: "sendNamedKey.\(event.label).queue") else { return true } requestBackgroundSurfaceStartIfNeeded() @@ -5751,40 +5748,27 @@ final class TerminalSurface: Identifiable, ObservableObject { /// normal key-text path. Mirrors `TerminalController.sendSocketText`. func sendInput(_ text: String) { guard let surface = surface else { return } - _ = performTerminalInputIfAllowed(reason: "sendInput") { - var bufferedText = "" - var previousWasCR = false - for scalar in text.unicodeScalars { - switch scalar.value { - case 0x0A: // \n — skip if preceded by \r (already sent Return) - if !previousWasCR { - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return - } - previousWasCR = false - case 0x0D: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return - previousWasCR = true - case 0x09: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x30) // kVK_Tab - previousWasCR = false - case 0x1B: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x35) // kVK_Escape - previousWasCR = false - case 0x7F: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x33) // kVK_Delete - previousWasCR = false - default: - bufferedText.unicodeScalars.append(scalar) - previousWasCR = false - } + guard consumeTerminalInputIfAllowed(reason: "sendInput") else { return } + var bufferedText = "" + for scalar in text.unicodeScalars { + switch scalar.value { + case 0x0A, 0x0D: + flushText(&bufferedText, surface: surface) + sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return + case 0x09: + flushText(&bufferedText, surface: surface) + sendKeyEvent(surface: surface, keycode: 0x30) // kVK_Tab + case 0x1B: + flushText(&bufferedText, surface: surface) + sendKeyEvent(surface: surface, keycode: 0x35) // kVK_Escape + case 0x7F: + flushText(&bufferedText, surface: surface) + sendKeyEvent(surface: surface, keycode: 0x33) // kVK_Delete + default: + bufferedText.unicodeScalars.append(scalar) } - flushText(&bufferedText, surface: surface) } + flushText(&bufferedText, surface: surface) } private func flushText(_ buffer: inout String, surface: ghostty_surface_t) { @@ -6011,8 +5995,9 @@ final class TerminalSurface: Identifiable, ObservableObject { pendingSocketInputQueue.append(input) pendingSocketInputBytes += incomingBytes + var pendingKeys = 0 #if DEBUG - let pendingKeys = pendingSocketInputQueue.reduce(into: 0) { count, item in + pendingKeys = pendingSocketInputQueue.reduce(into: 0) { count, item in if case .key = item { count += 1 } @@ -6020,11 +6005,7 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif return PendingSocketInputQueueSnapshot( items: pendingSocketInputQueue.count, -#if DEBUG keys: pendingKeys, -#else - keys: 0, -#endif bytes: pendingSocketInputBytes ) } @@ -6071,27 +6052,11 @@ final class TerminalSurface: Identifiable, ObservableObject { let queuedBytes = pendingSocketInputBytes pendingSocketInputQueue.removeAll(keepingCapacity: false) pendingSocketInputBytes = 0 -#if DEBUG - var queuedKeys = 0 -#endif - for item in queued { - switch item { - case .text(let chunk): - writeTextData(chunk, to: surface) - case .key(let event): -#if DEBUG - queuedKeys += 1 -#endif - sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) - } - } -#if DEBUG - return .flushed(PendingSocketInputQueueSnapshot(items: queued.count, keys: queuedKeys, bytes: queuedBytes)) -#else - return .flushed(PendingSocketInputQueueSnapshot(items: queued.count, keys: 0, bytes: queuedBytes)) -#endif + return .ready(items: queued, bytes: queuedBytes) } + let queued: [PendingSocketInput] + let queuedBytes: Int switch drain { case .empty: return @@ -6104,15 +6069,27 @@ final class TerminalSurface: Identifiable, ObservableObject { ) #endif return - case .flushed(let summary): + case .ready(let items, let bytes): + queued = items + queuedBytes = bytes + } + + var queuedKeys = 0 + for item in queued { + switch item { + case .text(let chunk): + writeTextData(chunk, to: surface) + case .key(let event): + queuedKeys += 1 + sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) + } + } #if DEBUG - cmuxDebugLog( - "surface.socket_input.flush surface=\(id.uuidString.prefix(8)) items=\(summary.items) " + - "keys=\(summary.keys) bytes=\(summary.bytes)" - ) + cmuxDebugLog( + "surface.socket_input.flush surface=\(id.uuidString.prefix(8)) items=\(queued.count) " + + "keys=\(queuedKeys) bytes=\(queuedBytes)" + ) #endif - return - } } func performBindingAction(_ action: String) -> Bool { @@ -7612,7 +7589,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { case .ready(let readySurface): surface = readySurface case .blocked: - return true + return false case .unavailable: return false } @@ -8239,10 +8216,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif override func keyUp(with event: NSEvent) { - if imeSuppressedKeyUpKeyCodes.remove(event.keyCode) != nil { - return - } - let surface: ghostty_surface_t switch ensureSurfaceReadyForInput() { case .ready(let readySurface): From 8cd9e2e881992fbe5fe6139b61096b557388e005 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 23:02:43 -0700 Subject: [PATCH 10/31] Unify terminal socket input routing --- Sources/GhosttyTerminalView.swift | 110 ++++++++++++++++++++++-------- Sources/TerminalController.swift | 85 ++--------------------- 2 files changed, 87 insertions(+), 108 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2123cdbd3a6d..abf4c79a8c07 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5745,34 +5745,92 @@ final class TerminalSurface: Identifiable, ObservableObject { /// Send text with control characters (Return, Tab, etc.) delivered as key /// events so the shell processes them, while regular text is sent via the - /// normal key-text path. Mirrors `TerminalController.sendSocketText`. + /// same key-text path used for attached socket input. func sendInput(_ text: String) { - guard let surface = surface else { return } + let inputs = parsedSocketInputs(from: text) + guard !inputs.isEmpty else { return } + + guard let surface = surface else { + var queued = false + for input in inputs { + queued = enqueuePendingSocketInputIfAllowed(input, reason: "sendInput.queue") || queued + } + if queued { + requestBackgroundSurfaceStartIfNeeded() + } + return + } + guard consumeTerminalInputIfAllowed(reason: "sendInput") else { return } + for input in inputs { + writePendingSocketInput(input, to: surface) + } + } + + private func parsedSocketInputs(from text: String) -> [PendingSocketInput] { + guard !text.isEmpty else { return [] } + + var inputs: [PendingSocketInput] = [] + inputs.reserveCapacity(8) var bufferedText = "" + bufferedText.reserveCapacity(text.count) + var previousWasCR = false + + func flushBufferedText() { + guard !bufferedText.isEmpty else { return } + inputs.append(.text(Data(bufferedText.utf8))) + bufferedText.removeAll(keepingCapacity: true) + } + + func appendKey(keycode: UInt32, label: String) { + inputs.append(.key(PendingKeyEvent(keycode: keycode, mods: GHOSTTY_MODS_NONE, label: label))) + } + for scalar in text.unicodeScalars { switch scalar.value { - case 0x0A, 0x0D: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return + case 0x0A: + if !previousWasCR { + flushBufferedText() + appendKey(keycode: 0x24, label: "input.return") + } + previousWasCR = false + case 0x0D: + flushBufferedText() + appendKey(keycode: 0x24, label: "input.return") + previousWasCR = true case 0x09: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x30) // kVK_Tab + flushBufferedText() + appendKey(keycode: 0x30, label: "input.tab") + previousWasCR = false case 0x1B: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x35) // kVK_Escape + flushBufferedText() + appendKey(keycode: 0x35, label: "input.escape") + previousWasCR = false case 0x7F: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x33) // kVK_Delete + flushBufferedText() + appendKey(keycode: 0x33, label: "input.delete") + previousWasCR = false default: bufferedText.unicodeScalars.append(scalar) + previousWasCR = false } } - flushText(&bufferedText, surface: surface) + flushBufferedText() + return inputs } - private func flushText(_ buffer: inout String, surface: ghostty_surface_t) { - guard !buffer.isEmpty else { return } + private func writePendingSocketInput(_ input: PendingSocketInput, to surface: ghostty_surface_t) { + switch input { + case .text(let data): + sendTextKeyData(data, to: surface) + case .key(let event): + sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) + } + } + + private func sendTextKeyData(_ data: Data, to surface: ghostty_surface_t) { + guard !data.isEmpty else { return } + let text = String(decoding: data, as: UTF8.self) var keyEvent = ghostty_input_key_s() keyEvent.action = GHOSTTY_ACTION_PRESS keyEvent.keycode = 0 @@ -5780,11 +5838,17 @@ final class TerminalSurface: Identifiable, ObservableObject { keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.unshifted_codepoint = 0 keyEvent.composing = false - buffer.withCString { ptr in + text.withCString { ptr in keyEvent.text = ptr _ = ghostty_surface_key(surface, keyEvent) } - buffer.removeAll(keepingCapacity: true) + } + + 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 sendKeyEvent( @@ -5842,13 +5906,6 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - 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 keycodeForLetter(_ letter: Character) -> UInt32? { switch String(letter).lowercased() { case "a": return UInt32(kVK_ANSI_A) @@ -6076,13 +6133,10 @@ final class TerminalSurface: Identifiable, ObservableObject { var queuedKeys = 0 for item in queued { - switch item { - case .text(let chunk): - writeTextData(chunk, to: surface) - case .key(let event): + if case .key = item { queuedKeys += 1 - sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) } + writePendingSocketInput(item, to: surface) } #if DEBUG cmuxDebugLog( diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 2ef73a5d6f67..142c52d3838b 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -15357,34 +15357,6 @@ class TerminalController { return result.isEmpty ? "ERROR: No tab selected" : result } - private func sendKeyEvent( - surface: ghostty_surface_t, - keycode: UInt32, - mods: ghostty_input_mods_e = GHOSTTY_MODS_NONE, - text: String? = nil - ) { - var keyEvent = ghostty_input_key_s() - keyEvent.action = GHOSTTY_ACTION_PRESS - keyEvent.keycode = keycode - keyEvent.mods = mods - keyEvent.consumed_mods = GHOSTTY_MODS_NONE - keyEvent.unshifted_codepoint = 0 - keyEvent.composing = false - if let text { - text.withCString { ptr in - keyEvent.text = ptr - _ = ghostty_surface_key(surface, keyEvent) - } - } else { - keyEvent.text = nil - _ = ghostty_surface_key(surface, keyEvent) - } - } - - private func sendTextEvent(surface: ghostty_surface_t, text: String) { - sendKeyEvent(surface: surface, keycode: 0, text: text) - } - enum SocketTextChunk: Equatable { case text(String) case control(UnicodeScalar) @@ -15425,42 +15397,18 @@ class TerminalController { } } - private func handleControlScalar(_ scalar: UnicodeScalar, surface: ghostty_surface_t) -> Bool { - switch scalar.value { - case 0x0A, 0x0D: - sendKeyEvent(surface: surface, keycode: UInt32(kVK_Return)) - return true - case 0x09: - sendKeyEvent(surface: surface, keycode: UInt32(kVK_Tab)) - return true - case 0x1B: - sendKeyEvent(surface: surface, keycode: UInt32(kVK_Escape)) - return true - case 0x7F: - sendKeyEvent(surface: surface, keycode: UInt32(kVK_Delete)) - return true - default: - return false - } - } - private struct RoutedTerminalInputResult { let queued: Bool let shouldForceRefresh: Bool } private static func routeUnescapedInput(_ text: String, to terminalPanel: TerminalPanel) -> RoutedTerminalInputResult { - if terminalPanel.surface.surface != nil { - let acceptsInput = terminalPanel.surface.acceptsTerminalInput - terminalPanel.surface.sendInput(text) - return RoutedTerminalInputResult(queued: false, shouldForceRefresh: acceptsInput) - } - - terminalPanel.sendText(text) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + let hasSurface = terminalPanel.surface.surface != nil + let acceptsInput = terminalPanel.surface.acceptsTerminalInput + terminalPanel.surface.sendInput(text) return RoutedTerminalInputResult( - queued: terminalPanel.surface.acceptsTerminalInput, - shouldForceRefresh: false + queued: !hasSurface && acceptsInput, + shouldForceRefresh: hasSurface && acceptsInput ) } @@ -15494,29 +15442,6 @@ class TerminalController { return success ? "OK" : "ERROR: Failed to send input" } - 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 { - cmuxDebugLog( - "socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))" - ) - } -#endif - } - private func sendInputToWorkspace(_ args: String) -> String { guard let tabManager else { return "ERROR: TabManager not available" } let parts = args.split(separator: " ", maxSplits: 1).map(String.init) From 47562bd98ddbaf3c4c3e38bfae811ad7c8e0cd8f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 23:10:49 -0700 Subject: [PATCH 11/31] Preserve queued terminal text semantics --- Sources/GhosttyTerminalView.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index abf4c79a8c07..3ec11c8bcc3b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4402,12 +4402,13 @@ final class TerminalSurface: Identifiable, ObservableObject { } private enum PendingSocketInput { - case text(Data) + case rawText(Data) + case keyText(Data) case key(PendingKeyEvent) var estimatedBytes: Int { switch self { - case .text(let data): + case .rawText(let data), .keyText(let data): return data.count case .key(let event): return max(event.label.utf8.count, 1) @@ -5722,7 +5723,7 @@ final class TerminalSurface: Identifiable, ObservableObject { func sendText(_ text: String) { guard let data = text.data(using: .utf8), !data.isEmpty else { return } guard let surface = surface else { - guard enqueuePendingSocketInputIfAllowed(.text(data), reason: "sendText.queue") else { return } + guard enqueuePendingSocketInputIfAllowed(.rawText(data), reason: "sendText.queue") else { return } requestBackgroundSurfaceStartIfNeeded() return } @@ -5778,7 +5779,7 @@ final class TerminalSurface: Identifiable, ObservableObject { func flushBufferedText() { guard !bufferedText.isEmpty else { return } - inputs.append(.text(Data(bufferedText.utf8))) + inputs.append(.keyText(Data(bufferedText.utf8))) bufferedText.removeAll(keepingCapacity: true) } @@ -5821,7 +5822,9 @@ final class TerminalSurface: Identifiable, ObservableObject { private func writePendingSocketInput(_ input: PendingSocketInput, to surface: ghostty_surface_t) { switch input { - case .text(let data): + case .rawText(let data): + writeTextData(data, to: surface) + case .keyText(let data): sendTextKeyData(data, to: surface) case .key(let event): sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) From 366a49bd2de5138c567b008f60e4f0bd5c196df6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 23:22:51 -0700 Subject: [PATCH 12/31] Report accepted terminal socket input --- Sources/GhosttyTerminalView.swift | 10 ++++++---- Sources/TerminalController.swift | 7 +++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3ec11c8bcc3b..aa7cbf01c968 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5747,9 +5747,10 @@ final class TerminalSurface: Identifiable, ObservableObject { /// Send text with control characters (Return, Tab, etc.) delivered as key /// events so the shell processes them, while regular text is sent via the /// same key-text path used for attached socket input. - func sendInput(_ text: String) { + @discardableResult + func sendInput(_ text: String) -> Bool { let inputs = parsedSocketInputs(from: text) - guard !inputs.isEmpty else { return } + guard !inputs.isEmpty else { return false } guard let surface = surface else { var queued = false @@ -5759,13 +5760,14 @@ final class TerminalSurface: Identifiable, ObservableObject { if queued { requestBackgroundSurfaceStartIfNeeded() } - return + return queued } - guard consumeTerminalInputIfAllowed(reason: "sendInput") else { return } + guard consumeTerminalInputIfAllowed(reason: "sendInput") else { return false } for input in inputs { writePendingSocketInput(input, to: surface) } + return true } private func parsedSocketInputs(from text: String) -> [PendingSocketInput] { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 8bcbae55a705..b7c9eea508f6 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -15403,11 +15403,10 @@ class TerminalController { private static func routeUnescapedInput(_ text: String, to terminalPanel: TerminalPanel) -> RoutedTerminalInputResult { let hasSurface = terminalPanel.surface.surface != nil - let acceptsInput = terminalPanel.surface.acceptsTerminalInput - terminalPanel.surface.sendInput(text) + let acceptedInput = terminalPanel.surface.sendInput(text) return RoutedTerminalInputResult( - queued: !hasSurface && acceptsInput, - shouldForceRefresh: hasSurface && acceptsInput + queued: !hasSurface && acceptedInput, + shouldForceRefresh: hasSurface && acceptedInput ) } From d903dbe7ab93cbf9b3dccccf6acc8af9d706a561 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 23:40:47 -0700 Subject: [PATCH 13/31] Fix dead terminal regression after input path merge --- cmuxTests/GhosttyCommandShiftForwardingTests.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cmuxTests/GhosttyCommandShiftForwardingTests.swift b/cmuxTests/GhosttyCommandShiftForwardingTests.swift index 231658862615..0f0a754f890f 100644 --- a/cmuxTests/GhosttyCommandShiftForwardingTests.swift +++ b/cmuxTests/GhosttyCommandShiftForwardingTests.swift @@ -129,10 +129,6 @@ final class GhosttyCommandShiftForwardingTests: XCTestCase { } defer { GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver } - let previousTextInputHandler = GhosttyNSView.debugTextInputEventHandler - GhosttyNSView.debugTextInputEventHandler = { _, _ in false } - defer { GhosttyNSView.debugTextInputEventHandler = previousTextInputHandler } - hostedTerminal.surface.markChildProcessExitedForTesting(reason: "unit-test-child-exited") let event = try XCTUnwrap(NSEvent.keyEvent( with: .keyDown, From 6c1eb23f299dce449dc8f43e6cc5746870ab71de Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 00:51:09 -0700 Subject: [PATCH 14/31] Preserve first terminal teardown reason --- Sources/GhosttyTerminalView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index aa7cbf01c968..f91376b123b2 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4980,7 +4980,6 @@ final class TerminalSurface: Identifiable, ObservableObject { } func beginPortalCloseLifecycle(reason: String) { - recordTeardownRequest(reason: reason) let generation = withInputLifecycleLock { () -> UInt64? in guard portalLifecycleState != .closed else { return nil } guard portalLifecycleState != .closing else { return nil } @@ -4989,6 +4988,7 @@ final class TerminalSurface: Identifiable, ObservableObject { return portalLifecycleGeneration } guard let generation else { return } + recordTeardownRequest(reason: reason) #if DEBUG cmuxDebugLog( "surface.lifecycle.close.begin surface=\(id.uuidString.prefix(5)) " + From 69d214d22da5a9e82646f92ec55b5efaf6830a60 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 00:59:08 -0700 Subject: [PATCH 15/31] Preserve direct surface send readiness wait --- Sources/TerminalController.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index b7c9eea508f6..7e82d05429db 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -15397,6 +15397,7 @@ class TerminalController { } private struct RoutedTerminalInputResult { + let accepted: Bool let queued: Bool let shouldForceRefresh: Bool } @@ -15405,6 +15406,7 @@ class TerminalController { let hasSurface = terminalPanel.surface.surface != nil let acceptedInput = terminalPanel.surface.sendInput(text) return RoutedTerminalInputResult( + accepted: acceptedInput, queued: !hasSurface && acceptedInput, shouldForceRefresh: hasSurface && acceptedInput ) @@ -15434,7 +15436,7 @@ class TerminalController { if routed.shouldForceRefresh { terminalPanel.surface.forceRefresh(reason: "terminalController.sendInput") } - success = true + success = routed.accepted } if let error { return error } return success ? "OK" : "ERROR: Failed to send input" @@ -15539,6 +15541,9 @@ class TerminalController { var success = false v2MainSync { guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { return } + if terminalPanel.surface.surface == nil { + _ = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) + } let unescaped = text .replacingOccurrences(of: "\\n", with: "\r") @@ -15549,7 +15554,7 @@ class TerminalController { if routed.shouldForceRefresh { terminalPanel.surface.forceRefresh(reason: "terminalController.sendInputToSurface") } - success = true + success = routed.accepted } return success ? "OK" : "ERROR: Failed to send input" From 73eb45140dc638d4a2de008c82b2d9573d2ca9e8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 01:47:22 -0700 Subject: [PATCH 16/31] Add dead PTY startup command regression --- Sources/GhosttyTerminalViewSupport.swift | 9 ++ .../WorkspaceSplitStartupCommandTests.swift | 84 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/Sources/GhosttyTerminalViewSupport.swift b/Sources/GhosttyTerminalViewSupport.swift index b7e6a0b0aacf..92d69ef10c72 100644 --- a/Sources/GhosttyTerminalViewSupport.swift +++ b/Sources/GhosttyTerminalViewSupport.swift @@ -39,6 +39,15 @@ extension TerminalSurface { tmuxStartCommand } + func debugConfigTemplateWaitAfterCommand() -> Bool { + configTemplate?.waitAfterCommand ?? false + } + + func debugRuntimeWaitAfterCommand(context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT) -> Bool? { + guard let surface else { return nil } + return cmuxInheritedSurfaceConfig(sourceSurface: surface, context: context).waitAfterCommand + } + func debugInitialInputMetadata() -> (hasInitialInput: Bool, byteCount: Int) { let byteCount = initialInput?.utf8.count ?? 0 return (byteCount > 0, byteCount) diff --git a/cmuxTests/WorkspaceSplitStartupCommandTests.swift b/cmuxTests/WorkspaceSplitStartupCommandTests.swift index e53468b2d847..6b18bf0f3d96 100644 --- a/cmuxTests/WorkspaceSplitStartupCommandTests.swift +++ b/cmuxTests/WorkspaceSplitStartupCommandTests.swift @@ -1,4 +1,5 @@ import XCTest +import AppKit import Bonsplit #if canImport(cmux_DEV) @@ -18,6 +19,45 @@ private func workspaceSplitNodes(in node: ExternalTreeNode) -> [ExternalSplitNod @MainActor final class WorkspaceSplitStartupCommandTests: XCTestCase { + private func waitForCondition( + timeout: TimeInterval = 2, + pollInterval: TimeInterval = 0.01, + _ condition: () -> Bool + ) -> Bool { + let deadline = Date.now.addingTimeInterval(timeout) + while Date.now < deadline { + if condition() { + return true + } + RunLoop.current.run(until: Date.now.addingTimeInterval(pollInterval)) + } + return condition() + } + + private func hostTerminalPanelInWindow(_ panel: TerminalPanel) throws -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = try XCTUnwrap(window.contentView) + let hostedView = panel.hostedView + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + XCTAssertTrue( + waitForCondition { + panel.surface.surface != nil + }, + "Expected runtime surface to materialize after hosting panel in a window" + ) + return window + } + func testTabManagerSplitCarriesRequestedWorkingDirectoryAndStartupCommand() { let manager = TabManager() guard let workspace = manager.selectedWorkspace, @@ -59,6 +99,10 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { tmuxStartCommand, "Programmatic tmux-compatible splits must preserve the original tmux command for pane format queries" ) + XCTAssertFalse( + splitPanel.surface.debugConfigTemplateWaitAfterCommand(), + "Startup-command splits must not ask Ghostty to retain a child-exited PTY" + ) guard let split = workspaceSplitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { XCTFail("Expected split terminal panel to create a split node") return @@ -96,6 +140,46 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { XCTAssertEqual(surface.requestedWorkingDirectory, requestedDirectory) XCTAssertEqual(surface.surface.debugInitialCommand(), startupCommand) XCTAssertEqual(surface.surface.debugTmuxStartCommand(), tmuxStartCommand) + XCTAssertFalse( + surface.surface.debugConfigTemplateWaitAfterCommand(), + "Startup-command tabs must not ask Ghostty to retain a child-exited PTY" + ) + } + + func testInitialWorkspaceStartupCommandDoesNotRequestWaitAfterCommand() throws { + let startupCommand = "/bin/cat" + let workspace = Workspace(initialTerminalCommand: startupCommand) + defer { workspace.teardownAllPanels() } + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let panel = try XCTUnwrap(workspace.terminalPanel(for: panelId)) + + XCTAssertEqual(panel.surface.debugInitialCommand(), startupCommand) + XCTAssertFalse( + panel.surface.debugConfigTemplateWaitAfterCommand(), + "Initial startup commands must close through the normal child-exit lifecycle instead of retaining a dead PTY" + ) + } + + func testRuntimeSurfaceDisablesWaitAfterCommandEvenWhenTemplateRequestsIt() throws { + var template = CmuxSurfaceConfigTemplate() + template.waitAfterCommand = true + let panel = TerminalPanel( + workspaceId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_TAB, + configTemplate: template, + initialCommand: "/bin/cat" + ) + let window = try hostTerminalPanelInWindow(panel) + defer { + panel.close() + window.close() + } + + XCTAssertEqual(panel.surface.debugInitialCommand(), "/bin/cat") + XCTAssertFalse( + try XCTUnwrap(panel.surface.debugRuntimeWaitAfterCommand(context: GHOSTTY_SURFACE_CONTEXT_TAB)), + "cmux must override inherited/user wait-after-command before creating the Ghostty runtime surface" + ) } func testSessionRestoreRelaunchesOMXHudTmuxStartCommand() throws { From 53a2e13aabc485640c264ed88ab96b0e75b98302 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 01:52:31 -0700 Subject: [PATCH 17/31] Disable wait-after-command for cmux terminals --- Sources/GhosttyTerminalView.swift | 4 ++- Sources/Workspace.swift | 38 +++------------------------- Sources/WorkspaceSurfaceConfig.swift | 4 ++- 3 files changed, 9 insertions(+), 37 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index f91376b123b2..c20a1b158d84 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5251,7 +5251,9 @@ final class TerminalSurface: Identifiable, ObservableObject { let baseConfig = configTemplate ?? CmuxSurfaceConfigTemplate() var surfaceConfig = ghostty_surface_config_new() surfaceConfig.font_size = baseConfig.fontSize - surfaceConfig.wait_after_command = baseConfig.waitAfterCommand + // cmux closes or replaces child-exited terminal panels itself. Leaving + // Ghostty wait-after-command enabled makes an exited PTY remain focusable. + surfaceConfig.wait_after_command = false surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS surfaceConfig.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( nsview: Unmanaged.passUnretained(view).toOpaque() diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 7afafc0c254c..41eac07fa480 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7749,19 +7749,6 @@ final class Workspace: Identifiable, ObservableObject { // Remove the default "Welcome" tab that bonsplit creates let welcomeTabIds = bonsplitController.allTabIds - // When the workspace boots with an explicit initial command (`cmux ssh` / - // `cmux vm new` both funnel their ssh startup script through this path), - // hold the PTY open after that command exits. Without this Ghostty - // silently respawns a local login shell and the user can't tell a dead - // VM apart from a healthy local prompt. - var resolvedConfigTemplate = configTemplate - if let trimmedCommand = initialTerminalCommand?.trimmingCharacters(in: .whitespacesAndNewlines), - !trimmedCommand.isEmpty { - var template = resolvedConfigTemplate ?? CmuxSurfaceConfigTemplate() - template.waitAfterCommand = true - resolvedConfigTemplate = template - } - var initialTabId: TabID? if let initialDetachedSurface { if let initialPaneId = bonsplitController.allPaneIds.first, @@ -7773,7 +7760,7 @@ final class Workspace: Identifiable, ObservableObject { let terminalPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: resolvedConfigTemplate, + configTemplate: configTemplate, workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, portOrdinal: portOrdinal, initialCommand: initialTerminalCommand, @@ -9972,21 +9959,11 @@ final class Workspace: Identifiable, ObservableObject { } guard let paneId = sourcePaneId else { return nil } - var inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) + let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) let requestedInitialCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) let explicitInitialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil let remoteTerminalStartupCommand = remoteTerminalStartupCommand() let startupCommand = explicitInitialCommand ?? remoteTerminalStartupCommand - // Hold the pane open after the remote session ends so the user can read the - // "ssh exited …" message the startup script prints. Otherwise Ghostty silently - // respawns a local login shell when the command exits (the PTY falls through - // to $SHELL), and a dead VM looks identical to a healthy workspace with a - // local prompt — which is what we saw during dogfood. - if startupCommand != nil { - var template = inheritedConfig ?? CmuxSurfaceConfigTemplate() - template.waitAfterCommand = true - inheritedConfig = template - } #if DEBUG dlog( "split.timing workspace=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + @@ -10139,20 +10116,11 @@ final class Workspace: Identifiable, ObservableObject { let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView - var inheritedConfig = inheritedTerminalConfig(inPane: paneId) + let inheritedConfig = inheritedTerminalConfig(inPane: paneId) let requestedInitialCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) let explicitInitialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil let remoteTerminalStartupCommand = remoteTerminalStartupCommand() let startupCommand = explicitInitialCommand ?? remoteTerminalStartupCommand - // See the comment at the other call site: hold the PTY open after the remote - // command exits so the user sees the error rather than a silently-respawned - // local login shell. - if startupCommand != nil { - var template = inheritedConfig ?? CmuxSurfaceConfigTemplate() - template.waitAfterCommand = true - inheritedConfig = template - } - // Create new terminal panel let newPanel = TerminalPanel( workspaceId: id, diff --git a/Sources/WorkspaceSurfaceConfig.swift b/Sources/WorkspaceSurfaceConfig.swift index 9ed9d9c1d3d5..c3214f839e19 100644 --- a/Sources/WorkspaceSurfaceConfig.swift +++ b/Sources/WorkspaceSurfaceConfig.swift @@ -32,7 +32,9 @@ struct CmuxSurfaceConfigTemplate { } } } - waitAfterCommand = cConfig.wait_after_command + // cmux owns terminal child-exit lifecycle and replacement UI. Never inherit + // Ghostty's wait-after-command flag, which intentionally retains exited PTYs. + waitAfterCommand = false } } From fefc529e67e4251890cb333b59c5e390a5c07b6e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 02:03:12 -0700 Subject: [PATCH 18/31] Fix dead PTY test seam visibility --- Sources/GhosttyTerminalView.swift | 9 +++++++++ Sources/GhosttyTerminalViewSupport.swift | 9 --------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fafaec42ae1a..a4b660b16f29 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4645,6 +4645,15 @@ final class TerminalSurface: Identifiable, ObservableObject { attachedView === view && surface != nil } + func debugConfigTemplateWaitAfterCommand() -> Bool { + configTemplate?.waitAfterCommand ?? false + } + + func debugRuntimeWaitAfterCommand(context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT) -> Bool? { + guard let surface else { return nil } + return cmuxInheritedSurfaceConfig(sourceSurface: surface, context: context).waitAfterCommand + } + func portalBindingGeneration() -> UInt64 { withInputLifecycleLock { portalLifecycleGeneration diff --git a/Sources/GhosttyTerminalViewSupport.swift b/Sources/GhosttyTerminalViewSupport.swift index 92d69ef10c72..b7e6a0b0aacf 100644 --- a/Sources/GhosttyTerminalViewSupport.swift +++ b/Sources/GhosttyTerminalViewSupport.swift @@ -39,15 +39,6 @@ extension TerminalSurface { tmuxStartCommand } - func debugConfigTemplateWaitAfterCommand() -> Bool { - configTemplate?.waitAfterCommand ?? false - } - - func debugRuntimeWaitAfterCommand(context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT) -> Bool? { - guard let surface else { return nil } - return cmuxInheritedSurfaceConfig(sourceSurface: surface, context: context).waitAfterCommand - } - func debugInitialInputMetadata() -> (hasInitialInput: Bool, byteCount: Int) { let byteCount = initialInput?.utf8.count ?? 0 return (byteCount > 0, byteCount) From 1c8a0cffb1ad556783e4adcdf834fa2c44566614 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 12:58:45 -0700 Subject: [PATCH 19/31] Remove duplicate keyboard reset helpers --- Resources/shell-integration/cmux-bash-integration.bash | 7 ------- Resources/shell-integration/cmux-zsh-integration.zsh | 7 ------- 2 files changed, 14 deletions(-) diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index a6dfc5b2dfbd..bdf40546d2aa 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -417,13 +417,6 @@ _cmux_report_shell_activity_state() { } >/dev/null 2>&1 & disown } -_cmux_reset_terminal_keyboard_protocols() { - [[ -t 1 || -n "${CMUX_TEST_FORCE_KEYBOARD_RESET:-}${CMUX_TEST_FORCE_KITTY_RESET:-}" ]] || return 0 - # A crashed TUI may leave keyboard protocol state pushed. At a fresh shell - # prompt, return terminal input encoding to plain readline bytes. - printf '\033[>m\033[<8u' -} - _cmux_ports_kick() { local reason="${1:-command}" # Lightweight: just tell the app to run a batched scan for this panel. diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index efc2e59c01b2..05a7bf70dea9 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -539,13 +539,6 @@ _cmux_report_shell_activity_state() { _cmux_send_bg "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } -_cmux_reset_terminal_keyboard_protocols() { - [[ -t 1 || -n "${CMUX_TEST_FORCE_KEYBOARD_RESET:-}${CMUX_TEST_FORCE_KITTY_RESET:-}" ]] || return 0 - # A crashed TUI may leave keyboard protocol state pushed. At a fresh shell - # prompt, return terminal input encoding to plain readline bytes. - printf '\033[>m\033[<8u' -} - _cmux_ports_kick() { local reason="${1:-command}" # Lightweight: just tell the app to run a batched scan for this panel. From 96d6fba1cbaa4ba81308c28b5619a6d51f8828bc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:43:46 -0700 Subject: [PATCH 20/31] Add dead terminal socket response regression --- .../AppDelegateIssue2907RoutingTests.swift | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/cmuxTests/AppDelegateIssue2907RoutingTests.swift b/cmuxTests/AppDelegateIssue2907RoutingTests.swift index c9143f763e91..f7ee8df79807 100644 --- a/cmuxTests/AppDelegateIssue2907RoutingTests.swift +++ b/cmuxTests/AppDelegateIssue2907RoutingTests.swift @@ -217,6 +217,69 @@ final class AppDelegateIssue2907RoutingTests: XCTestCase { XCTAssertNotNil(split["surface_id"] as? String) } + func testSurfaceSendTextAndKeyRejectDeadTerminalPane() throws { +#if DEBUG + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { + AppDelegate.shared = previousAppDelegate + } + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + window.orderOut(nil) + } + + let manager = TabManager() + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let terminalPanel = try XCTUnwrap(workspace.focusedTerminalPanel) + let surfaceId = terminalPanel.id.uuidString + terminalPanel.surface.markChildProcessExitedForTesting(reason: "unit-test-child-exited") + + let textEnvelope = try v2Envelope( + method: "surface.send_text", + params: [ + "surface_id": surfaceId, + "text": "chart" + ] + ).envelope + XCTAssertEqual(textEnvelope["ok"] as? Bool, false) + let textError = try XCTUnwrap(textEnvelope["error"] as? [String: Any]) + XCTAssertEqual(textError["code"] as? String, "invalid_state") + let textErrorData = try XCTUnwrap(textError["data"] as? [String: Any]) + XCTAssertEqual(textErrorData["surface_id"] as? String, surfaceId) + + let keyEnvelope = try v2Envelope( + method: "surface.send_key", + params: [ + "surface_id": surfaceId, + "key": "Return" + ] + ).envelope + XCTAssertEqual(keyEnvelope["ok"] as? Bool, false) + let keyError = try XCTUnwrap(keyEnvelope["error"] as? [String: Any]) + XCTAssertEqual(keyError["code"] as? String, "invalid_state") + let keyErrorData = try XCTUnwrap(keyError["data"] as? [String: Any]) + XCTAssertEqual(keyErrorData["surface_id"] as? String, surfaceId) +#else + throw XCTSkip("Dead terminal socket input rejection uses DEBUG-only lifecycle test hooks.") +#endif + } + func testIssue2907NoTargetCommandsPreferKeyRecoveredWindowOverRegisteredWindow() throws { _ = NSApplication.shared let previousAppDelegate = AppDelegate.shared From 5bcda787b8c98663857a8c5f0e1ef1f39179b326 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:43:58 -0700 Subject: [PATCH 21/31] Report blocked terminal socket input --- Sources/GhosttyTerminalView.swift | 100 ++++++++++++++++++++++++++---- Sources/TerminalController.swift | 82 +++++++++++++++++++----- 2 files changed, 152 insertions(+), 30 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a4b660b16f29..020307cb22a3 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4438,6 +4438,28 @@ final class TerminalSurface: Identifiable, ObservableObject { case childExited(reason: String) } + enum TerminalInputDeliveryResult: Equatable { + case delivered(queued: Bool, shouldForceRefresh: Bool) + case blocked + case empty + case invalidKey + + var delivered: Bool { + if case .delivered = self { return true } + return false + } + + var queued: Bool { + if case .delivered(let queued, _) = self { return queued } + return false + } + + var shouldForceRefresh: Bool { + if case .delivered(_, let shouldForceRefresh) = self { return shouldForceRefresh } + return false + } + } + private(set) var surface: ghostty_surface_t? private var inputLifecycleState: TerminalInputLifecycleState = .acceptingInput private weak var attachedView: GhosttyNSView? @@ -5744,15 +5766,30 @@ final class TerminalSurface: Identifiable, ObservableObject { @discardableResult func sendNamedKey(_ keyName: String) -> Bool { - guard let event = pendingKeyEvent(for: keyName) else { return false } + switch sendNamedKeyDelivery(keyName) { + case .delivered, .blocked: + return true + case .empty, .invalidKey: + return false + } + } + + @discardableResult + func sendNamedKeyDelivery(_ keyName: String) -> TerminalInputDeliveryResult { + guard let event = pendingKeyEvent(for: keyName) else { return .invalidKey } if let surface = surface { - guard consumeTerminalInputIfAllowed(reason: "sendNamedKey.\(event.label)") else { return true } + guard consumeTerminalInputIfAllowed(reason: "sendNamedKey.\(event.label)") else { return .blocked } sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) + return .delivered(queued: false, shouldForceRefresh: true) } else { - guard enqueuePendingSocketInputIfAllowed(.key(event), reason: "sendNamedKey.\(event.label).queue") else { return true } + let delivery = enqueuePendingSocketInputsIfAllowed( + [.key(event)], + reason: "sendNamedKey.\(event.label).queue" + ) + guard delivery.delivered else { return delivery } requestBackgroundSurfaceStartIfNeeded() + return delivery } - return true } /// Send text with control characters (Return, Tab, etc.) delivered as key @@ -5760,25 +5797,27 @@ final class TerminalSurface: Identifiable, ObservableObject { /// same key-text path used for attached socket input. @discardableResult func sendInput(_ text: String) -> Bool { + sendInputDelivery(text).delivered + } + + @discardableResult + func sendInputDelivery(_ text: String) -> TerminalInputDeliveryResult { let inputs = parsedSocketInputs(from: text) - guard !inputs.isEmpty else { return false } + guard !inputs.isEmpty else { return .empty } guard let surface = surface else { - var queued = false - for input in inputs { - queued = enqueuePendingSocketInputIfAllowed(input, reason: "sendInput.queue") || queued - } - if queued { + let delivery = enqueuePendingSocketInputsIfAllowed(inputs, reason: "sendInput.queue") + if delivery.delivered { requestBackgroundSurfaceStartIfNeeded() } - return queued + return delivery } - guard consumeTerminalInputIfAllowed(reason: "sendInput") else { return false } + guard consumeTerminalInputIfAllowed(reason: "sendInput") else { return .blocked } for input in inputs { writePendingSocketInput(input, to: surface) } - return true + return .delivered(queued: false, shouldForceRefresh: true) } private func parsedSocketInputs(from text: String) -> [PendingSocketInput] { @@ -6114,6 +6153,41 @@ final class TerminalSurface: Identifiable, ObservableObject { return false } + private func enqueuePendingSocketInputsIfAllowed(_ inputs: [PendingSocketInput], reason: String) -> TerminalInputDeliveryResult { + let result = withInputLifecycleLock { () -> (queued: PendingSocketInputQueueSnapshot?, blockReason: String?, discarded: PendingSocketInputDiscard?) in + guard let blockReason = terminalInputBlockReasonLocked() else { + var queued: PendingSocketInputQueueSnapshot? + for input in inputs { + queued = enqueuePendingSocketInputLocked(input) + } + return (queued, nil, nil) + } + return (nil, blockReason, clearPendingSocketInputLocked()) + } + + if let queued = result.queued { +#if DEBUG + cmuxDebugLog( + "surface.socket_input.queue surface=\(id.uuidString.prefix(8)) items=\(queued.items) " + + "keys=\(queued.keys) bytes=\(queued.bytes)" + ) +#endif + return .delivered(queued: true, shouldForceRefresh: false) + } + + if let blockReason = result.blockReason { + logPendingSocketInputDiscard(result.discarded, reason: "\(reason).\(blockReason)") +#if DEBUG + cmuxDebugLog( + "surface.input.discard surface=\(id.uuidString.prefix(5)) " + + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) blocked=\(blockReason)" + ) +#endif + } + + return .blocked + } + private func flushPendingSocketInputIfNeeded() { guard let surface = surface else { return } let drain = withInputLifecycleLock { () -> PendingSocketInputDrain in diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 289b7783b9d2..b6e697f991a8 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -6821,6 +6821,14 @@ class TerminalController { if routed.shouldForceRefresh { terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendText") } + guard routed.accepted || routed.failure == .empty else { + result = .err( + code: "invalid_state", + message: "Terminal input is not available", + data: ["surface_id": surfaceId.uuidString] + ) + return + } #if DEBUG let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0 cmuxDebugLog( @@ -6864,13 +6872,24 @@ class TerminalController { result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) return } - let surfaceWasReady = terminalPanel.surface.surface != nil - let acceptsInput = terminalPanel.surface.acceptsTerminalInput - guard terminalPanel.surface.sendNamedKey(key) else { + let delivery = terminalPanel.surface.sendNamedKeyDelivery(key) + switch delivery { + case .invalidKey: result = .err(code: "invalid_params", message: "Unknown key", data: ["key": key]) return + case .blocked: + result = .err( + code: "invalid_state", + message: "Terminal input is not available", + data: ["surface_id": surfaceId.uuidString] + ) + return + case .empty: + break + case .delivered: + break } - if surfaceWasReady && acceptsInput { + if delivery.shouldForceRefresh { terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey") } 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))]) @@ -15290,12 +15309,6 @@ class TerminalController { return terminalPanel.surface.surface } - private func resolveSurface(from arg: String, tabManager: TabManager) -> ghostty_surface_t? { - // Backwards compatibility: resolve a terminal surface by panel UUID or a stable index. - // Use a slightly longer wait to reduce flakiness during bonsplit/layout restructures. - return resolveTerminalSurface(from: arg, tabManager: tabManager, waitUpTo: 2.0) - } - private func resolveSurfaceId(from arg: String, tab: Workspace) -> UUID? { if let uuid = UUID(uuidString: arg), tab.panels[uuid] != nil { return uuid @@ -15414,18 +15427,33 @@ class TerminalController { } private struct RoutedTerminalInputResult { + enum Failure { + case blocked + case empty + } + let accepted: Bool let queued: Bool let shouldForceRefresh: Bool + let failure: Failure? } private static func routeUnescapedInput(_ text: String, to terminalPanel: TerminalPanel) -> RoutedTerminalInputResult { - let hasSurface = terminalPanel.surface.surface != nil - let acceptedInput = terminalPanel.surface.sendInput(text) + let delivery = terminalPanel.surface.sendInputDelivery(text) + let failure: RoutedTerminalInputResult.Failure? + switch delivery { + case .blocked: + failure = .blocked + case .empty, .invalidKey: + failure = .empty + case .delivered: + failure = nil + } return RoutedTerminalInputResult( - accepted: acceptedInput, - queued: !hasSurface && acceptedInput, - shouldForceRefresh: hasSurface && acceptedInput + accepted: delivery.delivered, + queued: delivery.queued, + shouldForceRefresh: delivery.shouldForceRefresh, + failure: failure ) } @@ -15590,7 +15618,17 @@ class TerminalController { return } - success = terminalPanel.surface.sendNamedKey(keyName) + let delivery = terminalPanel.surface.sendNamedKeyDelivery(keyName) + switch delivery { + case .delivered: + success = true + case .blocked: + error = "ERROR: Failed to send key" + case .empty: + success = false + case .invalidKey: + success = false + } } if let error { return error } return success ? "OK" : "ERROR: Unknown key '\(keyName)'" @@ -15611,7 +15649,17 @@ class TerminalController { error = "ERROR: Surface not found" return } - success = terminalPanel.surface.sendNamedKey(keyName) + let delivery = terminalPanel.surface.sendNamedKeyDelivery(keyName) + switch delivery { + case .delivered: + success = true + case .blocked: + error = "ERROR: Failed to send key" + case .empty: + success = false + case .invalidKey: + success = false + } } if let error { return error } From b1b3ea3d57946436a898be33e6b836201af5c13b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:53:38 -0700 Subject: [PATCH 22/31] Address terminal input review feedback --- Sources/GhosttyTerminalView.swift | 2 ++ Sources/TerminalController.swift | 5 ----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 020307cb22a3..37587591c048 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6154,6 +6154,8 @@ final class TerminalSurface: Identifiable, ObservableObject { } private func enqueuePendingSocketInputsIfAllowed(_ inputs: [PendingSocketInput], reason: String) -> TerminalInputDeliveryResult { + guard !inputs.isEmpty else { return .empty } + let result = withInputLifecycleLock { () -> (queued: PendingSocketInputQueueSnapshot?, blockReason: String?, discarded: PendingSocketInputDiscard?) in guard let blockReason = terminalInputBlockReasonLocked() else { var queued: PendingSocketInputQueueSnapshot? diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index b6e697f991a8..44e8be07519d 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -15259,11 +15259,6 @@ class TerminalController { return nil } - private func resolveTerminalSurface(from arg: String, tabManager: TabManager, waitUpTo timeout: TimeInterval = 0.6) -> ghostty_surface_t? { - guard let terminalPanel = resolveTerminalPanel(from: arg, tabManager: tabManager) else { return nil } - return waitForTerminalSurface(terminalPanel, waitUpTo: timeout) - } - private func waitForTerminalSurface(_ terminalPanel: TerminalPanel, waitUpTo timeout: TimeInterval = 0.6) -> ghostty_surface_t? { if let surface = terminalPanel.surface.surface { return surface } From abdde1f4b0b9606f5b7a8f34bc99274f3f6cbe80 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 16:53:42 -0700 Subject: [PATCH 23/31] Preserve dead terminal input contracts --- Sources/GhosttyTerminalView.swift | 4 ++-- Sources/TerminalController.swift | 11 +++++++++++ cmuxTests/AppDelegateIssue2907RoutingTests.swift | 2 ++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 37587591c048..39d11256d761 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5767,9 +5767,9 @@ final class TerminalSurface: Identifiable, ObservableObject { @discardableResult func sendNamedKey(_ keyName: String) -> Bool { switch sendNamedKeyDelivery(keyName) { - case .delivered, .blocked: + case .delivered: return true - case .empty, .invalidKey: + case .blocked, .empty, .invalidKey: return false } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 44e8be07519d..315c8b6e04fb 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -15472,6 +15472,17 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") + guard terminalPanel.surface.acceptsTerminalInput else { + success = false + return + } + if terminalPanel.surface.surface == nil { + guard waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) != nil else { + error = "ERROR: Surface not ready" + return + } + } + let routed = Self.routeUnescapedInput(unescaped, to: terminalPanel) if routed.shouldForceRefresh { terminalPanel.surface.forceRefresh(reason: "terminalController.sendInput") diff --git a/cmuxTests/AppDelegateIssue2907RoutingTests.swift b/cmuxTests/AppDelegateIssue2907RoutingTests.swift index f7ee8df79807..addb009bc6f7 100644 --- a/cmuxTests/AppDelegateIssue2907RoutingTests.swift +++ b/cmuxTests/AppDelegateIssue2907RoutingTests.swift @@ -249,6 +249,8 @@ final class AppDelegateIssue2907RoutingTests: XCTestCase { let terminalPanel = try XCTUnwrap(workspace.focusedTerminalPanel) let surfaceId = terminalPanel.id.uuidString terminalPanel.surface.markChildProcessExitedForTesting(reason: "unit-test-child-exited") + XCTAssertFalse(terminalPanel.surface.sendNamedKey("Return")) + XCTAssertEqual(TerminalController.shared.handleSocketLine("send chart"), "ERROR: Failed to send input") let textEnvelope = try v2Envelope( method: "surface.send_text", From 82e0ddb4dadebcdae7bab830aa414d7e8a47c34b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 17:10:47 -0700 Subject: [PATCH 24/31] Avoid dead terminal surface send wait --- Sources/TerminalController.swift | 4 ++++ cmuxTests/AppDelegateIssue2907RoutingTests.swift | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 315c8b6e04fb..01b60b57f2ac 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -15592,6 +15592,10 @@ class TerminalController { var success = false v2MainSync { guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { return } + guard terminalPanel.surface.acceptsTerminalInput else { + success = false + return + } if terminalPanel.surface.surface == nil { _ = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) } diff --git a/cmuxTests/AppDelegateIssue2907RoutingTests.swift b/cmuxTests/AppDelegateIssue2907RoutingTests.swift index addb009bc6f7..beea736d1088 100644 --- a/cmuxTests/AppDelegateIssue2907RoutingTests.swift +++ b/cmuxTests/AppDelegateIssue2907RoutingTests.swift @@ -251,6 +251,10 @@ final class AppDelegateIssue2907RoutingTests: XCTestCase { terminalPanel.surface.markChildProcessExitedForTesting(reason: "unit-test-child-exited") XCTAssertFalse(terminalPanel.surface.sendNamedKey("Return")) XCTAssertEqual(TerminalController.shared.handleSocketLine("send chart"), "ERROR: Failed to send input") + XCTAssertEqual( + TerminalController.shared.handleSocketLine("send_surface \(surfaceId) chart"), + "ERROR: Failed to send input" + ) let textEnvelope = try v2Envelope( method: "surface.send_text", From e2eb23a517bf4d9bd84262a5c7cff271c854a1c3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 17:16:49 -0700 Subject: [PATCH 25/31] Avoid duplicate bash keyboard reset --- Resources/shell-integration/cmux-bash-integration.bash | 1 - 1 file changed, 1 deletion(-) diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index bdf40546d2aa..da26b568622b 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -976,7 +976,6 @@ _cmux_prompt_command() { fi if [[ -n "$CMUX_PANEL_ID" ]]; then - _cmux_reset_terminal_keyboard_protocols _cmux_report_shell_activity_state prompt fi _cmux_report_tty_once From 3666b34ba6fe08133d9cd9cc4ea00213c3994155 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 23:22:05 -0700 Subject: [PATCH 26/31] Fix terminal socket input helper --- Sources/GhosttyTerminalView.swift | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1624d34f4d50..1a151fd18ac0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6019,8 +6019,7 @@ final class TerminalSurface: Identifiable, ObservableObject { for event in Self.parsedSocketInputEvents(for: text) { switch event { case .text(let value): - var bufferedText = value - flushText(&bufferedText, surface: surface) + sendTextKeyInput(value, to: surface) case .key(let event): sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) } @@ -6103,6 +6102,21 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + private func sendTextKeyInput(_ text: String, to surface: ghostty_surface_t) { + guard !text.isEmpty else { return } + var keyEvent = ghostty_input_key_s() + keyEvent.action = GHOSTTY_ACTION_PRESS + keyEvent.keycode = 0 + keyEvent.mods = GHOSTTY_MODS_NONE + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.unshifted_codepoint = 0 + keyEvent.composing = false + text.withCString { ptr in + keyEvent.text = ptr + _ = ghostty_surface_key(surface, keyEvent) + } + } + private func sendKeyEvent( surface: ghostty_surface_t, keycode: UInt32, @@ -6410,8 +6424,7 @@ final class TerminalSurface: Identifiable, ObservableObject { case .pasteText(let chunk): writeTextData(chunk, to: surface) case .inputText(let text): - var bufferedText = text - flushText(&bufferedText, surface: surface) + sendTextKeyInput(text, to: surface) case .key(let event): queuedKeys += 1 sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) From 4702b00c5edff8cbedb3f8f3864167e3f7a3c5e2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 01:59:38 -0700 Subject: [PATCH 27/31] Add remote split wait-after-command regression --- .../WorkspaceSplitStartupCommandTests.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cmuxTests/WorkspaceSplitStartupCommandTests.swift b/cmuxTests/WorkspaceSplitStartupCommandTests.swift index 6b18bf0f3d96..9107895efcf1 100644 --- a/cmuxTests/WorkspaceSplitStartupCommandTests.swift +++ b/cmuxTests/WorkspaceSplitStartupCommandTests.swift @@ -160,6 +160,29 @@ final class WorkspaceSplitStartupCommandTests: XCTestCase { ) } + func testRemoteSplitStartupCommandDoesNotRequestWaitAfterCommand() throws { + let workspace = Workspace() + guard let paneId = workspace.bonsplitController.focusedPaneId else { + XCTFail("Expected focused pane in new workspace") + return + } + + let panel = try XCTUnwrap(workspace.splitPaneWithNewTerminal( + targetPane: paneId, + orientation: .vertical, + insertFirst: false, + workingDirectory: nil, + initialInput: nil, + remoteStartupCommand: "ssh example.com" + )) + + XCTAssertEqual(panel.surface.debugInitialCommand(), "ssh example.com") + XCTAssertFalse( + panel.surface.debugConfigTemplateWaitAfterCommand(), + "Remote startup splits must not ask Ghostty to retain a child-exited PTY" + ) + } + func testRuntimeSurfaceDisablesWaitAfterCommandEvenWhenTemplateRequestsIt() throws { var template = CmuxSurfaceConfigTemplate() template.waitAfterCommand = true From 6afd698d1627eff111d93e0951b3401017bf73bc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 02:08:04 -0700 Subject: [PATCH 28/31] Fix remote startup wait-after-command override --- Sources/Workspace.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index d2ced0f1d5a8..c5e570487305 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -13258,11 +13258,6 @@ final class Workspace: Identifiable, ObservableObject { var inheritedConfig = inheritedTerminalConfig(inPane: paneId) let requestedRemoteStartupCommand = remoteStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines) let startupCommand = requestedRemoteStartupCommand?.isEmpty == false ? requestedRemoteStartupCommand : nil - if startupCommand != nil { - var template = inheritedConfig ?? CmuxSurfaceConfigTemplate() - template.waitAfterCommand = true - inheritedConfig = template - } let newPanel = TerminalPanel( workspaceId: id, From 3afa414c298d9cfc811f74812a0c09259d146d0b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 02:29:08 -0700 Subject: [PATCH 29/31] Address split terminal config feedback --- Sources/Workspace.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c5e570487305..88cf83e49555 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -13255,7 +13255,7 @@ final class Workspace: Identifiable, ObservableObject { initialInput: String?, remoteStartupCommand: String? = nil ) -> TerminalPanel? { - var inheritedConfig = inheritedTerminalConfig(inPane: paneId) + let inheritedConfig = inheritedTerminalConfig(inPane: paneId) let requestedRemoteStartupCommand = remoteStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines) let startupCommand = requestedRemoteStartupCommand?.isEmpty == false ? requestedRemoteStartupCommand : nil From a5c9a381fe12aa26f061c92dfaec83f7437a0000 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 06:25:52 -0700 Subject: [PATCH 30/31] fix: clarify pending terminal input synchronization --- Sources/GhosttyTerminalView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6f013af94ae1..6c6f1eaf40ab 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4704,8 +4704,8 @@ final class TerminalSurface: Identifiable, ObservableObject { private var runtimeSurfaceCreatedAt: Date? private var teardownRequestedAt: Date? private var teardownRequestReason: String? - // Main-thread only. Public socket send entrypoints are MainActor-isolated - // before reading `surface` or mutating this pending queue. + // Protected by `inputLifecycleLock`; Ghostty callback threads can clear + // pending socket input as soon as the child process exit is observed. private var pendingSocketInputQueue: [PendingSocketInput] = [] private var pendingSocketInputBytes: Int = 0 private let maxPendingSocketInputBytes = 1_048_576 From 2cfbd16cd98c9b3d1c161b662fdc68da7015d044 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 21 May 2026 18:57:17 -0700 Subject: [PATCH 31/31] fix: lock pending socket input startup check --- Sources/GhosttyTerminalView.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 446a06da7dee..365d5b2ad8c6 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5494,12 +5494,15 @@ final class TerminalSurface: Identifiable, ObservableObject { private var hasDeferredStartupWork: Bool { let inheritedCommand = configTemplate?.command?.trimmingCharacters(in: .whitespacesAndNewlines) let inheritedInput = configTemplate?.initialInput + let hasPendingSocketInput = withInputLifecycleLock { + pendingSocketInputBytes > 0 + } return initialCommand != nil || tmuxStartCommand != nil || initialInput != nil || inheritedCommand?.isEmpty == false || inheritedInput?.isEmpty == false || - pendingSocketInputBytes > 0 + hasPendingSocketInput } func hasDeferredStartupWorkForBackgroundStart() -> Bool { @@ -6854,12 +6857,14 @@ final class TerminalSurface: Identifiable, ObservableObject { @MainActor func debugPendingSocketInputForTesting() -> (items: Int, bytes: Int, keyEvents: Int) { - let keyEvents = pendingSocketInputQueue.reduce(into: 0) { count, item in - if case .key = item { - count += 1 + withInputLifecycleLock { + let keyEvents = pendingSocketInputQueue.reduce(into: 0) { count, item in + if case .key = item { + count += 1 + } } + return (pendingSocketInputQueue.count, pendingSocketInputBytes, keyEvents) } - return (pendingSocketInputQueue.count, pendingSocketInputBytes, keyEvents) } /// Test-only helper to deterministically simulate a released runtime surface.