diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6b1028d3..f1c44d0b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1825,6 +1825,12 @@ final class TerminalSurface: Identifiable, ObservableObject { flushPendingTextIfNeeded() + // Kick an initial draw after creation/size setup. On some startup paths Ghostty can + // miss the first vsync callback and sit on a blank frame until another focus/visibility + // transition nudges the renderer. + view.forceRefreshSurface() + ghostty_surface_refresh(createdSurface) + #if DEBUG let runtimeFontText = cmuxCurrentSurfaceFontSizePoints(createdSurface).map { String(format: "%.2f", $0) @@ -1886,14 +1892,15 @@ final class TerminalSurface: Identifiable, ObservableObject { /// Force a full size recalculation and surface redraw. func forceRefresh() { + let hasSurface = surface != nil let viewState: String if let view = attachedView { let inWindow = view.window != nil let bounds = view.bounds let metalOK = (view.layer as? CAMetalLayer) != nil - viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK)" + viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK) hasSurface=\(hasSurface)" } else { - viewState = "NO_ATTACHED_VIEW" + viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)" } #if DEBUG let ts = ISO8601DateFormatter().string(from: Date()) @@ -1907,7 +1914,8 @@ final class TerminalSurface: Identifiable, ObservableObject { FileManager.default.createFile(atPath: logPath, contents: line.data(using: .utf8)) } #endif - guard let view = attachedView, + guard let surface, + let view = attachedView, view.window != nil, view.bounds.width > 0, view.bounds.height > 0 else { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 9b392625..66c4d986 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8409,20 +8409,20 @@ class TerminalController { // Socket commands are line-based; allow callers to express control chars with backslash escapes. let text = unescapeSocketText(raw) - var result = "ERROR: No window" - DispatchQueue.main.sync { - // Like simulate_shortcut, prefer a visible window so debug automation doesn't - // fail during key window transitions. - guard let window = NSApp.keyWindow - ?? NSApp.mainWindow - ?? NSApp.windows.first(where: { $0.isVisible }) - ?? NSApp.windows.first else { return } - NSApp.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(nil) - guard let fr = window.firstResponder else { - result = "ERROR: No first responder" - return - } + var result = "ERROR: No window" + DispatchQueue.main.sync { + // Like simulate_shortcut, prefer a visible window so debug automation doesn't + // fail during key window transitions. + guard let window = NSApp.keyWindow + ?? NSApp.mainWindow + ?? NSApp.windows.first(where: { $0.isVisible }) + ?? NSApp.windows.first else { return } + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + guard let fr = window.firstResponder else { + result = "ERROR: No first responder" + return + } if let client = fr as? NSTextInputClient { client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) @@ -8430,7 +8430,22 @@ class TerminalController { return } - // Fall back to the responder chain insertText action. + // If workspace handoff temporarily leaves a non-terminal first responder, + // route debug typing to the selected terminal's focused panel directly. + if let tabManager, + let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }), + let panelId = tab.focusedPanelId, + let terminalPanel = tab.terminalPanel(for: panelId), + !terminalPanel.hostedView.isSurfaceViewFirstResponder() { + // Match Enter semantics expected by tests/debug tooling when bypassing AppKit. + let directText = text.replacingOccurrences(of: "\n", with: "\r") + terminalPanel.surface.sendText(directText) + result = "OK" + return + } + + // Fall back to the responder-chain insertText action. (fr as? NSResponder)?.insertText(text) result = "OK" } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 2daddf4b..7dda1b50 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1254,6 +1254,11 @@ final class WindowTerminalPortal: NSObject { ) #endif hostedView.isHidden = false + // A reveal can happen without any frame delta (same targetFrame), which means the + // normal frame-change refresh path won't run. Nudge geometry + redraw so newly + // revealed terminals don't sit on a stale/blank IOSurface until later focus churn. + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow() } #if DEBUG diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 272bd086..e31d831c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2265,7 +2265,11 @@ final class Workspace: Identifiable, ObservableObject { } hostedView.reconcileGeometryNow() - terminalPanel.surface.forceRefresh() + if hasSurface { + terminalPanel.surface.forceRefresh() + } else if isAttached && hasUsableBounds { + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } } return needsFollowUpPass @@ -2306,6 +2310,31 @@ final class Workspace: Identifiable, ObservableObject { } } + private func scheduleMovedTerminalRefresh(panelId: UUID) { + guard terminalPanel(for: panelId) != nil else { return } + + // Force an NSViewRepresentable update after drag/move reparenting. This keeps + // portal host binding current when a pane auto-closes during tab moves. + terminalPanel(for: panelId)?.requestViewReattach() + + let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + guard let self, let panel = self.terminalPanel(for: panelId) else { return } + panel.hostedView.reconcileGeometryNow() + if panel.surface.surface != nil { + panel.surface.forceRefresh() + } else { + panel.surface.requestBackgroundSurfaceStartIfNeeded() + } + } + } + + // Run once immediately and once on the next turn so rapid split close/reparent + // sequences still get a post-layout redraw. + runRefreshPass(0) + runRefreshPass(0.03) + } + private func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) { for tabId in tabIds { if skipPinned, @@ -2928,12 +2957,18 @@ extension Workspace: BonsplitDelegate { ) #endif applyTabSelection(tabId: tab.id, inPane: destination) +#if DEBUG + let movedPanelIdAfter = panelIdFromSurfaceId(tab.id) +#endif + if let movedPanelId = panelIdFromSurfaceId(tab.id) { + scheduleMovedTerminalRefresh(panelId: movedPanelId) + } #if DEBUG let selectedAfter = controller.selectedTab(inPane: destination) .map { String(String(describing: $0.id).prefix(5)) } ?? "nil" let focusedPaneAfter = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" - let movedPanelFocused = (movedPanelId != nil && movedPanelId == focusedPanelId) ? 1 : 0 + let movedPanelFocused = (movedPanelIdAfter != nil && movedPanelIdAfter == focusedPanelId) ? 1 : 0 dlog( "split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + "destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " + diff --git a/vendor/bonsplit b/vendor/bonsplit index f24ba922..21db26f8 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit f24ba9222651ecc170869662eec9a5880404a82c +Subproject commit 21db26f8a6a0c7707af10da672c0d7cf07076c66