From 258ad92883f3f42734075ea04fa33da1a400afcd Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 20 Oct 2026 08:29:06 -0300 Subject: [PATCH 1/5] test: split panes with one tab each keep their tab bars --- Sources/AppDelegate+UITestHarnesses.swift | 7 +++- programaUITests/BonsplitTabDragUITests.swift | 40 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Sources/AppDelegate+UITestHarnesses.swift b/Sources/AppDelegate+UITestHarnesses.swift index fb3b1df9..fb02eb8a 100644 --- a/Sources/AppDelegate+UITestHarnesses.swift +++ b/Sources/AppDelegate+UITestHarnesses.swift @@ -152,6 +152,7 @@ extension AppDelegate { guard env["PROGRAMA_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] == "1" else { return } guard tabManager != nil else { return } let startWithHiddenSidebar = env["PROGRAMA_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] == "1" + let splitPanes = env["PROGRAMA_UI_TEST_BONSPLIT_SPLIT_PANES"] == "1" let deadline = Date().addingTimeInterval(20.0) func hasMainTerminalWindow() -> Bool { @@ -204,7 +205,11 @@ extension AppDelegate { let betaTitle = "UITest Beta" tabManager.setCustomTitle(tabId: workspace.id, title: workspaceTitle) workspace.setPanelCustomTitle(panelId: alphaPanelId, title: alphaTitle) - tabManager.newSurface() + if splitPanes { + tabManager.createSplit(direction: .right) + } else { + tabManager.newSurface() + } guard let betaPanelId = workspace.focusedPanelId, betaPanelId != alphaPanelId else { self.writeBonsplitTabDragUITestData(["setupError": "Failed to create second surface"]) diff --git a/programaUITests/BonsplitTabDragUITests.swift b/programaUITests/BonsplitTabDragUITests.swift index e36ff5d8..47e64e4f 100644 --- a/programaUITests/BonsplitTabDragUITests.swift +++ b/programaUITests/BonsplitTabDragUITests.swift @@ -418,6 +418,42 @@ final class BonsplitTabDragUITests: XCTestCase { ) } + func testSplitPanesWithOneTabEachKeepTheirTabBars() { + let (app, dataPath) = launchConfiguredApp(splitPanes: true) + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for split-pane tab bar UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + + // Each pane holds a single tab. Its tab bar still has to show, since that bar + // carries the close and new-tab controls that undo the split. + let alphaTab = app.buttons[ready["alphaTitle"] ?? "UITest Alpha"] + let betaTab = app.buttons[ready["betaTitle"] ?? "UITest Beta"] + XCTAssertTrue( + waitForCondition(timeout: 5.0) { alphaTab.exists && alphaTab.isHittable }, + "Expected the left pane's tab bar to show its only tab. window=\(window.frame) alpha=\(alphaTab.debugDescription)" + ) + XCTAssertTrue( + waitForCondition(timeout: 5.0) { betaTab.exists && betaTab.isHittable }, + "Expected the right pane's tab bar to show its only tab. window=\(window.frame) beta=\(betaTab.debugDescription)" + ) + XCTAssertLessThan(alphaTab.frame.midX, betaTab.frame.midX, "Expected alpha and beta in side-by-side panes") + } + private enum WorkspacePresentationMode: String { case standard case minimal @@ -425,6 +461,7 @@ final class BonsplitTabDragUITests: XCTestCase { private func launchConfiguredApp( startWithHiddenSidebar: Bool = false, + splitPanes: Bool = false, presentationMode: WorkspacePresentationMode = .minimal ) -> (XCUIApplication, String) { let app = XCUIApplication() @@ -436,6 +473,9 @@ final class BonsplitTabDragUITests: XCTestCase { if startWithHiddenSidebar { app.launchEnvironment["PROGRAMA_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] = "1" } + if splitPanes { + app.launchEnvironment["PROGRAMA_UI_TEST_BONSPLIT_SPLIT_PANES"] = "1" + } app.launchArguments += ["-workspacePresentationMode", presentationMode.rawValue] app.launch() app.activate() From c23c3851cc4a98f2bc863563ecc94d629cf848be Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 20 Oct 2026 08:31:41 -0300 Subject: [PATCH 2/5] fix: keep the tab bar on every pane once a workspace is split --- CHANGELOG.md | 2 +- .../Bonsplit/Internal/Views/PaneContainerView.swift | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b086315..94392a91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - On Windows, pasting text with line breaks or control characters into a program that has not turned on bracketed paste (cmd.exe, PowerShell) asks first, with a preview. ### Changed -- A pane with a single tab hides its tab bar, along with the new-tab and split buttons. The bar comes back when the pane gets a second tab. +- A workspace with a single tab hides its tab bar, along with the new-tab and split buttons. The bar comes back when the pane gets a second tab or the workspace is split, so every split pane keeps its close and new-tab buttons. - A focused terminal that has nothing new to show uses about half as much CPU. It stops waking up on every screen refresh after a quarter second of quiet and starts again on the next change. - Only one copy of Programa runs at a time: launching a second copy brings the running app forward and exits. - Closing a window with its red button or Close Window asks first when any of its workspaces has a running process, with the same confirmation as closing the last workspace. The socket method `window.close` still closes without asking. diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index 27fe911a..8fe6fb77 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -558,12 +558,14 @@ struct PaneContainerView: View { : 0 } - /// A pane with exactly one tab has nothing to switch between, so its strip is - /// hidden. A nonzero `leadingInset` means the strip doubles as the titlebar row - /// beside the traffic lights (minimal mode, sidebar hidden); it stays so the - /// terminal does not slide under them and the window remains draggable there. + /// The only pane of a workspace with exactly one tab has nothing to switch + /// between, so its strip is hidden. Once the workspace is split, every pane keeps + /// its strip: it carries the close, new-tab and split controls that undo the split. + /// A nonzero `leadingInset` means the strip doubles as the titlebar row beside the + /// traffic lights (minimal mode, sidebar hidden); it stays so the terminal does not + /// slide under them and the window remains draggable there. private var showsTabStrip: Bool { - pane.tabs.count != 1 || leadingInset > 0 + pane.tabs.count != 1 || leadingInset > 0 || controller.rootNode.allPaneIds.count > 1 } var body: some View { From 46887b4810886d2685efbe5670956f1cc307a973 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 20 Oct 2026 08:42:06 -0300 Subject: [PATCH 3/5] test: check split-pane tabs by frame, not XCUI hittability --- programaUITests/BonsplitTabDragUITests.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/programaUITests/BonsplitTabDragUITests.swift b/programaUITests/BonsplitTabDragUITests.swift index 47e64e4f..6ac3b418 100644 --- a/programaUITests/BonsplitTabDragUITests.swift +++ b/programaUITests/BonsplitTabDragUITests.swift @@ -440,15 +440,16 @@ final class BonsplitTabDragUITests: XCTestCase { XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") // Each pane holds a single tab. Its tab bar still has to show, since that bar - // carries the close and new-tab controls that undo the split. + // carries the close and new-tab controls that undo the split. `isHittable` is + // not usable here: XCUI cannot hit-test through the macOS 26 glass tab pills. let alphaTab = app.buttons[ready["alphaTitle"] ?? "UITest Alpha"] let betaTab = app.buttons[ready["betaTitle"] ?? "UITest Beta"] XCTAssertTrue( - waitForCondition(timeout: 5.0) { alphaTab.exists && alphaTab.isHittable }, + waitForCondition(timeout: 5.0) { alphaTab.exists && window.frame.contains(alphaTab.frame) }, "Expected the left pane's tab bar to show its only tab. window=\(window.frame) alpha=\(alphaTab.debugDescription)" ) XCTAssertTrue( - waitForCondition(timeout: 5.0) { betaTab.exists && betaTab.isHittable }, + waitForCondition(timeout: 5.0) { betaTab.exists && window.frame.contains(betaTab.frame) }, "Expected the right pane's tab bar to show its only tab. window=\(window.frame) beta=\(betaTab.debugDescription)" ) XCTAssertLessThan(alphaTab.frame.midX, betaTab.frame.midX, "Expected alpha and beta in side-by-side panes") From 7a09b4defb0ce60923eb79d833e07893acb44b24 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 20 Oct 2026 10:23:12 -0300 Subject: [PATCH 4/5] fix: keep an empty drag strip at the top when a single tab hides its tab bar --- CHANGELOG.md | 2 +- .../Bonsplit/Internal/Views/PaneContainerView.swift | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94392a91..bbaff705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - On Windows, pasting text with line breaks or control characters into a program that has not turned on bracketed paste (cmd.exe, PowerShell) asks first, with a preview. ### Changed -- A workspace with a single tab hides its tab bar, along with the new-tab and split buttons. The bar comes back when the pane gets a second tab or the workspace is split, so every split pane keeps its close and new-tab buttons. +- A workspace with a single tab hides its tab bar, along with the new-tab and split buttons, and leaves an empty strip in its place so the window can still be dragged from the top. The bar comes back when the pane gets a second tab or the workspace is split, so every split pane keeps its close and new-tab buttons. - A focused terminal that has nothing new to show uses about half as much CPU. It stops waking up on every screen refresh after a quarter second of quiet and starts again on the next change. - Only one copy of Programa runs at a time: launching a second copy brings the running app forward and exits. - Closing a window with its red button or Close Window asks first when any of its workspaces has a running process, with the same confirmation as closing the last workspace. The socket method `window.close` still closes without asking. diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index 8fe6fb77..7298531c 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -559,7 +559,7 @@ struct PaneContainerView: View { } /// The only pane of a workspace with exactly one tab has nothing to switch - /// between, so its strip is hidden. Once the workspace is split, every pane keeps + /// between, so its strip is hidden and an empty drag band takes its place. Once the workspace is split, every pane keeps /// its strip: it carries the close, new-tab and split controls that undo the split. /// A nonzero `leadingInset` means the strip doubles as the titlebar row beside the /// traffic lights (minimal mode, sidebar hidden); it stays so the terminal does not @@ -594,6 +594,13 @@ struct PaneContainerView: View { showSplitButtons: showSplitButtons ) } + if !showsTabStrip { + // The hidden strip's pane sits at the window top, so this band stands + // in for the titlebar: without it nothing up there drags the window. + TabBarDragZoneView() + .frame(height: usesNativePaneChrome ? TabBarGlassStyling.barHeight : TabBarMetrics.barHeight) + .accessibilityHidden(true) + } // Content area with drop zones contentAreaWithDropZones From e9f46e13cfc9fbd4df320cddd3d32c1239708527 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 20 Oct 2026 10:42:12 -0300 Subject: [PATCH 5/5] feat: glass + button in the empty top strip opens a new tab --- CHANGELOG.md | 2 +- Sources/AppDelegate+UITestHarnesses.swift | 10 ++++ programaUITests/BonsplitTabDragUITests.swift | 38 ++++++++++++ .../Internal/Views/PaneContainerView.swift | 58 +++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + 6 files changed, 109 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbaff705..1864cb3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - On Windows, pasting text with line breaks or control characters into a program that has not turned on bracketed paste (cmd.exe, PowerShell) asks first, with a preview. ### Changed -- A workspace with a single tab hides its tab bar, along with the new-tab and split buttons, and leaves an empty strip in its place so the window can still be dragged from the top. The bar comes back when the pane gets a second tab or the workspace is split, so every split pane keeps its close and new-tab buttons. +- A workspace with a single tab hides its tab bar, along with the new-tab and split buttons, and leaves an empty strip in its place so the window can still be dragged from the top. A glass + button in that strip's right corner opens a new tab. The bar comes back when the pane gets a second tab or the workspace is split, so every split pane keeps its close and new-tab buttons. - A focused terminal that has nothing new to show uses about half as much CPU. It stops waking up on every screen refresh after a quarter second of quiet and starts again on the next change. - Only one copy of Programa runs at a time: launching a second copy brings the running app forward and exits. - Closing a window with its red button or Close Window asks first when any of its workspaces has a running process, with the same confirmation as closing the last workspace. The socket method `window.close` still closes without asking. diff --git a/Sources/AppDelegate+UITestHarnesses.swift b/Sources/AppDelegate+UITestHarnesses.swift index fb02eb8a..4e12fea7 100644 --- a/Sources/AppDelegate+UITestHarnesses.swift +++ b/Sources/AppDelegate+UITestHarnesses.swift @@ -153,6 +153,7 @@ extension AppDelegate { guard tabManager != nil else { return } let startWithHiddenSidebar = env["PROGRAMA_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] == "1" let splitPanes = env["PROGRAMA_UI_TEST_BONSPLIT_SPLIT_PANES"] == "1" + let singleTab = env["PROGRAMA_UI_TEST_BONSPLIT_SINGLE_TAB"] == "1" let deadline = Date().addingTimeInterval(20.0) func hasMainTerminalWindow() -> Bool { @@ -205,6 +206,15 @@ extension AppDelegate { let betaTitle = "UITest Beta" tabManager.setCustomTitle(tabId: workspace.id, title: workspaceTitle) workspace.setPanelCustomTitle(panelId: alphaPanelId, title: alphaTitle) + if singleTab { + self.writeBonsplitTabDragUITestData([ + "ready": "1", + "workspaceId": workspace.id.uuidString, + "alphaTitle": alphaTitle, + "alphaPanelId": alphaPanelId.uuidString, + ]) + return + } if splitPanes { tabManager.createSplit(direction: .right) } else { diff --git a/programaUITests/BonsplitTabDragUITests.swift b/programaUITests/BonsplitTabDragUITests.swift index 6ac3b418..8d7aa29f 100644 --- a/programaUITests/BonsplitTabDragUITests.swift +++ b/programaUITests/BonsplitTabDragUITests.swift @@ -455,6 +455,40 @@ final class BonsplitTabDragUITests: XCTestCase { XCTAssertLessThan(alphaTab.frame.midX, betaTab.frame.midX, "Expected alpha and beta in side-by-side panes") } + func testSingleTabFloatingNewTabButtonOpensATab() { + let (app, dataPath) = launchConfiguredApp(singleTab: true) + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for floating new-tab button UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + + // A single tab hides its tab bar, so the strip left in its place carries the "+". + let alphaTab = app.buttons[ready["alphaTitle"] ?? "UITest Alpha"] + let newTabButton = app.descendants(matching: .any).matching(identifier: "paneFloatingNewTabButton").firstMatch + XCTAssertTrue(newTabButton.waitForExistence(timeout: 5.0), "Expected the floating new-tab button with a single tab") + XCTAssertFalse(alphaTab.exists, "Expected the single tab's tab bar to be hidden") + + newTabButton.click() + + XCTAssertTrue( + waitForCondition(timeout: 5.0) { alphaTab.exists && !newTabButton.exists }, + "Expected the click to open a second tab and bring the tab bar back. alpha=\(alphaTab.debugDescription)" + ) + } + private enum WorkspacePresentationMode: String { case standard case minimal @@ -463,6 +497,7 @@ final class BonsplitTabDragUITests: XCTestCase { private func launchConfiguredApp( startWithHiddenSidebar: Bool = false, splitPanes: Bool = false, + singleTab: Bool = false, presentationMode: WorkspacePresentationMode = .minimal ) -> (XCUIApplication, String) { let app = XCUIApplication() @@ -477,6 +512,9 @@ final class BonsplitTabDragUITests: XCTestCase { if splitPanes { app.launchEnvironment["PROGRAMA_UI_TEST_BONSPLIT_SPLIT_PANES"] = "1" } + if singleTab { + app.launchEnvironment["PROGRAMA_UI_TEST_BONSPLIT_SINGLE_TAB"] = "1" + } app.launchArguments += ["-workspacePresentationMode", presentationMode.rawValue] app.launch() app.activate() diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index 7298531c..dfde4aea 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -600,6 +600,16 @@ struct PaneContainerView: View { TabBarDragZoneView() .frame(height: usesNativePaneChrome ? TabBarGlassStyling.barHeight : TabBarMetrics.barHeight) .accessibilityHidden(true) + .overlay(alignment: .trailing) { + FloatingNewTabButton( + title: Bundle.module.localizedString(forKey: "tabBar.newTab", value: "New Tab", table: nil) + ) { [weak pane, weak bonsplitController] in + guard let pane, let bonsplitController else { return } + bonsplitController.requestNewTab(kind: "terminal", inPane: pane.id) + } + .frame(width: 24, height: 22) + .padding(.trailing, 8) + } } // Content area with drop zones @@ -1020,3 +1030,51 @@ struct UnifiedPaneDropDelegate: DropDelegate { return nil } } + +/// The "+" that opens a new tab from the empty strip a single tab leaves behind. +/// It is an AppKit button on purpose: the window's titlebar-drag routing in +/// `programa_sendEvent` turns a click on plain SwiftUI content there into a window drag. +private struct FloatingNewTabButton: NSViewRepresentable { + let title: String + let action: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(action: action) } + + func makeNSView(context: Context) -> NSView { + let button = NSButton(frame: .zero) + button.image = NSImage(systemSymbolName: "plus", accessibilityDescription: title) + button.isBordered = false + button.contentTintColor = .secondaryLabelColor + button.toolTip = title + button.setAccessibilityLabel(title) + button.setAccessibilityIdentifier("paneFloatingNewTabButton") + button.target = context.coordinator + button.action = #selector(Coordinator.pressed) + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + let glass = NSGlassEffectView(frame: .zero) + glass.style = .regular + glass.cornerRadius = 11 + glass.contentView = button + return glass + } + #endif + return button + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.action = action + } + + final class Coordinator: NSObject { + var action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + @objc func pressed() { + action() + } + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Resources/en.lproj/Localizable.strings b/vendor/bonsplit/Sources/Bonsplit/Resources/en.lproj/Localizable.strings index fbe1639b..be17e61f 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Resources/en.lproj/Localizable.strings +++ b/vendor/bonsplit/Sources/Bonsplit/Resources/en.lproj/Localizable.strings @@ -3,3 +3,4 @@ "contextMenu.tabColor" = "Tab Color"; "contextMenu.clearColor" = "Clear Color"; "contextMenu.chooseCustomColor" = "Choose Custom Color…"; +"tabBar.newTab" = "New Tab"; diff --git a/vendor/bonsplit/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings b/vendor/bonsplit/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings index eb9a47bc..be2ad4bb 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings +++ b/vendor/bonsplit/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings @@ -3,3 +3,4 @@ "contextMenu.tabColor" = "タブの色"; "contextMenu.clearColor" = "色をクリア"; "contextMenu.chooseCustomColor" = "カスタムカラーを選択…"; +"tabBar.newTab" = "新規タブ";