diff --git a/CLAUDE.md b/CLAUDE.md
index bf060569..0bf75b01 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -68,6 +68,23 @@ This creates an isolated app with its own name, bundle ID, socket, and derived d
Before launching a new tagged run, clean up any older tags you started in this session (quit old tagged app + remove its `/tmp` socket/derived data).
+## Debug event log
+
+All debug events (keys, mouse, focus, splits, tabs) go to a single unified log in DEBUG builds:
+
+```bash
+tail -f /tmp/cmux-debug.log
+```
+
+- Implementation: `vendor/bonsplit/Sources/Bonsplit/Public/DebugEventLog.swift`
+- Free function `dlog("message")` — logs with timestamp and appends to file in real time
+- Entire file is `#if DEBUG`; all call sites must be wrapped in `#if DEBUG` / `#endif`
+- 500-entry ring buffer; `DebugEventLog.shared.dump()` writes full buffer to file
+- Key events logged in `AppDelegate.swift` (monitor, performKeyEquivalent)
+- Mouse/UI events logged inline in views (ContentView, BrowserPanelView, etc.)
+- Focus events: `focus.panel`, `focus.bonsplit`, `focus.firstResponder`, `focus.moveFocus`
+- Bonsplit events: `tab.select`, `tab.close`, `tab.dragStart`, `tab.drop`, `pane.focus`, `pane.drop`, `divider.dragStart`
+
## Pitfalls
- Do not add an app-level display link or manual `ghostty_surface_draw` loop; rely on Ghostty wakeups/renderer to avoid typing lag.
diff --git a/Resources/Info.plist b/Resources/Info.plist
index 6387ec67..4a293313 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -69,6 +69,19 @@
+ UTExportedTypeDeclarations
+
+
+ UTTypeIdentifier
+ com.splittabbar.tabtransfer
+ UTTypeDescription
+ Bonsplit Tab Transfer
+ UTTypeConformsTo
+
+ public.data
+
+
+
SUFeedURL
https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml
SUPublicEDKey
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index c2951cde..e6bb46b2 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -73,7 +73,7 @@ func browserOmnibarSelectionDeltaForCommandNavigation(
let normalizedFlags = flags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function])
- guard normalizedFlags == [.command] || normalizedFlags == [.control] else { return nil }
+ guard normalizedFlags == [.control] else { return nil }
if chars == "n" { return 1 }
if chars == "p" { return -1 }
return nil
@@ -1418,9 +1418,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
shortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event in
guard let self else { return event }
if event.type == .keyDown {
+#if DEBUG
+ let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
+ dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")")
+#endif
if self.handleCustomShortcut(event: event) {
+#if DEBUG
+ dlog(" → consumed by handleCustomShortcut")
+ DebugEventLog.shared.dump()
+#endif
return nil // Consume the event
}
+#if DEBUG
+ DebugEventLog.shared.dump()
+#endif
return event // Pass through
}
self.handleBrowserOmnibarSelectionRepeatLifecycleEvent(event)
@@ -1623,6 +1634,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return false
}
+ // Guard against stale browserAddressBarFocusedPanelId after focus transitions
+ // (e.g., split that doesn't properly blur the address bar). If the first responder
+ // is a terminal surface, the address bar can't be focused.
+ if browserAddressBarFocusedPanelId != nil,
+ NSApp.keyWindow?.firstResponder is GhosttyNSView {
+#if DEBUG
+ dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId")
+#endif
+ browserAddressBarFocusedPanelId = nil
+ stopBrowserOmnibarSelectionRepeat()
+ }
+
// Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P.
if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) {
dispatchBrowserOmnibarSelectionMove(delta: delta)
@@ -1663,6 +1686,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
+ // New Window: Cmd+Shift+N
+ // Handled here instead of relying on SwiftUI's CommandGroup menu item because
+ // after a browser panel has been shown, SwiftUI's menu dispatch can silently
+ // consume the key equivalent without firing the action closure.
+ if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newWindow)) {
+ openNewMainWindow(nil)
+ return true
+ }
+
// Check Show Notifications shortcut
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showNotifications)) {
toggleNotificationsPopover(animated: false)
@@ -1863,7 +1895,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let normalizedFlags = flags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function])
- guard normalizedFlags == [.command] || normalizedFlags == [.control] else { return false }
+ guard normalizedFlags == [.control] else { return false }
return chars == "n" || chars == "p"
}
@@ -2289,6 +2321,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
self.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar()
self.browserAddressBarFocusedPanelId = panelId
self.stopBrowserOmnibarSelectionRepeat()
+#if DEBUG
+ dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))")
+#endif
}
browserAddressBarBlurObserver = NotificationCenter.default.addObserver(
@@ -2301,6 +2336,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
if self.browserAddressBarFocusedPanelId == panelId {
self.browserAddressBarFocusedPanelId = nil
self.stopBrowserOmnibarSelectionRepeat()
+#if DEBUG
+ dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))")
+#endif
}
}
}
@@ -3247,11 +3285,75 @@ enum MenuBarIconRenderer {
}
}
+
private extension NSWindow {
@objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool {
+#if DEBUG
+ let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
+ dlog("performKeyEquiv: \(Self.keyDescription(event)) fr=\(frType)")
+#endif
+
+ // When the terminal surface is the first responder, prevent SwiftUI's
+ // hosting view from consuming key events via performKeyEquivalent.
+ // After a browser panel (WKWebView) has been in the responder chain,
+ // SwiftUI's internal focus system can get into a broken state where it
+ // intercepts key events in the content view hierarchy, returns true
+ // (claiming consumption), but never actually fires the action closure.
+ //
+ // For non-Command keys: bypass the view hierarchy entirely and send
+ // directly to the terminal so arrow keys, Ctrl+N/P, etc. reach keyDown.
+ //
+ // For Command keys: bypass the SwiftUI content view hierarchy and
+ // dispatch directly to the main menu. No SwiftUI view should be handling
+ // Command shortcuts when the terminal is focused — the local event monitor
+ // (handleCustomShortcut) already handles app-level shortcuts, and anything
+ // remaining should be menu items.
+ if let ghosttyView = self.firstResponder as? GhosttyNSView {
+ let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
+ if !flags.contains(.command) {
+ let result = ghosttyView.performKeyEquivalent(with: event)
+#if DEBUG
+ dlog(" → ghostty direct: \(result)")
+#endif
+ return result
+ }
+ }
+
if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
+#if DEBUG
+ dlog(" → consumed by handleBrowserSurfaceKeyEquivalent")
+#endif
return true
}
- return cmux_performKeyEquivalent(with: event)
+
+ // When the terminal is focused, skip the full NSWindow.performKeyEquivalent
+ // (which walks the SwiftUI content view hierarchy) and dispatch Command-key
+ // events directly to the main menu. This avoids the broken SwiftUI focus path.
+ if self.firstResponder is GhosttyNSView,
+ event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
+ let mainMenu = NSApp.mainMenu, mainMenu.performKeyEquivalent(with: event) {
+#if DEBUG
+ dlog(" → consumed by mainMenu (bypassed SwiftUI)")
+#endif
+ return true
+ }
+
+ let result = cmux_performKeyEquivalent(with: event)
+#if DEBUG
+ if result { dlog(" → consumed by original performKeyEquivalent") }
+#endif
+ return result
+ }
+
+ static func keyDescription(_ event: NSEvent) -> String {
+ var parts: [String] = []
+ let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
+ if flags.contains(.command) { parts.append("Cmd") }
+ if flags.contains(.shift) { parts.append("Shift") }
+ if flags.contains(.option) { parts.append("Opt") }
+ if flags.contains(.control) { parts.append("Ctrl") }
+ let chars = event.charactersIgnoringModifiers ?? "?"
+ parts.append("'\(chars)'(\(event.keyCode))")
+ return parts.joined(separator: "+")
}
}
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index 58444ba0..4364fa01 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -1,4 +1,5 @@
import AppKit
+import Bonsplit
import SwiftUI
import ObjectiveC
import UniformTypeIdentifiers
@@ -216,6 +217,9 @@ struct ContentView: View {
.onChanged { value in
if !isResizerDragging {
isResizerDragging = true
+ #if DEBUG
+ dlog("sidebar.resizeDragStart")
+ #endif
if !isResizerHovering {
NSCursor.resizeLeftRight.push()
isResizerHovering = true
@@ -973,7 +977,12 @@ private struct TabItemView: View {
Spacer()
ZStack(alignment: .trailing) {
- Button(action: { tabManager.closeWorkspaceWithConfirmation(tab) }) {
+ Button(action: {
+ #if DEBUG
+ dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button")
+ #endif
+ tabManager.closeWorkspaceWithConfirmation(tab)
+ }) {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .medium))
.foregroundColor(isActive ? .white.opacity(0.7) : .secondary)
@@ -1105,6 +1114,9 @@ private struct TabItemView: View {
.opacity(isBeingDragged ? 0.6 : 1)
.overlay {
MiddleClickCapture {
+ #if DEBUG
+ dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick")
+ #endif
tabManager.closeWorkspaceWithConfirmation(tab)
}
}
@@ -1268,6 +1280,15 @@ private struct TabItemView: View {
}
private func updateSelection() {
+ #if DEBUG
+ let mods = NSEvent.modifierFlags
+ var modStr = ""
+ if mods.contains(.command) { modStr += "cmd " }
+ if mods.contains(.shift) { modStr += "shift " }
+ if mods.contains(.option) { modStr += "opt " }
+ if mods.contains(.control) { modStr += "ctrl " }
+ dlog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))")
+ #endif
let modifiers = NSEvent.modifierFlags
let isCommand = modifiers.contains(.command)
let isShift = modifiers.contains(.shift)
@@ -1847,6 +1868,9 @@ private struct SidebarTabDropDelegate: DropDelegate {
dropIndicator = nil
dragAutoScrollController.stop()
}
+ #if DEBUG
+ dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")")
+ #endif
guard let draggedTabId else { return false }
guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { return false }
let tabIds = tabManager.tabs.map(\.id)
@@ -2057,6 +2081,9 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
}
override func mouseDown(with event: NSEvent) {
+ #if DEBUG
+ dlog("folder.dragStart dir=\(directory)")
+ #endif
let fileURL = URL(fileURLWithPath: directory)
let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL)
diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift
index cbf8f644..d8bf5463 100644
--- a/Sources/Find/SurfaceSearchOverlay.swift
+++ b/Sources/Find/SurfaceSearchOverlay.swift
@@ -1,3 +1,4 @@
+import Bonsplit
import SwiftUI
struct SurfaceSearchOverlay: View {
@@ -55,6 +56,9 @@ struct SurfaceSearchOverlay: View {
}
Button(action: {
+ #if DEBUG
+ dlog("findbar.next surface=\(surface.id.uuidString.prefix(5))")
+ #endif
_ = surface.performBindingAction("navigate_search:next")
}) {
Image(systemName: "chevron.up")
@@ -63,6 +67,9 @@ struct SurfaceSearchOverlay: View {
.help("Next match (Return)")
Button(action: {
+ #if DEBUG
+ dlog("findbar.prev surface=\(surface.id.uuidString.prefix(5))")
+ #endif
_ = surface.performBindingAction("navigate_search:previous")
}) {
Image(systemName: "chevron.down")
@@ -70,7 +77,12 @@ struct SurfaceSearchOverlay: View {
.buttonStyle(SearchButtonStyle())
.help("Previous match (Shift+Return)")
- Button(action: onClose) {
+ Button(action: {
+ #if DEBUG
+ dlog("findbar.close surface=\(surface.id.uuidString.prefix(5))")
+ #endif
+ onClose()
+ }) {
Image(systemName: "xmark")
}
.buttonStyle(SearchButtonStyle())
diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift
index 29b73468..b79fb836 100644
--- a/Sources/GhosttyTerminalView.swift
+++ b/Sources/GhosttyTerminalView.swift
@@ -1496,6 +1496,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
var scrollbar: GhosttyScrollbar?
var cellSize: CGSize = .zero
var desiredFocus: Bool = false
+ var suppressingReparentFocus: Bool = false
var tabId: UUID?
var onFocus: (() -> Void)?
var onTriggerFlash: (() -> Void)?
@@ -1782,6 +1783,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// If we become first responder before the ghostty surface exists (e.g. during
// split/tab creation while the surface is still being created), record the desired focus.
desiredFocus = true
+
+ // During programmatic splits, SwiftUI reparents the old NSView which triggers
+ // becomeFirstResponder. Suppress onFocus + ghostty_surface_set_focus to prevent
+ // the old view from stealing focus and creating model/surface divergence.
+ if suppressingReparentFocus {
+#if DEBUG
+ dlog("focus.firstResponder SUPPRESSED (reparent) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
+#endif
+ return result
+ }
+
// Always notify the host app that this pane became the first responder so bonsplit
// focus/selection can converge. Previously this was gated on `surface != nil`, which
// allowed a mismatch where AppKit focus moved but the UI focus indicator (bonsplit)
@@ -1795,6 +1807,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
let deltaMs = (now - lastScrollEventTime) * 1000
Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))")
#if DEBUG
+ dlog("focus.firstResponder surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
if let terminalSurface {
AppDelegate.shared?.recordJumpUnreadFocusIfExpected(
tabId: terminalSurface.tabId,
@@ -2248,6 +2261,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// MARK: - Mouse Handling
override func mouseDown(with event: NSEvent) {
+ #if DEBUG
+ dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
+ #endif
window?.makeFirstResponder(self)
guard let surface = surface else { return }
let point = convert(event.locationInWindow, from: nil)
@@ -2504,7 +2520,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
- insertDroppedPasteboard(sender.draggingPasteboard)
+ #if DEBUG
+ dlog("terminal.fileDrop surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
+ #endif
+ return insertDroppedPasteboard(sender.draggingPasteboard)
}
}
@@ -2903,6 +2922,9 @@ final class GhosttySurfaceScrollView: NSView {
}
func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) {
+#if DEBUG
+ dlog("focus.moveFocus to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
+#endif
let work = { [weak self] in
guard let self else { return }
guard let window = self.window else { return }
@@ -3030,6 +3052,16 @@ final class GhosttySurfaceScrollView: NSView {
}
}
+ /// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during
+ /// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles.
+ func suppressReparentFocus() {
+ surfaceView.suppressingReparentFocus = true
+ }
+
+ func clearSuppressReparentFocus() {
+ surfaceView.suppressingReparentFocus = false
+ }
+
/// Returns true if the terminal's actual Ghostty surface view is (or contains) the window first responder.
/// This is stricter than checking `hostedView` descendants, since the scroll view can sometimes become
/// first responder transiently while focus is being applied.
diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift
index 6ba5b438..82a7fe84 100644
--- a/Sources/KeyboardShortcutSettings.swift
+++ b/Sources/KeyboardShortcutSettings.swift
@@ -7,6 +7,7 @@ enum KeyboardShortcutSettings {
// Titlebar / primary UI
case toggleSidebar
case newTab
+ case newWindow
case showNotifications
case jumpToUnread
case triggerFlash
@@ -35,6 +36,7 @@ enum KeyboardShortcutSettings {
switch self {
case .toggleSidebar: return "Toggle Sidebar"
case .newTab: return "New Tab"
+ case .newWindow: return "New Window"
case .showNotifications: return "Show Notifications"
case .jumpToUnread: return "Jump to Latest Unread"
case .triggerFlash: return "Flash Focused Panel"
@@ -57,6 +59,7 @@ enum KeyboardShortcutSettings {
switch self {
case .toggleSidebar: return "shortcut.toggleSidebar"
case .newTab: return "shortcut.newTab"
+ case .newWindow: return "shortcut.newWindow"
case .showNotifications: return "shortcut.showNotifications"
case .jumpToUnread: return "shortcut.jumpToUnread"
case .triggerFlash: return "shortcut.triggerFlash"
@@ -81,6 +84,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "b", command: true, shift: false, option: false, control: false)
case .newTab:
return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false)
+ case .newWindow:
+ return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false)
case .showNotifications:
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
case .jumpToUnread:
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index a8c0bdab..728b63a4 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -861,6 +861,10 @@ final class BrowserPanel: Panel, ObservableObject {
let webView = CmuxWebView(frame: .zero, configuration: config)
webView.allowsBackForwardNavigationGestures = true
+ // Match the empty-page background to the window so newly-created browsers
+ // don't flash white before content loads.
+ webView.underPageBackgroundColor = .windowBackgroundColor
+
// Always present as Safari.
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
@@ -874,6 +878,16 @@ final class BrowserPanel: Panel, ObservableObject {
self?.refreshFavicon(from: webView)
}
}
+ navDelegate.didFailNavigation = { [weak self] _, failedURL in
+ Task { @MainActor in
+ guard let self else { return }
+ // Clear stale title/favicon from the previous page so the tab
+ // shows the failed URL instead of the old page's branding.
+ self.pageTitle = failedURL.isEmpty ? "" : failedURL
+ self.faviconPNGData = nil
+ self.lastFaviconURLString = nil
+ }
+ }
navDelegate.openInNewTab = { [weak self] url in
self?.openLinkInNewTab(url: url)
}
@@ -1397,6 +1411,7 @@ private extension BrowserPanel {
private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
var didFinish: ((WKWebView) -> Void)?
+ var didFailNavigation: ((WKWebView, String) -> Void)?
var openInNewTab: ((URL) -> Void)?
/// The URL of the last navigation that was attempted. Used to preserve the omnibar URL
/// when a provisional navigation fails (e.g. connection refused on localhost:3000).
@@ -1426,6 +1441,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
let failedURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String
?? lastAttemptedURL?.absoluteString
?? ""
+ didFailNavigation?(webView, failedURL)
loadErrorPage(in: webView, failedURL: failedURL, error: nsError)
}
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index 588dbd7f..3bde66de 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -1,3 +1,4 @@
+import Bonsplit
import SwiftUI
import WebKit
import AppKit
@@ -197,7 +198,12 @@ struct BrowserPanelView: View {
let navButtonSize: CGFloat = 22
return HStack(spacing: 0) {
- Button(action: { panel.goBack() }) {
+ Button(action: {
+ #if DEBUG
+ dlog("browser.back panel=\(panel.id.uuidString.prefix(5))")
+ #endif
+ panel.goBack()
+ }) {
Image(systemName: "chevron.left")
.font(.system(size: 12, weight: .medium))
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
@@ -208,7 +214,12 @@ struct BrowserPanelView: View {
.opacity(panel.canGoBack ? 1.0 : 0.4)
.help("Go Back")
- Button(action: { panel.goForward() }) {
+ Button(action: {
+ #if DEBUG
+ dlog("browser.forward panel=\(panel.id.uuidString.prefix(5))")
+ #endif
+ panel.goForward()
+ }) {
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
@@ -221,8 +232,14 @@ struct BrowserPanelView: View {
Button(action: {
if panel.isLoading {
+ #if DEBUG
+ dlog("browser.stop panel=\(panel.id.uuidString.prefix(5))")
+ #endif
panel.stopLoading()
} else {
+ #if DEBUG
+ dlog("browser.reload panel=\(panel.id.uuidString.prefix(5))")
+ #endif
panel.reload()
}
}) {
@@ -1711,8 +1728,44 @@ private final class OmnibarNativeTextField: NSTextField {
}
override func mouseDown(with event: NSEvent) {
+ #if DEBUG
+ dlog("browser.omnibarClick")
+ #endif
onPointerDown?()
- super.mouseDown(with: event)
+
+ if currentEditor() == nil {
+ // First click — activate editing and select all (standard URL bar behavior).
+ // Avoids NSTextView's tracking loop which can spin forever if text layout
+ // enters an infinite invalidation cycle (e.g. under memory pressure).
+ window?.makeFirstResponder(self)
+ currentEditor()?.selectAll(nil)
+ } else {
+ // Already editing — allow normal click-to-place-cursor and drag-to-select.
+ // Guard against a stuck tracking loop by posting a synthetic mouseUp after
+ // a timeout so the main thread can't be blocked indefinitely.
+ var trackingFinished = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
+ guard !trackingFinished, let self, let window = self.window else { return }
+ #if DEBUG
+ dlog("browser.omnibarTrackingTimeout — forcing mouseUp")
+ #endif
+ if let fakeUp = NSEvent.mouseEvent(
+ with: .leftMouseUp,
+ location: event.locationInWindow,
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 0,
+ clickCount: 1,
+ pressure: 0.0
+ ) {
+ NSApp.postEvent(fakeUp, atStart: true)
+ }
+ }
+ super.mouseDown(with: event)
+ trackingFinished = true
+ }
}
override func keyDown(with event: NSEvent) {
@@ -1783,7 +1836,12 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
guard self.parent.isFocused else { return }
guard self.parent.shouldSuppressWebViewFocus() else { return }
guard let field = self.parentField, let window = field.window else { return }
- if !(window.firstResponder === field) {
+ // Check both the field itself AND its field editor (which becomes
+ // the actual first responder when the text field is being edited).
+ let fr = window.firstResponder
+ let isAlreadyFocused = fr === field ||
+ ((fr as? NSTextView)?.delegate as? NSTextField) === field
+ if !isAlreadyFocused {
window.makeFirstResponder(field)
}
}
@@ -2116,6 +2174,9 @@ private struct OmnibarSuggestionsView: View {
VStack(spacing: rowSpacing) {
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
Button {
+ #if DEBUG
+ dlog("browser.suggestionClick index=\(idx) text=\"\(item.listText)\"")
+ #endif
onCommit(item)
} label: {
HStack(spacing: 6) {
diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift
index 978d0a5f..a1627e7f 100644
--- a/Sources/Panels/CmuxWebView.swift
+++ b/Sources/Panels/CmuxWebView.swift
@@ -32,6 +32,20 @@ final class CmuxWebView: WKWebView {
super.keyDown(with: event)
}
+ // MARK: - Drag-and-drop passthrough
+
+ // WKWebView inherently calls registerForDraggedTypes with public.text (and others).
+ // Bonsplit tab drags use NSString (public.utf8-plain-text) which conforms to public.text,
+ // so AppKit's view-hierarchy-based drag routing delivers the session to WKWebView instead
+ // of SwiftUI's sibling .onDrop overlays. Rejecting in draggingEntered doesn't help because
+ // AppKit only bubbles up through superviews, not siblings.
+ //
+ // Fix: prevent WKWebView from registering as a drag destination entirely. AppKit won't
+ // route drags here, so they reach the SwiftUI overlay drop zones as intended.
+ override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) {
+ // No-op: suppress WKWebView's automatic drag type registration.
+ }
+
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
super.willOpenMenu(menu, with: event)
diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift
index cd213be8..b6e004ef 100644
--- a/Sources/Update/UpdateTitlebarAccessory.swift
+++ b/Sources/Update/UpdateTitlebarAccessory.swift
@@ -1,4 +1,5 @@
import AppKit
+import Bonsplit
import Combine
import SwiftUI
@@ -299,14 +300,24 @@ private struct TitlebarControlsView: View {
private func controlsGroup(config: TitlebarControlsStyleConfig) -> some View {
let hintLayoutItems = titlebarHintLayoutItems(config: config)
let content = HStack(spacing: config.spacing) {
- TitlebarControlButton(config: config, action: onToggleSidebar) {
+ TitlebarControlButton(config: config, action: {
+ #if DEBUG
+ dlog("titlebar.toggleSidebar")
+ #endif
+ onToggleSidebar()
+ }) {
iconLabel(systemName: "sidebar.left", config: config)
}
.accessibilityIdentifier("titlebarControl.toggleSidebar")
.accessibilityLabel("Toggle Sidebar")
.help(KeyboardShortcutSettings.Action.toggleSidebar.tooltip("Show or hide the sidebar"))
- TitlebarControlButton(config: config, action: onToggleNotifications) {
+ TitlebarControlButton(config: config, action: {
+ #if DEBUG
+ dlog("titlebar.notifications")
+ #endif
+ onToggleNotifications()
+ }) {
ZStack(alignment: .topTrailing) {
iconLabel(systemName: "bell", config: config)
@@ -328,7 +339,12 @@ private struct TitlebarControlsView: View {
.accessibilityLabel("Notifications")
.help(KeyboardShortcutSettings.Action.showNotifications.tooltip("Show notifications"))
- TitlebarControlButton(config: config, action: onNewTab) {
+ TitlebarControlButton(config: config, action: {
+ #if DEBUG
+ dlog("titlebar.newTab")
+ #endif
+ onNewTab()
+ }) {
iconLabel(systemName: "plus", config: config)
}
.accessibilityIdentifier("titlebarControl.newTab")
diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift
index 4af84b12..510c9479 100644
--- a/Sources/Workspace.swift
+++ b/Sources/Workspace.swift
@@ -58,6 +58,7 @@ final class Workspace: Identifiable, ObservableObject {
/// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels)
private var isProgrammaticSplit = false
+
// Closing tabs mutates split layout immediately; terminal views handle their own AppKit
// layout/size synchronization.
@@ -392,6 +393,10 @@ final class Workspace: Identifiable, ObservableObject {
)
surfaceIdToPanelId[newTab.id] = newPanel.id
+ // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId,
+ // so we can hand it to focusPanel as the "move focus FROM" view.
+ let previousHostedView = focusedTerminalPanel?.hostedView
+
// Create the split with the new tab already present in the new pane.
isProgrammaticSplit = true
defer { isProgrammaticSplit = false }
@@ -401,10 +406,18 @@ final class Workspace: Identifiable, ObservableObject {
return nil
}
- // SplitViewController focuses the newly created pane, but the AppKit first responder can lag
- // (or remain on the source surface) during SwiftUI/bonsplit structural updates. Explicitly
- // focus the new panel so model focus + responder chain converge deterministically.
- focusPanel(newPanel.id)
+#if DEBUG
+ dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)")
+#endif
+
+ // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting.
+ // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view,
+ // stealing focus from the new panel and creating model/surface divergence.
+ previousHostedView?.suppressReparentFocus()
+ focusPanel(newPanel.id, previousHostedView: previousHostedView)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
+ previousHostedView?.clearSuppressReparentFocus()
+ }
return newPanel
}
@@ -505,9 +518,13 @@ final class Workspace: Identifiable, ObservableObject {
return nil
}
- // See newTerminalSplit: explicitly focus the newly created panel so focus state is
- // deterministic for both user and socket-driven workflows.
+ // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting.
+ let previousHostedView = focusedTerminalPanel?.hostedView
+ previousHostedView?.suppressReparentFocus()
focusPanel(browserPanel.id)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
+ previousHostedView?.clearSuppressReparentFocus()
+ }
installBrowserPanelSubscription(browserPanel)
@@ -790,9 +807,10 @@ final class Workspace: Identifiable, ObservableObject {
}
// MARK: - Focus Management
- func focusPanel(_ panelId: UUID) {
+ func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) {
#if DEBUG
- let pane = bonsplitController.focusedPaneId?.id.uuidString ?? "nil"
+ let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
+ dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane)")
FocusLogStore.shared.append("Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane)")
#endif
guard let tabId = surfaceIdFromPanelId(panelId) else { return }
@@ -801,7 +819,9 @@ final class Workspace: Identifiable, ObservableObject {
// Capture the currently focused terminal view so we can explicitly move AppKit first
// responder when focusing another terminal (helps avoid "highlighted but typing goes to
// another pane" after heavy split/tab mutations).
- let previousTerminalHostedView = focusedTerminalPanel?.hostedView
+ // When a caller passes an explicit previousHostedView (e.g. during split creation where
+ // bonsplit has already mutated focusedPaneId), prefer it over the derived value.
+ let previousTerminalHostedView = previousHostedView ?? focusedTerminalPanel?.hostedView
// `selectTab` does not necessarily move bonsplit's focused pane. For programmatic focus
// (socket API, notification click, etc.), ensure the target tab's pane becomes focused
diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift
index c385f4ef..d92e2b42 100644
--- a/Sources/WorkspaceContentView.swift
+++ b/Sources/WorkspaceContentView.swift
@@ -137,11 +137,17 @@ struct EmptyPanelView: View {
}
private func createTerminal() {
+ #if DEBUG
+ dlog("emptyPane.newTerminal pane=\(paneId.id.uuidString.prefix(5))")
+ #endif
focusPane()
_ = workspace.newTerminalSurface(inPane: paneId)
}
private func createBrowser() {
+ #if DEBUG
+ dlog("emptyPane.newBrowser pane=\(paneId.id.uuidString.prefix(5))")
+ #endif
focusPane()
_ = workspace.newBrowserSurface(inPane: paneId)
}
diff --git a/vendor/bonsplit b/vendor/bonsplit
index 74ea74ea..d8af8119 160000
--- a/vendor/bonsplit
+++ b/vendor/bonsplit
@@ -1 +1 @@
-Subproject commit 74ea74ea6294b92d50f5d27dd15daf3aebbfa987
+Subproject commit d8af81190c90a0ddb28f8dbd5ad79070564b2234