From 626edb5f9dfa3cbba031d076720802e7e0efa8b4 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 29 Sep 2026 14:50:49 -0300 Subject: [PATCH 1/5] docs: drop the phantom homebrew submodule and pin the zig version in setup --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 968dc99c7..77583274d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ - macOS 14+ - Xcode 26+ (Swift 6 and the macOS 26 SDK) -- [Zig](https://ziglang.org/) (install via `brew install zig`) +- [Zig](https://ziglang.org/), the exact version printed by `scripts/required-zig-version.sh` (currently 0.16.0). `brew install zig` may install a different version, which fails the GhosttyKit build - [Rust](https://rustup.rs/) with Cargo for the shared application core. Add `aarch64-apple-darwin` and `x86_64-apple-darwin` targets when building a universal macOS release. The Xcode build compiles and links the core automatically. @@ -28,7 +28,7 @@ in [docs/windows-testing.md](docs/windows-testing.md). ``` This will: - - Initialize git submodules (ghostty, homebrew-programa) + - Initialize git submodules (ghostty) - Build the GhosttyKit.xcframework from source - Create the necessary symlinks From c93520c3e95c63147e5f75977ae3c2029eebb2b1 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 29 Sep 2026 14:50:49 -0300 Subject: [PATCH 2/5] fix: carry tab color through split-with-tab --- .../Bonsplit/Internal/Models/TabItem.swift | 16 ++++++++++++ .../Bonsplit/Public/BonsplitController.swift | 26 ++----------------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Models/TabItem.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Models/TabItem.swift index df5603b3a..411cc4134 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Internal/Models/TabItem.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Models/TabItem.swift @@ -53,6 +53,22 @@ struct TabItem: Identifiable, Hashable, Codable { self.customColorHex = customColorHex } + init(from tab: Tab) { + self.init( + id: tab.id.id, + title: tab.title, + hasCustomTitle: tab.hasCustomTitle, + icon: tab.icon, + iconImageData: tab.iconImageData, + kind: tab.kind, + isDirty: tab.isDirty, + showsNotificationBadge: tab.showsNotificationBadge, + isLoading: tab.isLoading, + isPinned: tab.isPinned, + customColorHex: tab.customColorHex + ) + } + func hash(into hasher: inout Hasher) { hasher.combine(id) } diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitController.swift b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitController.swift index 25e8e0a4e..d11ec3e3e 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitController.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitController.swift @@ -423,18 +423,7 @@ public final class BonsplitController { let internalTab: TabItem? if let tab { - internalTab = TabItem( - id: tab.id.id, - title: tab.title, - hasCustomTitle: tab.hasCustomTitle, - icon: tab.icon, - iconImageData: tab.iconImageData, - kind: tab.kind, - isDirty: tab.isDirty, - showsNotificationBadge: tab.showsNotificationBadge, - isLoading: tab.isLoading, - isPinned: tab.isPinned - ) + internalTab = TabItem(from: tab) } else { internalTab = nil } @@ -485,18 +474,7 @@ public final class BonsplitController { return nil } - let internalTab = TabItem( - id: tab.id.id, - title: tab.title, - hasCustomTitle: tab.hasCustomTitle, - icon: tab.icon, - iconImageData: tab.iconImageData, - kind: tab.kind, - isDirty: tab.isDirty, - showsNotificationBadge: tab.showsNotificationBadge, - isLoading: tab.isLoading, - isPinned: tab.isPinned - ) + let internalTab = TabItem(from: tab) // Perform split with insertion side. internalController.splitPaneWithTab( From 5fb88d526a3dcfd8c18680c2dd454b6405b2c853 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 29 Sep 2026 14:50:49 -0300 Subject: [PATCH 3/5] feat: show tab colors and the Tab Color menu in the glass tab bar --- Sources/WindowPaneChromePortal.swift | 47 +++++++++++++++++-- .../Internal/Views/PaneContainerView.swift | 36 ++++++++++++++ .../BonsplitPaneChromePortalBridge.swift | 11 +++++ 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/Sources/WindowPaneChromePortal.swift b/Sources/WindowPaneChromePortal.swift index 26e4b3f7e..b2c6e01c5 100644 --- a/Sources/WindowPaneChromePortal.swift +++ b/Sources/WindowPaneChromePortal.swift @@ -670,6 +670,7 @@ private final class NativePaneTabBarView: PaneChromeDragBackgroundView { select: { [weak descriptor] in descriptor?.onSelect(tab.id) }, close: { [weak descriptor] in descriptor?.onClose(tab.id) }, context: { [weak descriptor] action in descriptor?.onContextAction(tab.id, action) }, + applyTabColor: { [weak descriptor] hex in descriptor?.onApplyTabColor(tab.id, hex) }, dragData: { [weak descriptor] in descriptor?.dragPasteboardData(tab.id) }, dragState: { [weak descriptor] active in descriptor?.onDragStateChanged(tab.id, active) } ) @@ -952,11 +953,16 @@ private final class NativeGlassTabPillView: NSView, NSDraggingSource { select: @escaping () -> Void, close: @escaping () -> Void, context: @escaping (TabContextAction) -> Void, + applyTabColor: @escaping (String) -> Void, dragData: @escaping () -> Data?, dragState: @escaping (Bool) -> Void ) { - control.update(tab, select: select, close: close, context: context, dragData: dragData, dragState: dragState) + control.update( + tab, select: select, close: close, context: context, + applyTabColor: applyTabColor, dragData: dragData, dragState: dragState + ) isSelected = tab.isSelected + customTint = tab.customColorHex.flatMap { NSColor(hex: $0) } control.onHoverChanged = { [weak self] hovering in self?.isHovered = hovering self?.applySurfaceState() @@ -966,6 +972,8 @@ private final class NativeGlassTabPillView: NSView, NSDraggingSource { private var isSelected = false private var isHovered = false + /// User-chosen tab color; when set it replaces the neutral surface tone. + private var customTint: NSColor? /// Maps-style states: the selected pill reads as a solid lit surface, /// hover lifts a quiet pill slightly, unselected pills stay quiet glass. @@ -976,7 +984,12 @@ private final class NativeGlassTabPillView: NSView, NSDraggingSource { // control capsules share it; quiet pills stay untinted. labelColor@0.12 // is a white lift in dark mode, but the same alpha in light mode is a // black wash that reads muddy/pressed — halve it there. - if isSelected { + if let customTint { + // Same low-opacity wash as the SwiftUI tab bar (0.2), lifted a little + // for hover/selected so those states stay distinguishable. + let alpha: CGFloat = isSelected ? 0.4 : (isHovered ? 0.3 : 0.2) + glass.tintColor = customTint.withAlphaComponent(alpha) + } else if isSelected { glass.tintColor = WindowGlassEffect.surfaceLiftTint(for: effectiveAppearance) } else if isHovered { glass.tintColor = WindowGlassEffect.surfaceLiftTint(for: effectiveAppearance, hover: true) @@ -1008,6 +1021,7 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS private var selectAction: (() -> Void)? private var closeAction: (() -> Void)? private var contextAction: ((TabContextAction) -> Void)? + private var applyTabColorAction: ((String) -> Void)? private var dragData: (() -> Data?)? private var dragState: ((Bool) -> Void)? var onHoverChanged: ((Bool) -> Void)? @@ -1070,6 +1084,7 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS select: @escaping () -> Void, close: @escaping () -> Void, context: @escaping (TabContextAction) -> Void, + applyTabColor: @escaping (String) -> Void, dragData: @escaping () -> Data?, dragState: @escaping (Bool) -> Void ) { @@ -1077,6 +1092,7 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS selectAction = select closeAction = close contextAction = context + applyTabColorAction = applyTabColor self.dragData = dragData self.dragState = dragState titleField.stringValue = tab.title @@ -1187,8 +1203,12 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS override func rightMouseDown(with event: NSEvent) { guard let tab else { return } + NSMenu.popUpContextMenu(makeMenu(from: tab.menuItems), with: event, for: self) + } + + private func makeMenu(from items: [BonsplitPaneChromeMenuItem]) -> NSMenu { let menu = NSMenu() - for item in tab.menuItems { + for item in items { switch item { case .separator: menu.addItem(.separator()) @@ -1198,9 +1218,28 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS menuItem.representedObject = ActionBox(action) menuItem.isEnabled = enabled menu.addItem(menuItem) + case .submenu(let title, let children): + let menuItem = NSMenuItem(title: title, action: nil, keyEquivalent: "") + menuItem.submenu = makeMenu(from: children) + menu.addItem(menuItem) + case .tabColor(let title, let hex, let swatch): + let menuItem = NSMenuItem(title: title, action: #selector(applyTabColor(_:)), keyEquivalent: "") + menuItem.target = self + menuItem.representedObject = hex + menuItem.image = NSImage(size: NSSize(width: 12, height: 12), flipped: false) { rect in + swatch.setFill() + NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)).fill() + return true + } + menu.addItem(menuItem) } } - NSMenu.popUpContextMenu(menu, with: event, for: self) + return menu + } + + @objc private func applyTabColor(_ sender: NSMenuItem) { + guard let hex = sender.representedObject as? String else { return } + applyTabColorAction?(hex) } @objc private func closePressed() { closeAction?() } diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index eda05ae41..ee200ae03 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -284,6 +284,7 @@ private struct BonsplitPaneChromeAnchor: NSViewRepresentable { isPinned: tab.isPinned, isDirty: tab.isDirty, showsNotificationBadge: tab.showsNotificationBadge, + customColorHex: tab.customColorHex, accessibilityValue: accessibilityValue(for: tab), menuItems: menuItems(for: tab, at: index, splitController: splitController) ) @@ -343,6 +344,10 @@ private struct BonsplitPaneChromeAnchor: NSViewRepresentable { guard let pane, let bonsplitController else { return } bonsplitController.requestTabContextAction(action, for: tabID, inPane: pane.id) }, + onApplyTabColor: { [weak pane, weak bonsplitController] tabID, hex in + guard let pane, let bonsplitController else { return } + bonsplitController.requestTabColor(hex, for: tabID, inPane: pane.id) + }, dragPasteboardData: { [weak pane, weak splitController] tabID in guard let pane, let splitController, let tab = pane.tabs.first(where: { $0.id == tabID.id }) else { return nil } @@ -486,9 +491,40 @@ private struct BonsplitPaneChromeAnchor: NSViewRepresentable { action: tab.showsNotificationBadge ? .markAsRead : .markAsUnread, isEnabled: true )) + items.append(.separator) + items.append(tabColorSubmenu(for: tab)) return items } + private func tabColorSubmenu(for tab: TabItem) -> BonsplitPaneChromeMenuItem { + var items: [BonsplitPaneChromeMenuItem] = [] + if tab.customColorHex != nil { + items.append(.action( + title: localized("contextMenu.clearColor", defaultValue: "Clear Color"), + action: .clearTabColor, + isEnabled: true + )) + } + items.append(.action( + title: localized("contextMenu.chooseCustomColor", defaultValue: "Choose Custom Color…"), + action: .chooseCustomTabColor, + isEnabled: true + )) + let palette = bonsplitController?.tabColorPaletteProvider?() ?? [] + if !palette.isEmpty { items.append(.separator) } + for swatch in palette { + items.append(.tabColor(title: swatch.name, hex: swatch.hex, swatch: swatch.swatchColor)) + } + return .submenu( + title: localized("contextMenu.tabColor", defaultValue: "Tab Color"), + items: items + ) + } + + private func localized(_ key: String, defaultValue: String) -> String { + Bundle.module.localizedString(forKey: key, value: defaultValue, table: nil) + } + private func localized(_ value: String) -> String { Bundle.module.localizedString(forKey: value, value: value, table: nil) } diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitPaneChromePortalBridge.swift b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitPaneChromePortalBridge.swift index 328cb7d16..1a1ccb7b2 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitPaneChromePortalBridge.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitPaneChromePortalBridge.swift @@ -5,6 +5,10 @@ import Foundation public enum BonsplitPaneChromeMenuItem { case separator case action(title: String, action: TabContextAction, isEnabled: Bool) + /// A nested menu (used for the Tab Color submenu). + indirect case submenu(title: String, items: [BonsplitPaneChromeMenuItem]) + /// A palette swatch that applies `hex` to the tab through `onApplyTabColor`. + case tabColor(title: String, hex: String, swatch: NSColor) } /// Read-only tab state consumed by the native pane chrome renderer. @@ -17,6 +21,8 @@ public struct BonsplitPaneChromeTabDescriptor { public let isPinned: Bool public let isDirty: Bool public let showsNotificationBadge: Bool + /// Optional "#RRGGBB" tint for the tab. + public let customColorHex: String? public let accessibilityValue: String public let menuItems: [BonsplitPaneChromeMenuItem] @@ -29,6 +35,7 @@ public struct BonsplitPaneChromeTabDescriptor { isPinned: Bool, isDirty: Bool, showsNotificationBadge: Bool, + customColorHex: String?, accessibilityValue: String, menuItems: [BonsplitPaneChromeMenuItem] ) { @@ -40,6 +47,7 @@ public struct BonsplitPaneChromeTabDescriptor { self.isPinned = isPinned self.isDirty = isDirty self.showsNotificationBadge = showsNotificationBadge + self.customColorHex = customColorHex self.accessibilityValue = accessibilityValue self.menuItems = menuItems } @@ -57,6 +65,7 @@ public final class BonsplitPaneChromeDescriptor { public let onSelect: (TabID) -> Void public let onClose: (TabID) -> Void public let onContextAction: (TabID, TabContextAction) -> Void + public let onApplyTabColor: (TabID, String) -> Void public let dragPasteboardData: (TabID) -> Data? public let onDragStateChanged: (TabID, Bool) -> Void public let validatedDropIndex: (Int) -> Int? @@ -77,6 +86,7 @@ public final class BonsplitPaneChromeDescriptor { onSelect: @escaping (TabID) -> Void, onClose: @escaping (TabID) -> Void, onContextAction: @escaping (TabID, TabContextAction) -> Void, + onApplyTabColor: @escaping (TabID, String) -> Void, dragPasteboardData: @escaping (TabID) -> Data?, onDragStateChanged: @escaping (TabID, Bool) -> Void, validatedDropIndex: @escaping (Int) -> Int?, @@ -96,6 +106,7 @@ public final class BonsplitPaneChromeDescriptor { self.onSelect = onSelect self.onClose = onClose self.onContextAction = onContextAction + self.onApplyTabColor = onApplyTabColor self.dragPasteboardData = dragPasteboardData self.onDragStateChanged = onDragStateChanged self.validatedDropIndex = validatedDropIndex From 41839cfbd27b4ce982fa2a7b64f214bc828acf56 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 29 Sep 2026 14:50:50 -0300 Subject: [PATCH 4/5] test: check tab color survives a session restore --- programaTests/WorkspaceUnitTests.swift | 28 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/programaTests/WorkspaceUnitTests.swift b/programaTests/WorkspaceUnitTests.swift index a48ac4865..e51f9093e 100644 --- a/programaTests/WorkspaceUnitTests.swift +++ b/programaTests/WorkspaceUnitTests.swift @@ -5364,22 +5364,30 @@ final class SessionPanelSnapshotTabColorTests: XCTestCase { ) } - func testCustomColorHexRoundTripsThroughEncodeDecode() throws { - let original = makeSnapshot(customColorHex: "#1565C0") - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(SessionPanelSnapshot.self, from: data) - XCTAssertEqual(decoded.customColorHex, "#1565C0") - XCTAssertEqual(decoded.id, original.id) + @MainActor + func testWorkspaceTabColorSurvivesSessionRestore() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + workspace.setPanelColor(panelId: panelId, hex: "#1565C0") + + let snapshot = workspace.sessionSnapshot(includeScrollback: false) + XCTAssertEqual(snapshot.panels.first(where: { $0.id == panelId })?.customColorHex, "#1565C0") + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + XCTAssertEqual(restored.panelColorHexes[restoredPanelId], "#1565C0") + let restoredTabId = try XCTUnwrap(restored.surfaceIdFromPanelId(restoredPanelId)) + XCTAssertEqual(restored.bonsplitController.tab(restoredTabId)?.customColorHex, "#1565C0") } func testMissingCustomColorHexKeyDecodesToNil() throws { // Simulates a pre-tab-color session snapshot written before this field existed. let legacySnapshot = makeSnapshot(customColorHex: nil) - var data = try JSONEncoder().encode(legacySnapshot) - var object = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let data = try JSONEncoder().encode(legacySnapshot) + let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) XCTAssertNil(object["customColorHex"], "legacy fixture must not already contain the key") - object.removeValue(forKey: "customColorHex") - data = try JSONSerialization.data(withJSONObject: object) let decoded = try JSONDecoder().decode(SessionPanelSnapshot.self, from: data) XCTAssertNil(decoded.customColorHex) From 1c3372358c2715b33d183d20e8c01a62131416a2 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 29 Sep 2026 15:25:39 -0300 Subject: [PATCH 5/5] fix: default the new tab color descriptor parameters so existing callers compile --- .../Bonsplit/Public/BonsplitPaneChromePortalBridge.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitPaneChromePortalBridge.swift b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitPaneChromePortalBridge.swift index 1a1ccb7b2..0ea0b404c 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitPaneChromePortalBridge.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Public/BonsplitPaneChromePortalBridge.swift @@ -35,7 +35,7 @@ public struct BonsplitPaneChromeTabDescriptor { isPinned: Bool, isDirty: Bool, showsNotificationBadge: Bool, - customColorHex: String?, + customColorHex: String? = nil, accessibilityValue: String, menuItems: [BonsplitPaneChromeMenuItem] ) { @@ -86,7 +86,7 @@ public final class BonsplitPaneChromeDescriptor { onSelect: @escaping (TabID) -> Void, onClose: @escaping (TabID) -> Void, onContextAction: @escaping (TabID, TabContextAction) -> Void, - onApplyTabColor: @escaping (TabID, String) -> Void, + onApplyTabColor: @escaping (TabID, String) -> Void = { _, _ in }, dragPasteboardData: @escaping (TabID) -> Data?, onDragStateChanged: @escaping (TabID, Bool) -> Void, validatedDropIndex: @escaping (Int) -> Int?,