Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ public struct AppCatalogSection: SettingCatalogSection {
userDefaultsKey: "appIconMode"
)

/// Optional macOS window title template. Empty preserves the default
/// active-workspace title behavior.
public let windowTitleTemplate = DefaultsKey<String>(
id: "app.windowTitleTemplate",
defaultValue: "",
userDefaultsKey: "windowTitleTemplate"
)

public let menuBarOnly = DefaultsKey<Bool>(
id: "app.menuBarOnly",
defaultValue: false,
Expand Down
55 changes: 55 additions & 0 deletions Sources/App/WindowTitleTemplate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation

struct WindowTitleTemplate: Equatable, Sendable {
static let userDefaultsKey = "windowTitleTemplate"
static let defaultRawValue = ""

var rawValue: String

static func configured(defaults: UserDefaults = .standard) -> WindowTitleTemplate? {
let rawValue = defaults.string(forKey: userDefaultsKey) ?? defaultRawValue
guard !rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
return WindowTitleTemplate(rawValue: rawValue)
}

func resolved(context: WindowTitleTemplateContext) -> String {
let replacements = Dictionary(uniqueKeysWithValues: replacements(context: context))
var resolved = ""
var index = rawValue.startIndex
while index < rawValue.endIndex {
guard rawValue[index] == "{",
let closeIndex = rawValue[index...].firstIndex(of: "}") else {
resolved.append(rawValue[index])
index = rawValue.index(after: index)
continue
}

let placeholderStart = rawValue.index(after: index)
let placeholder = String(rawValue[placeholderStart..<closeIndex])
if let replacement = replacements[placeholder] {
resolved += replacement
} else {
resolved += String(rawValue[index...closeIndex])
}
index = rawValue.index(after: closeIndex)
}
return resolved
}

private func replacements(context: WindowTitleTemplateContext) -> [(String, String)] {
[
("windowId", context.windowId.uuidString.lowercased()),
("windowToken", Self.windowToken(for: context.windowId)),
("activeWorkspace", context.activeWorkspace),
("activeDirectory", context.activeDirectory),
("defaultTitle", context.defaultTitle),
("appName", context.appName),
]
}

private static func windowToken(for windowId: UUID) -> String {
String(windowId.uuidString.prefix(8)).lowercased()
}
}
9 changes: 9 additions & 0 deletions Sources/App/WindowTitleTemplateContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

struct WindowTitleTemplateContext: Equatable, Sendable {
var defaultTitle: String
var activeWorkspace: String
var activeDirectory: String
var windowId: UUID
var appName: String
}
39 changes: 39 additions & 0 deletions Sources/AppDelegate+WindowIdentity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import AppKit
import Foundation

@MainActor
extension AppDelegate {
func mainWindow(for windowId: UUID) -> NSWindow? {
windowForMainWindowId(windowId)
}

func windowForMainWindowId(_ windowId: UUID) -> NSWindow? {
if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }),
let window = ctx.window {
return window
}
let expectedIdentifier = "cmux.main.\(windowId.uuidString)"
return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier })
}

func startupPrimaryWindowIdForInitialMainWindow() -> UUID? {
guard !didAttemptStartupSessionRestore else { return nil }
guard !didHandleExplicitOpenIntentAtStartup else { return nil }
return startupSessionSnapshot?.windows.first?.windowId
}

func availableWindowIdForNewMainWindow(preferredWindowId: UUID?) -> UUID? {
guard let preferredWindowId else { return nil }
guard !mainWindowContexts.values.contains(where: { $0.windowId == preferredWindowId }) else { return nil }
return preferredWindowId
}

func refreshWindowTitlesAcrossMainWindows() {
var seenManagers = Set<ObjectIdentifier>()
for context in mainWindowContexts.values {
let identifier = ObjectIdentifier(context.tabManager)
guard seenManagers.insert(identifier).inserted else { continue }
context.tabManager.refreshWindowTitle()
}
}
}
27 changes: 12 additions & 15 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1013,7 +1013,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
/// Tracks the cascade point for new windows, matching Ghostty's upstream algorithm.
/// Reset to `.zero` so the first window seeds the point from its own position.
private var lastCascadePoint = NSPoint.zero
private var startupSessionSnapshot: AppSessionSnapshot?
private(set) var startupSessionSnapshot: AppSessionSnapshot?
private var didPrepareStartupSessionSnapshot = false
var didAttemptStartupSessionRestore = false
private var isApplyingSessionRestore = false
Expand Down Expand Up @@ -4384,6 +4384,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
#endif
if let existing = mainWindowContexts[key] {
tabManager.window = window
tabManager.windowId = existing.windowId
existing.window = window
let resolvedFileExplorerState = fileExplorerState ?? existing.fileExplorerState
if let fileExplorerState {
Expand All @@ -4408,6 +4409,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
#endif
existing.tabManager.window = existingWindow
existing.tabManager.windowId = existing.windowId
existing.keyboardFocusCoordinator.update(
window: existingWindow,
tabManager: existing.tabManager,
Expand All @@ -4418,6 +4420,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return
}
tabManager.window = window
tabManager.windowId = windowId
existing.window = window
let resolvedFileExplorerState = fileExplorerState ?? existing.fileExplorerState
if let fileExplorerState {
Expand All @@ -4434,6 +4437,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
reindexMainWindowContextIfNeeded(existing, for: window)
} else {
tabManager.window = window
tabManager.windowId = windowId
mainWindowContexts[key] = MainWindowContext(
windowId: windowId,
tabManager: tabManager,
Expand Down Expand Up @@ -4486,6 +4490,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
cmuxConfigStore: CmuxConfigStore? = nil,
fileExplorerState: FileExplorerState? = nil
) -> UUID {
tabManager.windowId = windowId
mainWindowContexts[ObjectIdentifier(tabManager)] = MainWindowContext(
windowId: windowId,
tabManager: tabManager,
Expand Down Expand Up @@ -4945,10 +4950,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return moved
}

func mainWindow(for windowId: UUID) -> NSWindow? {
windowForMainWindowId(windowId)
}

@discardableResult
func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool {
guard let state = scriptableMainWindow(windowId: windowId),
Expand Down Expand Up @@ -5714,15 +5715,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: reassert)
}

func windowForMainWindowId(_ windowId: UUID) -> NSWindow? {
if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }),
let window = ctx.window {
return window
}
let expectedIdentifier = "cmux.main.\(windowId.uuidString)"
return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier })
}

private func resolvedWindow(for context: MainWindowContext) -> NSWindow? {
if let window = context.window {
return window
Expand Down Expand Up @@ -7004,6 +6996,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent

return createMainWindow(
initialTerminalInput: suppressWelcome ? "" : nil,
preferredWindowId: startupPrimaryWindowIdForInitialMainWindow(),
shouldActivate: shouldActivate
)
}
Expand Down Expand Up @@ -8095,19 +8088,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
initialWorkingDirectory: String? = nil,
initialTerminalInput: String? = nil,
sessionWindowSnapshot: SessionWindowSnapshot? = nil,
preferredWindowId: UUID? = nil,
shouldActivate: Bool = true,
sourceWindow preferredSourceWindow: NSWindow? = nil,
remapClosedPanelHistoryFromSessionSnapshot: Bool = true,
restoredSessionSnapshotHandler: (([[UUID: UUID]], TabManager) -> Void)? = nil
) -> UUID {
reserveInitialSocketPathIfNeeded()
let windowId = UUID()
let requestedWindowId = preferredWindowId ?? sessionWindowSnapshot?.windowId
let windowId = availableWindowIdForNewMainWindow(preferredWindowId: requestedWindowId) ?? UUID()
let tabManager = TabManager(
initialWorkspaceTitle: initialWorkspaceTitle,
initialWorkingDirectory: initialWorkingDirectory,
initialTerminalInput: initialTerminalInput,
autoWelcomeIfNeeded: initialTerminalInput == nil
)
tabManager.windowId = windowId
if let sessionWindowSnapshot {
let restoredPanelIdsByWorkspaceIndex = tabManager.restoreSessionSnapshot(
sessionWindowSnapshot.tabManager,
Expand Down Expand Up @@ -12372,6 +12368,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
guard seenStores.insert(identifier).inserted else { continue }
store.loadAll()
}
refreshWindowTitlesAcrossMainWindows()
#if DEBUG
cmuxDebugLog("cmuxConfig.reload source=\(source) stores=\(seenStores.count)")
#endif
Expand Down
1 change: 1 addition & 0 deletions Sources/CmuxSettingsJSONPathSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ extension CmuxSettingsFileStore {
"app.language",
"app.appearance",
"app.appIcon",
"app.windowTitleTemplate",
"app.menuBarOnly",
"app.newWorkspacePlacement",
"app.workspaceInheritWorkingDirectory",
Expand Down
1 change: 1 addition & 0 deletions Sources/KeyboardShortcutSettingsFileStore+Template.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ extension CmuxSettingsFileStore {
"language": LanguageSettings.defaultLanguage.rawValue,
"appearance": AppearanceSettings.defaultMode.rawValue,
"appIcon": AppIconSettings.defaultMode.rawValue,
"windowTitleTemplate": WindowTitleTemplate.defaultRawValue,
"menuBarOnly": MenuBarOnlySettings.defaultMenuBarOnly,
"newWorkspacePlacement": WorkspacePlacementSettings.defaultPlacement.rawValue,
"forkConversationDefaultDestination": AgentConversationForkDefaultSettings.defaultDestination.rawValue,
Expand Down
2 changes: 1 addition & 1 deletion Sources/KeyboardShortcutSettingsFileStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,6 @@ final class CmuxSettingsFileStore {

return snapshot
}

private func parseAppSection(
_ section: [String: Any],
sourcePath: String,
Expand Down Expand Up @@ -424,6 +423,7 @@ final class CmuxSettingsFileStore {
if let value = jsonBool(section["menuBarOnly"]) {
snapshot.managedUserDefaults[MenuBarOnlySettings.menuBarOnlyKey] = .bool(value)
}
if let raw = jsonString(section["windowTitleTemplate"]) { snapshot.managedUserDefaults[WindowTitleTemplate.userDefaultsKey] = .string(raw) } else if section.keys.contains("windowTitleTemplate") { logInvalid("app.windowTitleTemplate", sourcePath: sourcePath) }
if let raw = jsonString(section["newWorkspacePlacement"]) {
guard let placement = NewWorkspacePlacement(rawValue: raw) else {
logInvalid("app.newWorkspacePlacement", sourcePath: sourcePath)
Expand Down
72 changes: 72 additions & 0 deletions Sources/TabManager+WindowTitle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import AppKit
import Foundation

extension TabManager {
func refreshWindowTitle() {
updateWindowTitleForSelectedTab()
}

func workspaceCurrentDirectoryDidChange(workspaceId: UUID) {
guard workspaceId == selectedTabId else { return }
refreshWindowTitle()
}

func updateWindowTitleForSelectedTab() {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }) else {
updateWindowTitle(for: nil)
return
}
updateWindowTitle(for: tab)
}

func updateWindowTitle(for tab: Workspace?) {
let title = windowTitle(for: tab)
guard let targetWindow = window else { return }
targetWindow.title = title
}

/// The name to display for `tab` across window chrome — the custom title
/// bar, `NSWindow.title`, and the toolbar command label.
///
/// A workspace group's anchor is represented everywhere by the group itself
/// (the sidebar draws only the group header, never a separate anchor row,
/// per `SidebarWorkspaceRenderItem`), so for an anchor the single source of
/// truth for the displayed name is the group's `name`. The anchor's own
/// `title` is merely seeded equal to the group name at creation and would
/// otherwise drift when the group is renamed.
func resolvedWorkspaceDisplayTitle(for tab: Workspace) -> String {
if let group = workspaceGroups.first(where: { $0.anchorWorkspaceId == tab.id }) {
return group.name
}
return tab.title
}

private func windowTitle(for tab: Workspace?) -> String {
let defaultTitle = defaultWindowTitle(for: tab)
guard let windowId, let template = WindowTitleTemplate.configured() else { return defaultTitle }

let workspaceTitle = tab.map {
resolvedWorkspaceDisplayTitle(for: $0)
.trimmingCharacters(in: .whitespacesAndNewlines)
} ?? ""
let activeDirectory = tab?.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let resolvedTitle = template.resolved(context: WindowTitleTemplateContext(
defaultTitle: defaultTitle,
activeWorkspace: workspaceTitle.isEmpty ? defaultTitle : workspaceTitle,
activeDirectory: activeDirectory,
windowId: windowId,
appName: "cmux"
))
let trimmedResolvedTitle = resolvedTitle.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedResolvedTitle.isEmpty ? defaultTitle : trimmedResolvedTitle
}

private func defaultWindowTitle(for tab: Workspace?) -> String {
guard let tab else { return "cmux" }
let trimmedTitle = resolvedWorkspaceDisplayTitle(for: tab).trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedTitle.isEmpty { return trimmedTitle }
let trimmedDirectory = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedDirectory.isEmpty ? "cmux" : trimmedDirectory
}
}
Loading
Loading