From 484b66c8ace112d0cb0cafff73b8f91cc2f753ae Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 17 Feb 2026 03:21:08 -0800
Subject: [PATCH] Fix terminal keys swallowed after opening browser (#45)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Fix terminal keys (arrows, Ctrl+N/P) swallowed after opening browser
After a browser panel is shown, SwiftUI's internal focus system activates
and its _NSHostingView starts consuming arrow keys and other non-Command
key events via performKeyEquivalent, preventing them from reaching the
terminal's keyDown handler.
Fix: In the NSWindow performKeyEquivalent swizzle, when GhosttyNSView is
the first responder and the event has no Command modifier, route directly
to the terminal's performKeyEquivalent — bypassing SwiftUI's view hierarchy
walk entirely.
Also clear stale browserAddressBarFocusedPanelId when a terminal surface
has focus, preventing Cmd+N from being eaten by omnibar selection logic
after focus transitions away from a browser.
Adds DEBUG-only keyboard event ring buffer (KeyDebugLog) that dumps to
/tmp/cmux-key-debug.log for diagnosing future key routing issues.
* Fix split focus and Cmd+Shift+N swallowed after opening browser
Split focus: capture the source terminal's hostedView before bonsplit
mutates focusedPaneId, so focusPanel moves focus FROM the old pane
instead of from the new pane to itself. Also retry ensureFocus when the
new terminal's view has no window yet (matching the existing retry
pattern for isVisibleInUI).
Cmd+Shift+N: after WKWebView has been in the responder chain, SwiftUI's
internal focus system can intercept Command-key events in the content
view hierarchy (returning true) without firing the CommandGroup action
closure. Fix by dispatching Command-key events directly to NSApp.mainMenu
when the terminal is first responder, bypassing the broken SwiftUI path.
Also add Cmd+Shift+N to handleCustomShortcut so it's customizable and
doesn't depend on SwiftUI menu dispatch at all.
* Unified debug event log: merge key/mouse/focus into /tmp/cmux-debug.log
- Delete KeyDebugLog, MouseDebugLog, klog(), mlog() from AppDelegate
- Replace all klog/mlog calls with dlog() (provided by bonsplit)
- Remove debugLogCallback wiring from Workspace
- Add focus change logging: focus.panel, focus.firstResponder,
split.created, focus.moveFocus
- Add import Bonsplit where needed for dlog access
- Fix stale drag state on cancelled tab drags (bonsplit submodule)
* Fix split focus stolen by re-entrant becomeFirstResponder during reparenting
During programmatic splits (Cmd+D / Cmd+Shift+D), SwiftUI reparents the old
terminal view, which fires becomeFirstResponder → onFocus → focusPanel for the
OLD panel, stealing focus from the newly created pane.
Add programmaticFocusTargetPanelId guard to suppress re-entrant focusPanel
calls for non-target panels during split creation.
Also document the unified debug event log in CLAUDE.md.
* Clear stale title/favicon when browser navigation fails
When a page fails to load (e.g. connection refused), the tab was still
showing the previous page's title and favicon. Now didFailProvisionalNavigation
resets pageTitle to the failed URL and clears faviconPNGData.
* Fix Cmd+N swallowed by browser omnibar and improve split focus suppression
- Only Ctrl+N/P trigger omnibar navigation, not Cmd+N/P (Cmd+N should
always create new workspace regardless of address bar focus)
- Move split focus suppression from workspace-level guard to source:
suppress becomeFirstResponder side-effects (onFocus + ghostty_surface_set_focus)
directly on the old GhosttyNSView during reparenting, preventing both
model-level and libghostty-level focus divergence
- Remove programmaticFocusTargetPanelId from Workspace.focusPanel
* Fix omnibar hang, WebView white flash, drag-over-browser, and idle CPU spin
- Omnibar: first click selects all without entering NSTextView tracking loop;
subsequent clicks have 3s synthetic mouseUp safety net to prevent hang
- WebView: set underPageBackgroundColor to match window so new browsers don't
flash white before content loads
- Drag/drop: register custom UTType (com.splittabbar.tabtransfer) in Info.plist
so WKWebView doesn't intercept tab drags; override registerForDraggedTypes
on CmuxWebView as belt-and-suspenders
- CPU: fix infinite makeFirstResponder loop in controlTextDidEndEditing by
checking both the text field and its field editor (the actual first responder)
---
CLAUDE.md | 17 +++
Resources/Info.plist | 13 +++
Sources/AppDelegate.swift | 108 ++++++++++++++++++-
Sources/ContentView.swift | 29 ++++-
Sources/Find/SurfaceSearchOverlay.swift | 14 ++-
Sources/GhosttyTerminalView.swift | 34 +++++-
Sources/KeyboardShortcutSettings.swift | 5 +
Sources/Panels/BrowserPanel.swift | 16 +++
Sources/Panels/BrowserPanelView.swift | 69 +++++++++++-
Sources/Panels/CmuxWebView.swift | 14 +++
Sources/Update/UpdateTitlebarAccessory.swift | 22 +++-
Sources/Workspace.swift | 38 +++++--
Sources/WorkspaceContentView.swift | 6 ++
vendor/bonsplit | 2 +-
14 files changed, 364 insertions(+), 23 deletions(-)
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