Fix terminal zoom inheritance for new splits/surfaces/workspaces (#384)

* Fix terminal Cmd zoom routing for Ghostty focus descendants (#383)

* Inherit new terminal zoom from last terminal context

Prefer pane-selected terminal as Ghostty config inheritance source when creating splits/new terminals, then focused/fallback terminals. This preserves runtime zoom/font size when opening the next terminal.

* Fix terminal zoom inheritance across split/tab/workspace creation
This commit is contained in:
Austin Wang 2026-02-23 11:26:11 -08:00 committed by GitHub
parent c1822fdaac
commit 6598a38fe3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 551 additions and 51 deletions

View file

@ -194,6 +194,54 @@ func shouldRouteTerminalFontZoomShortcutToGhostty(
return browserZoomShortcutAction(flags: flags, chars: chars, keyCode: keyCode) != nil
}
func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? {
guard let responder else { return nil }
if let ghosttyView = responder as? GhosttyNSView {
return ghosttyView
}
if let view = responder as? NSView,
let ghosttyView = cmuxOwningGhosttyView(for: view) {
return ghosttyView
}
if let textView = responder as? NSTextView,
let delegateView = textView.delegate as? NSView,
let ghosttyView = cmuxOwningGhosttyView(for: delegateView) {
return ghosttyView
}
var current = responder.nextResponder
while let next = current {
if let ghosttyView = next as? GhosttyNSView {
return ghosttyView
}
if let view = next as? NSView,
let ghosttyView = cmuxOwningGhosttyView(for: view) {
return ghosttyView
}
current = next.nextResponder
}
return nil
}
private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? {
if let ghosttyView = view as? GhosttyNSView {
return ghosttyView
}
var current: NSView? = view.superview
while let candidate = current {
if let ghosttyView = candidate as? GhosttyNSView {
return ghosttyView
}
current = candidate.superview
}
return nil
}
#if DEBUG
func browserZoomShortcutTraceCandidate(
flags: NSEvent.ModifierFlags,
@ -2300,7 +2348,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// When the terminal has active IME composition (e.g. Korean, Japanese, Chinese
// input), don't intercept key events let them flow through to the input method.
if let ghosttyView = NSApp.keyWindow?.firstResponder as? GhosttyNSView,
if let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder),
ghosttyView.hasMarkedText() {
return false
}
@ -2345,7 +2393,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// (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 {
cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil {
#if DEBUG
dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId")
#endif
@ -4441,7 +4489,8 @@ private extension NSWindow {
// 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 firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder)
if let ghosttyView = firstResponderGhosttyView {
// If the IME is composing, don't intercept key events let them flow
// through normal AppKit event dispatch so the input method can process them.
if ghosttyView.hasMarkedText() {
@ -4484,7 +4533,7 @@ private extension NSWindow {
// 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,
if firstResponderGhosttyView != nil,
event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
let mainMenu = NSApp.mainMenu {
let consumedByMenu = mainMenu.performKeyEquivalent(with: event)

View file

@ -1616,6 +1616,13 @@ final class TerminalSurface: Identifiable, ObservableObject {
surfaceCallbackContext = callbackContext
surfaceConfig.scale_factor = scaleFactors.layer
surfaceConfig.context = surfaceContext
#if DEBUG
let templateFontText = String(format: "%.2f", surfaceConfig.font_size)
dlog(
"zoom.create surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " +
"templateFont=\(templateFontText)"
)
#endif
var envVars: [ghostty_env_var_s] = []
var envStorage: [(UnsafeMutablePointer<CChar>, UnsafeMutablePointer<CChar>)] = []
defer {
@ -1761,6 +1768,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
#endif
return
}
guard let createdSurface = surface else { return }
// For vsync-driven rendering, Ghostty needs to know which display we're on so it can
// start a CVDisplayLink with the right refresh rate. If we don't set this early, the
@ -1772,21 +1780,48 @@ final class TerminalSurface: Identifiable, ObservableObject {
if let screen = view.window?.screen ?? NSScreen.main,
let displayID = screen.displayID,
displayID != 0 {
ghostty_surface_set_display_id(surface, displayID)
ghostty_surface_set_display_id(createdSurface, displayID)
}
ghostty_surface_set_content_scale(surface, scaleFactors.x, scaleFactors.y)
ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y)
let wpx = UInt32((view.bounds.width * scaleFactors.x).rounded(.toNearestOrAwayFromZero))
let hpx = UInt32((view.bounds.height * scaleFactors.y).rounded(.toNearestOrAwayFromZero))
if wpx > 0, hpx > 0 {
ghostty_surface_set_size(surface, wpx, hpx)
ghostty_surface_set_size(createdSurface, wpx, hpx)
lastPixelWidth = wpx
lastPixelHeight = hpx
lastXScale = scaleFactors.x
lastYScale = scaleFactors.y
}
// Some GhosttyKit builds can drop inherited font_size during post-create
// config/scale reconciliation. If runtime points don't match the inherited
// template points, re-apply via binding action so all creation paths
// (new surface, split, new workspace) preserve zoom from the source terminal.
if let inheritedFontPoints = configTemplate?.font_size,
inheritedFontPoints > 0 {
let currentFontPoints = cmuxCurrentSurfaceFontSizePoints(createdSurface)
let shouldReapply = {
guard let currentFontPoints else { return true }
return abs(currentFontPoints - inheritedFontPoints) > 0.05
}()
if shouldReapply {
let action = String(format: "set_font_size:%.3f", inheritedFontPoints)
_ = performBindingAction(action)
}
}
flushPendingTextIfNeeded()
#if DEBUG
let runtimeFontText = cmuxCurrentSurfaceFontSizePoints(createdSurface).map {
String(format: "%.2f", $0)
} ?? "nil"
dlog(
"zoom.create.done surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " +
"runtimeFont=\(runtimeFontText)"
)
#endif
}
func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) {

View file

@ -753,9 +753,15 @@ class TabManager: ObservableObject {
@discardableResult
func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace {
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal)
let newWorkspace = Workspace(
title: "Terminal \(tabs.count + 1)",
workingDirectory: workingDirectory,
portOrdinal: ordinal,
configTemplate: inheritedConfig
)
wireClosedBrowserTracking(for: newWorkspace)
let insertIndex = newTabInsertIndex()
if insertIndex >= 0 && insertIndex <= tabs.count {
@ -785,6 +791,36 @@ class TabManager: ObservableObject {
@discardableResult
func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) }
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
guard let workspace = selectedWorkspace else { return nil }
if let focusedTerminal = workspace.focusedTerminalPanel {
return focusedTerminal
}
if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() {
return rememberedTerminal
}
if let focusedPaneId = workspace.bonsplitController.focusedPaneId,
let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) {
return paneTerminal
}
return workspace.terminalPanelForConfigInheritance()
}
private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? {
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface {
return cmuxInheritedSurfaceConfig(
sourceSurface: sourceSurface,
context: GHOSTTY_SURFACE_CONTEXT_TAB
)
}
if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
var config = ghostty_surface_config_new()
config.font_size = fallbackFontPoints
return config
}
return nil
}
private func normalizedWorkingDirectory(_ directory: String?) -> String? {
guard let directory else { return nil }
let normalized = normalizeDirectory(directory)

View file

@ -3,6 +3,58 @@ import SwiftUI
import AppKit
import Bonsplit
import Combine
import CoreText
func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String {
switch context {
case GHOSTTY_SURFACE_CONTEXT_WINDOW:
return "window"
case GHOSTTY_SURFACE_CONTEXT_TAB:
return "tab"
case GHOSTTY_SURFACE_CONTEXT_SPLIT:
return "split"
default:
return "unknown(\(context))"
}
}
func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? {
guard let quicklookFont = ghostty_surface_quicklook_font(surface) else {
return nil
}
let ctFont = Unmanaged<CTFont>.fromOpaque(quicklookFont).takeRetainedValue()
let points = Float(CTFontGetSize(ctFont))
guard points > 0 else { return nil }
return points
}
func cmuxInheritedSurfaceConfig(
sourceSurface: ghostty_surface_t,
context: ghostty_surface_context_e
) -> ghostty_surface_config_s {
let inherited = ghostty_surface_inherited_config(sourceSurface, context)
var config = inherited
// Make runtime zoom inheritance explicit, even when Ghostty's
// inherit-font-size config is disabled.
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface)
if let points = runtimePoints {
config.font_size = points
}
#if DEBUG
let inheritedText = String(format: "%.2f", inherited.font_size)
let runtimeText = runtimePoints.map { String(format: "%.2f", $0) } ?? "nil"
let finalText = String(format: "%.2f", config.font_size)
dlog(
"zoom.inherit context=\(cmuxSurfaceContextName(context)) " +
"inherited=\(inheritedText) runtime=\(runtimeText) final=\(finalText)"
)
#endif
return config
}
struct SidebarStatusEntry {
let key: String
@ -261,6 +313,15 @@ final class Workspace: Identifiable, ObservableObject {
/// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels)
private var isProgrammaticSplit = false
/// Last terminal panel used as an inheritance source (typically last focused terminal).
private var lastTerminalConfigInheritancePanelId: UUID?
/// Last known terminal font points from inheritance sources. Used as fallback when
/// no live terminal surface is currently available.
private var lastTerminalConfigInheritanceFontPoints: Float?
/// Per-panel inherited zoom lineage. Descendants reuse this root value unless
/// a panel is explicitly re-zoomed by the user.
private var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:]
/// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore.
var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)?
@ -376,7 +437,12 @@ final class Workspace: Identifiable, ObservableObject {
}
}
init(title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0) {
init(
title: String = "Terminal",
workingDirectory: String? = nil,
portOrdinal: Int = 0,
configTemplate: ghostty_surface_config_s? = nil
) {
self.id = UUID()
self.portOrdinal = portOrdinal
self.processTitle = title
@ -414,11 +480,13 @@ final class Workspace: Identifiable, ObservableObject {
let terminalPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_TAB,
configTemplate: configTemplate,
workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil,
portOrdinal: portOrdinal
)
panels[terminalPanel.id] = terminalPanel
panelTitles[terminalPanel.id] = terminalPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate)
// Create initial tab in bonsplit and store the mapping
var initialTabId: TabID?
@ -919,6 +987,169 @@ final class Workspace: Identifiable, ObservableObject {
// MARK: - Panel Operations
private func seedTerminalInheritanceFontPoints(
panelId: UUID,
configTemplate: ghostty_surface_config_s?
) {
guard let fontPoints = configTemplate?.font_size, fontPoints > 0 else { return }
terminalInheritanceFontPointsByPanelId[panelId] = fontPoints
lastTerminalConfigInheritanceFontPoints = fontPoints
}
private func resolvedTerminalInheritanceFontPoints(
for terminalPanel: TerminalPanel,
sourceSurface: ghostty_surface_t,
inheritedConfig: ghostty_surface_config_s
) -> Float? {
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface)
if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 {
if let runtimePoints, abs(runtimePoints - rooted) > 0.05 {
// Runtime zoom changed after lineage was seeded (manual zoom on descendant);
// treat runtime as the new root for future descendants.
return runtimePoints
}
return rooted
}
if inheritedConfig.font_size > 0 {
return inheritedConfig.font_size
}
return runtimePoints
}
private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) {
lastTerminalConfigInheritancePanelId = terminalPanel.id
if let sourceSurface = terminalPanel.surface.surface,
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) {
let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id]
if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 {
terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints
}
lastTerminalConfigInheritanceFontPoints =
terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints
}
}
func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? {
guard let panelId = lastTerminalConfigInheritancePanelId else { return nil }
return terminalPanel(for: panelId)
}
func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? {
lastTerminalConfigInheritanceFontPoints
}
/// Candidate terminal panels used as the source when creating inherited Ghostty config.
/// Preference order:
/// 1) explicitly preferred terminal panel (when the caller has one),
/// 2) selected terminal in the target pane,
/// 3) currently focused terminal in the workspace,
/// 4) last remembered terminal source,
/// 5) first terminal tab in the target pane,
/// 6) deterministic workspace fallback.
private func terminalPanelConfigInheritanceCandidates(
preferredPanelId: UUID? = nil,
inPane preferredPaneId: PaneID? = nil
) -> [TerminalPanel] {
var candidates: [TerminalPanel] = []
var seen: Set<UUID> = []
func appendCandidate(_ panel: TerminalPanel?) {
guard let panel, seen.insert(panel.id).inserted else { return }
candidates.append(panel)
}
if let preferredPanelId,
let terminalPanel = terminalPanel(for: preferredPanelId) {
appendCandidate(terminalPanel)
}
if let preferredPaneId,
let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id,
let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId),
let selectedTerminalPanel = terminalPanel(for: selectedPanelId) {
appendCandidate(selectedTerminalPanel)
}
if let focusedTerminalPanel {
appendCandidate(focusedTerminalPanel)
}
if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() {
appendCandidate(rememberedTerminalPanel)
}
if let preferredPaneId {
for tab in bonsplitController.tabs(inPane: preferredPaneId) {
guard let panelId = panelIdFromSurfaceId(tab.id),
let terminalPanel = terminalPanel(for: panelId) else { continue }
appendCandidate(terminalPanel)
}
}
for terminalPanel in panels.values
.compactMap({ $0 as? TerminalPanel })
.sorted(by: { $0.id.uuidString < $1.id.uuidString }) {
appendCandidate(terminalPanel)
}
return candidates
}
/// Picks the first terminal panel candidate used as the inheritance source.
func terminalPanelForConfigInheritance(
preferredPanelId: UUID? = nil,
inPane preferredPaneId: PaneID? = nil
) -> TerminalPanel? {
terminalPanelConfigInheritanceCandidates(
preferredPanelId: preferredPanelId,
inPane: preferredPaneId
).first
}
private func inheritedTerminalConfig(
preferredPanelId: UUID? = nil,
inPane preferredPaneId: PaneID? = nil
) -> ghostty_surface_config_s? {
// Walk candidates in priority order and use the first panel with a live surface.
// This avoids returning nil when the top candidate exists but is not attached yet.
for terminalPanel in terminalPanelConfigInheritanceCandidates(
preferredPanelId: preferredPanelId,
inPane: preferredPaneId
) {
guard let sourceSurface = terminalPanel.surface.surface else { continue }
var config = cmuxInheritedSurfaceConfig(
sourceSurface: sourceSurface,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT
)
if let rootedFontPoints = resolvedTerminalInheritanceFontPoints(
for: terminalPanel,
sourceSurface: sourceSurface,
inheritedConfig: config
), rootedFontPoints > 0 {
config.font_size = rootedFontPoints
terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints
}
rememberTerminalConfigInheritanceSource(terminalPanel)
if config.font_size > 0 {
lastTerminalConfigInheritanceFontPoints = config.font_size
}
return config
}
if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints {
var config = ghostty_surface_config_new()
config.font_size = fallbackFontPoints
#if DEBUG
dlog(
"zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))"
)
#endif
return config
}
return nil
}
/// Create a new split with a terminal panel
@discardableResult
func newTerminalSplit(
@ -927,22 +1158,6 @@ final class Workspace: Identifiable, ObservableObject {
insertFirst: Bool = false,
focus: Bool = true
) -> TerminalPanel? {
// Get inherited config from the source terminal when possible.
// If the split is initiated from a non-terminal panel (for example browser),
// fall back to any terminal in the workspace.
let inheritedConfig: ghostty_surface_config_s? = {
if let sourceTerminal = terminalPanel(for: panelId),
let existing = sourceTerminal.surface.surface {
return ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT)
}
if let fallbackSurface = panels.values
.compactMap({ ($0 as? TerminalPanel)?.surface.surface })
.first {
return ghostty_surface_inherited_config(fallbackSurface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
}
return nil
}()
// Find the pane containing the source panel
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
var sourcePaneId: PaneID?
@ -955,6 +1170,7 @@ final class Workspace: Identifiable, ObservableObject {
}
guard let paneId = sourcePaneId else { return nil }
let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId)
// Create the new terminal panel.
let newPanel = TerminalPanel(
@ -965,6 +1181,7 @@ final class Workspace: Identifiable, ObservableObject {
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
// Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit
// mutates layout state (avoids transient "Empty Panel" flashes during split).
@ -989,6 +1206,7 @@ final class Workspace: Identifiable, ObservableObject {
panels.removeValue(forKey: newPanel.id)
panelTitles.removeValue(forKey: newPanel.id)
surfaceIdToPanelId.removeValue(forKey: newTab.id)
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
return nil
}
@ -1024,16 +1242,7 @@ final class Workspace: Identifiable, ObservableObject {
func newTerminalSurface(inPane paneId: PaneID, focus: Bool? = nil) -> TerminalPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
// Get an existing terminal panel to inherit config from
let inheritedConfig: ghostty_surface_config_s? = {
for panel in panels.values {
if let terminalPanel = panel as? TerminalPanel,
let surface = terminalPanel.surface.surface {
return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
}
}
return nil
}()
let inheritedConfig = inheritedTerminalConfig(inPane: paneId)
// Create new terminal panel
let newPanel = TerminalPanel(
@ -1044,6 +1253,7 @@ final class Workspace: Identifiable, ObservableObject {
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
// Create tab in bonsplit
guard let newTabId = bonsplitController.createTab(
@ -1056,6 +1266,7 @@ final class Workspace: Identifiable, ObservableObject {
) else {
panels.removeValue(forKey: newPanel.id)
panelTitles.removeValue(forKey: newPanel.id)
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
return nil
}
@ -1819,14 +2030,19 @@ final class Workspace: Identifiable, ObservableObject {
/// Create a new terminal panel (used when replacing the last panel)
@discardableResult
func createReplacementTerminalPanel() -> TerminalPanel {
let inheritedConfig = inheritedTerminalConfig(
preferredPanelId: focusedPanelId,
inPane: bonsplitController.focusedPaneId
)
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_TAB,
configTemplate: nil,
configTemplate: inheritedConfig,
portOrdinal: portOrdinal
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
// Create tab in bonsplit
if let newTabId = bonsplitController.createTab(
@ -2100,6 +2316,9 @@ extension Workspace: BonsplitDelegate {
}
panel.focus()
if let terminalPanel = panel as? TerminalPanel {
rememberTerminalConfigInheritanceSource(terminalPanel)
}
let isManuallyUnread = manualUnreadPanelIds.contains(panelId)
let markedAt = manualUnreadMarkedAt[panelId]
if Self.shouldClearManualUnread(
@ -2327,6 +2546,10 @@ extension Workspace: BonsplitDelegate {
panelSubscriptions.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId)
if lastTerminalConfigInheritancePanelId == panelId {
lastTerminalConfigInheritancePanelId = nil
}
// Keep the workspace invariant: always retain at least one real panel.
// This prevents runtime close callbacks from ever collapsing into a tabless workspace.
@ -2519,15 +2742,7 @@ extension Workspace: BonsplitDelegate {
// Keep the existing placeholder tab identity and replace only the panel mapping.
// This avoids an extra create+close tab churn that can transiently render an
// empty pane during drag-to-split of a single-tab pane.
let inheritedConfig: ghostty_surface_config_s? = {
for panel in panels.values {
if let terminalPanel = panel as? TerminalPanel,
let surface = terminalPanel.surface.surface {
return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
}
}
return nil
}()
let inheritedConfig = inheritedTerminalConfig(inPane: originalPane)
let replacementPanel = TerminalPanel(
workspaceId: id,
@ -2537,6 +2752,7 @@ extension Workspace: BonsplitDelegate {
)
panels[replacementPanel.id] = replacementPanel
panelTitles[replacementPanel.id] = replacementPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig)
surfaceIdToPanelId[replacementTab.id] = replacementPanel.id
bonsplitController.updateTab(
@ -2579,7 +2795,7 @@ extension Workspace: BonsplitDelegate {
// Get the focused terminal in the original pane to inherit config from
guard let sourceTabId = controller.selectedTab(inPane: originalPane)?.id,
let sourcePanelId = panelIdFromSurfaceId(sourceTabId),
let sourcePanel = terminalPanel(for: sourcePanelId) else { return }
terminalPanel(for: sourcePanelId) != nil else { return }
#if DEBUG
dlog(
@ -2588,11 +2804,10 @@ extension Workspace: BonsplitDelegate {
)
#endif
let inheritedConfig: ghostty_surface_config_s? = if let existing = sourcePanel.surface.surface {
ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT)
} else {
nil
}
let inheritedConfig = inheritedTerminalConfig(
preferredPanelId: sourcePanelId,
inPane: originalPane
)
let newPanel = TerminalPanel(
workspaceId: id,
@ -2602,6 +2817,7 @@ extension Workspace: BonsplitDelegate {
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
guard let newTabId = bonsplitController.createTab(
title: newPanel.displayTitle,
@ -2613,6 +2829,7 @@ extension Workspace: BonsplitDelegate {
) else {
panels.removeValue(forKey: newPanel.id)
panelTitles.removeValue(forKey: newPanel.id)
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
return
}

View file

@ -1238,6 +1238,10 @@ final class BrowserZoomShortcutActionTests: XCTestCase {
browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24),
.zoomIn
)
XCTAssertEqual(
browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30),
.zoomIn
)
}
func testZoomOutSupportsMinusAndUnderscoreVariants() {
@ -1316,6 +1320,30 @@ final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase {
}
}
final class GhosttyResponderResolutionTests: XCTestCase {
private final class FocusProbeView: NSView {
override var acceptsFirstResponder: Bool { true }
}
func testResolvesGhosttyViewFromDescendantResponder() {
let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
ghosttyView.addSubview(descendant)
XCTAssertTrue(cmuxOwningGhosttyView(for: descendant) === ghosttyView)
}
func testResolvesGhosttyViewFromGhosttyResponder() {
let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
XCTAssertTrue(cmuxOwningGhosttyView(for: ghosttyView) === ghosttyView)
}
func testReturnsNilForUnrelatedResponder() {
let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
XCTAssertNil(cmuxOwningGhosttyView(for: view))
}
}
final class CommandPaletteKeyboardNavigationTests: XCTestCase {
func testArrowKeysMoveSelectionWithoutModifiers() {
XCTAssertEqual(
@ -2313,6 +2341,141 @@ final class TabManagerSurfaceCreationTests: XCTestCase {
}
}
@MainActor
final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase {
func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let leftPanelId = workspace.focusedPanelId,
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else {
XCTFail("Expected workspace split setup to succeed")
return
}
// Programmatic split focuses the new right panel by default.
XCTAssertEqual(workspace.focusedPanelId, rightPanel.id)
let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: leftPaneId)
XCTAssertEqual(
sourcePanel?.id,
leftPanelId,
"Expected inheritance to use the selected terminal in the target pane"
)
}
func testFallsBackToAnotherTerminalInPaneWhenSelectedTabIsBrowser() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let terminalPanelId = workspace.focusedPanelId,
let paneId = workspace.paneId(forPanelId: terminalPanelId),
let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else {
XCTFail("Expected workspace browser setup to succeed")
return
}
XCTAssertEqual(workspace.focusedPanelId, browserPanel.id)
let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: paneId)
XCTAssertEqual(
sourcePanel?.id,
terminalPanelId,
"Expected inheritance to fall back to a terminal in the pane when browser is selected"
)
}
func testPreferredTerminalPanelWinsWhenProvided() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let terminalPanelId = workspace.focusedPanelId else {
XCTFail("Expected selected workspace with a terminal panel")
return
}
let sourcePanel = workspace.terminalPanelForConfigInheritance(preferredPanelId: terminalPanelId)
XCTAssertEqual(sourcePanel?.id, terminalPanelId)
}
func testPrefersLastFocusedTerminalWhenBrowserFocusedInDifferentPane() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let leftTerminalPanelId = workspace.focusedPanelId,
let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal),
let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else {
XCTFail("Expected split setup to succeed")
return
}
workspace.focusPanel(leftTerminalPanelId)
_ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true)
XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId)
let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId)
XCTAssertEqual(
sourcePanel?.id,
leftTerminalPanelId,
"Expected inheritance to prefer last focused terminal when browser is focused in another pane"
)
}
}
@MainActor
final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase {
func testUsesFocusedTerminalWhenTerminalIsFocused() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let terminalPanelId = workspace.focusedPanelId else {
XCTFail("Expected selected workspace with focused terminal")
return
}
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
XCTAssertEqual(sourcePanel?.id, terminalPanelId)
}
func testFallsBackToTerminalWhenBrowserIsFocused() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let terminalPanelId = workspace.focusedPanelId,
let paneId = workspace.paneId(forPanelId: terminalPanelId),
let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else {
XCTFail("Expected selected workspace setup to succeed")
return
}
XCTAssertEqual(workspace.focusedPanelId, browserPanel.id)
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
XCTAssertEqual(
sourcePanel?.id,
terminalPanelId,
"Expected new workspace inheritance source to resolve to the pane terminal when browser is focused"
)
}
func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let leftTerminalPanelId = workspace.focusedPanelId,
let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal),
let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else {
XCTFail("Expected split setup to succeed")
return
}
workspace.focusPanel(leftTerminalPanelId)
_ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true)
XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId)
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
XCTAssertEqual(
sourcePanel?.id,
leftTerminalPanelId,
"Expected workspace inheritance source to use last focused terminal across panes"
)
}
}
@MainActor
final class TabManagerReopenClosedBrowserFocusTests: XCTestCase {
func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() {