Add cmux themes command
This commit is contained in:
parent
01a4797a03
commit
6584a01aef
5 changed files with 697 additions and 46 deletions
561
CLI/cmux.swift
561
CLI/cmux.swift
|
|
@ -934,6 +934,14 @@ struct CMUXCLI {
|
|||
return
|
||||
}
|
||||
|
||||
if command == "themes" {
|
||||
try runThemes(
|
||||
commandArgs: commandArgs,
|
||||
jsonOutput: jsonOutput
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if command == "claude-teams" {
|
||||
try runClaudeTeams(
|
||||
commandArgs: commandArgs,
|
||||
|
|
@ -4264,6 +4272,31 @@ struct CMUXCLI {
|
|||
Double check with the end user before sending anything. Review the message and attachments for secrets,
|
||||
private code, credentials, tokens, and other sensitive information first.
|
||||
"""
|
||||
case "themes":
|
||||
return """
|
||||
Usage: cmux themes
|
||||
cmux themes list
|
||||
cmux themes set <theme>
|
||||
cmux themes set --light <theme> [--dark <theme>]
|
||||
cmux themes set --dark <theme> [--light <theme>]
|
||||
cmux themes clear
|
||||
|
||||
List bundled and discovered Ghostty themes, show the current cmux light/dark
|
||||
theme defaults, and update the default theme override used by cmux.
|
||||
|
||||
Commands:
|
||||
list List available themes and mark the current light/dark defaults
|
||||
set <theme> Set the same theme for both light and dark appearance
|
||||
set --light <theme> Set the light appearance theme
|
||||
set --dark <theme> Set the dark appearance theme
|
||||
clear Remove the cmux theme override and fall back to other config
|
||||
|
||||
Examples:
|
||||
cmux themes
|
||||
cmux themes set "Catppuccin Mocha"
|
||||
cmux themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha"
|
||||
cmux themes clear
|
||||
"""
|
||||
case "claude-teams":
|
||||
return String(localized: "cli.claude-teams.usage", defaultValue: """
|
||||
Usage: cmux claude-teams [claude-args...]
|
||||
|
|
@ -5330,6 +5363,533 @@ struct CMUXCLI {
|
|||
return true
|
||||
}
|
||||
|
||||
private static let cmuxThemeOverrideBundleIdentifier = "com.cmuxterm.app"
|
||||
private static let cmuxThemesBlockStart = "# cmux themes start"
|
||||
private static let cmuxThemesBlockEnd = "# cmux themes end"
|
||||
private static let cmuxThemesReloadNotificationName = "com.cmuxterm.themes.reload-config"
|
||||
|
||||
private struct ThemeSelection {
|
||||
let rawValue: String?
|
||||
let light: String?
|
||||
let dark: String?
|
||||
let sourcePath: String?
|
||||
}
|
||||
|
||||
private struct ThemeReloadStatus {
|
||||
let requested: Bool
|
||||
let targetBundleIdentifier: String
|
||||
}
|
||||
|
||||
private func runThemes(commandArgs: [String], jsonOutput: Bool) throws {
|
||||
if commandArgs.isEmpty {
|
||||
try printThemesList(jsonOutput: jsonOutput)
|
||||
return
|
||||
}
|
||||
|
||||
guard let subcommand = commandArgs.first else {
|
||||
try printThemesList(jsonOutput: jsonOutput)
|
||||
return
|
||||
}
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
if commandArgs.count > 1 {
|
||||
throw CLIError(message: "themes list does not take any positional arguments")
|
||||
}
|
||||
try printThemesList(jsonOutput: jsonOutput)
|
||||
case "set":
|
||||
try runThemesSet(
|
||||
args: Array(commandArgs.dropFirst()),
|
||||
jsonOutput: jsonOutput
|
||||
)
|
||||
case "clear":
|
||||
if commandArgs.count > 1 {
|
||||
throw CLIError(message: "themes clear does not take any positional arguments")
|
||||
}
|
||||
try runThemesClear(jsonOutput: jsonOutput)
|
||||
default:
|
||||
if subcommand.hasPrefix("-") {
|
||||
throw CLIError(message: "Unknown themes subcommand '\(subcommand)'. Run 'cmux themes --help'.")
|
||||
}
|
||||
|
||||
try runThemesSet(
|
||||
args: commandArgs,
|
||||
jsonOutput: jsonOutput
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func printThemesList(jsonOutput: Bool) throws {
|
||||
let themes = availableThemeNames()
|
||||
let current = currentThemeSelection()
|
||||
let configPath = try cmuxThemeOverrideConfigURL().path
|
||||
|
||||
if jsonOutput {
|
||||
let currentPayload: [String: Any] = [
|
||||
"raw_value": current.rawValue ?? NSNull(),
|
||||
"light": current.light ?? NSNull(),
|
||||
"dark": current.dark ?? NSNull(),
|
||||
"source_path": current.sourcePath ?? NSNull()
|
||||
]
|
||||
let payload: [String: Any] = [
|
||||
"themes": themes.map { theme in
|
||||
[
|
||||
"name": theme,
|
||||
"current_light": current.light?.caseInsensitiveCompare(theme) == .orderedSame,
|
||||
"current_dark": current.dark?.caseInsensitiveCompare(theme) == .orderedSame
|
||||
]
|
||||
},
|
||||
"current": currentPayload,
|
||||
"config_path": configPath
|
||||
]
|
||||
print(jsonString(payload))
|
||||
return
|
||||
}
|
||||
|
||||
print("Current light: \(current.light ?? "inherit")")
|
||||
print("Current dark: \(current.dark ?? "inherit")")
|
||||
print("Config: \(configPath)")
|
||||
if let sourcePath = current.sourcePath {
|
||||
print("Source: \(sourcePath)")
|
||||
}
|
||||
print("")
|
||||
|
||||
guard !themes.isEmpty else {
|
||||
print("No themes found.")
|
||||
return
|
||||
}
|
||||
|
||||
for theme in themes {
|
||||
var badges: [String] = []
|
||||
if current.light?.caseInsensitiveCompare(theme) == .orderedSame {
|
||||
badges.append("light")
|
||||
}
|
||||
if current.dark?.caseInsensitiveCompare(theme) == .orderedSame {
|
||||
badges.append("dark")
|
||||
}
|
||||
let badgeText = badges.isEmpty ? "" : " [\(badges.joined(separator: ", "))]"
|
||||
print("\(theme)\(badgeText)")
|
||||
}
|
||||
}
|
||||
|
||||
private func runThemesSet(args: [String], jsonOutput: Bool) throws {
|
||||
let (lightOpt, rem0) = parseOption(args, name: "--light")
|
||||
let (darkOpt, rem1) = parseOption(rem0, name: "--dark")
|
||||
|
||||
if let unknown = rem1.first(where: { $0.hasPrefix("--") }) {
|
||||
throw CLIError(message: "themes set: unknown flag '\(unknown)'. Known flags: --light <theme>, --dark <theme>")
|
||||
}
|
||||
|
||||
let availableThemes = availableThemeNames()
|
||||
let current = currentThemeSelection()
|
||||
|
||||
let lightTheme: String?
|
||||
let darkTheme: String?
|
||||
|
||||
if lightOpt == nil && darkOpt == nil {
|
||||
let joinedTheme = rem1.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !joinedTheme.isEmpty else {
|
||||
throw CLIError(message: "themes set requires a theme name or --light/--dark flags")
|
||||
}
|
||||
let resolved = try validatedThemeName(joinedTheme, availableThemes: availableThemes)
|
||||
lightTheme = resolved
|
||||
darkTheme = resolved
|
||||
} else {
|
||||
if !rem1.isEmpty {
|
||||
throw CLIError(message: "themes set: unexpected argument '\(rem1.joined(separator: " "))'")
|
||||
}
|
||||
lightTheme = try lightOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.light
|
||||
darkTheme = try darkOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.dark
|
||||
}
|
||||
|
||||
guard let rawThemeValue = encodedThemeValue(light: lightTheme, dark: darkTheme) else {
|
||||
throw CLIError(message: "themes set requires at least one theme")
|
||||
}
|
||||
|
||||
let configURL = try writeManagedThemeOverride(rawThemeValue: rawThemeValue)
|
||||
let reloadStatus = reloadThemesIfPossible()
|
||||
|
||||
if jsonOutput {
|
||||
let payload: [String: Any] = [
|
||||
"ok": true,
|
||||
"light": lightTheme ?? NSNull(),
|
||||
"dark": darkTheme ?? NSNull(),
|
||||
"raw_value": rawThemeValue,
|
||||
"config_path": configURL.path,
|
||||
"reload_requested": reloadStatus.requested,
|
||||
"reload_target_bundle_id": reloadStatus.targetBundleIdentifier
|
||||
]
|
||||
print(jsonString(payload))
|
||||
return
|
||||
}
|
||||
|
||||
print(
|
||||
"OK light=\(lightTheme ?? "-") dark=\(darkTheme ?? "-") config=\(configURL.path) reload=requested"
|
||||
)
|
||||
}
|
||||
|
||||
private func runThemesClear(jsonOutput: Bool) throws {
|
||||
let configURL = try clearManagedThemeOverride()
|
||||
let reloadStatus = reloadThemesIfPossible()
|
||||
|
||||
if jsonOutput {
|
||||
let payload: [String: Any] = [
|
||||
"ok": true,
|
||||
"cleared": true,
|
||||
"config_path": configURL.path,
|
||||
"reload_requested": reloadStatus.requested,
|
||||
"reload_target_bundle_id": reloadStatus.targetBundleIdentifier
|
||||
]
|
||||
print(jsonString(payload))
|
||||
return
|
||||
}
|
||||
|
||||
print("OK cleared config=\(configURL.path) reload=requested")
|
||||
}
|
||||
|
||||
private func currentThemeSelection() -> ThemeSelection {
|
||||
var rawValue: String?
|
||||
var sourcePath: String?
|
||||
|
||||
for url in themeConfigSearchURLs() {
|
||||
guard let contents = try? String(contentsOf: url, encoding: .utf8),
|
||||
let nextValue = lastThemeDirective(in: contents) else {
|
||||
continue
|
||||
}
|
||||
rawValue = nextValue
|
||||
sourcePath = url.path
|
||||
}
|
||||
|
||||
return parseThemeSelection(rawValue: rawValue, sourcePath: sourcePath)
|
||||
}
|
||||
|
||||
private func parseThemeSelection(rawValue: String?, sourcePath: String?) -> ThemeSelection {
|
||||
guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !rawValue.isEmpty else {
|
||||
return ThemeSelection(rawValue: nil, light: nil, dark: nil, sourcePath: sourcePath)
|
||||
}
|
||||
|
||||
var fallbackTheme: String?
|
||||
var lightTheme: String?
|
||||
var darkTheme: String?
|
||||
|
||||
for token in rawValue.split(separator: ",").map(String.init) {
|
||||
let entry = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !entry.isEmpty else { continue }
|
||||
|
||||
let parts = entry.split(separator: ":", maxSplits: 1).map(String.init)
|
||||
if parts.count != 2 {
|
||||
if fallbackTheme == nil {
|
||||
fallbackTheme = entry
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { continue }
|
||||
|
||||
switch key {
|
||||
case "light":
|
||||
if lightTheme == nil {
|
||||
lightTheme = value
|
||||
}
|
||||
case "dark":
|
||||
if darkTheme == nil {
|
||||
darkTheme = value
|
||||
}
|
||||
default:
|
||||
if fallbackTheme == nil {
|
||||
fallbackTheme = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedLight = lightTheme ?? fallbackTheme ?? darkTheme
|
||||
let resolvedDark = darkTheme ?? fallbackTheme ?? lightTheme
|
||||
return ThemeSelection(rawValue: rawValue, light: resolvedLight, dark: resolvedDark, sourcePath: sourcePath)
|
||||
}
|
||||
|
||||
private func encodedThemeValue(light: String?, dark: String?) -> String? {
|
||||
let normalizedLight = light?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedDark = dark?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
switch (normalizedLight?.isEmpty == false ? normalizedLight : nil, normalizedDark?.isEmpty == false ? normalizedDark : nil) {
|
||||
case let (lightTheme?, darkTheme?):
|
||||
return "light:\(lightTheme),dark:\(darkTheme)"
|
||||
case let (lightTheme?, nil):
|
||||
return "light:\(lightTheme)"
|
||||
case let (nil, darkTheme?):
|
||||
return "dark:\(darkTheme)"
|
||||
case (nil, nil):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func availableThemeNames() -> [String] {
|
||||
let fileManager = FileManager.default
|
||||
var seen: Set<String> = []
|
||||
var themes: [String] = []
|
||||
|
||||
for directoryURL in themeDirectoryURLs() {
|
||||
guard let entries = try? fileManager.contentsOfDirectory(
|
||||
at: directoryURL,
|
||||
includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
for entry in entries {
|
||||
let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey])
|
||||
guard values?.isDirectory != true else { continue }
|
||||
guard values?.isRegularFile == true || values?.isRegularFile == nil else { continue }
|
||||
let name = entry.lastPathComponent
|
||||
let folded = name.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||||
if seen.insert(folded).inserted {
|
||||
themes.append(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return themes.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
private func themeDirectoryURLs() -> [URL] {
|
||||
let fileManager = FileManager.default
|
||||
let processEnv = ProcessInfo.processInfo.environment
|
||||
var urls: [URL] = []
|
||||
var seen: Set<String> = []
|
||||
|
||||
func appendIfExisting(_ url: URL?) {
|
||||
guard let url else { return }
|
||||
let standardized = url.standardizedFileURL
|
||||
guard fileManager.fileExists(atPath: standardized.path) else { return }
|
||||
if seen.insert(standardized.path).inserted {
|
||||
urls.append(standardized)
|
||||
}
|
||||
}
|
||||
|
||||
if let resourcesDir = processEnv["GHOSTTY_RESOURCES_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!resourcesDir.isEmpty {
|
||||
appendIfExisting(URL(fileURLWithPath: resourcesDir, isDirectory: true).appendingPathComponent("themes", isDirectory: true))
|
||||
}
|
||||
|
||||
appendIfExisting(
|
||||
Bundle.main.resourceURL?
|
||||
.appendingPathComponent("ghostty", isDirectory: true)
|
||||
.appendingPathComponent("themes", isDirectory: true)
|
||||
)
|
||||
|
||||
if let executableURL = resolvedExecutableURL() {
|
||||
var current = executableURL.deletingLastPathComponent().standardizedFileURL
|
||||
while true {
|
||||
if current.lastPathComponent == "Resources" {
|
||||
appendIfExisting(
|
||||
current
|
||||
.appendingPathComponent("ghostty", isDirectory: true)
|
||||
.appendingPathComponent("themes", isDirectory: true)
|
||||
)
|
||||
}
|
||||
if current.lastPathComponent == "Contents" {
|
||||
appendIfExisting(
|
||||
current
|
||||
.appendingPathComponent("Resources", isDirectory: true)
|
||||
.appendingPathComponent("ghostty", isDirectory: true)
|
||||
.appendingPathComponent("themes", isDirectory: true)
|
||||
)
|
||||
}
|
||||
|
||||
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false)
|
||||
let repoThemes = current.appendingPathComponent("Resources/ghostty/themes", isDirectory: true)
|
||||
if fileManager.fileExists(atPath: projectMarker.path),
|
||||
fileManager.fileExists(atPath: repoThemes.path) {
|
||||
appendIfExisting(repoThemes)
|
||||
break
|
||||
}
|
||||
|
||||
guard let parent = parentSearchURL(for: current) else { break }
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
if let xdgDataDirs = processEnv["XDG_DATA_DIRS"] {
|
||||
for dataDir in xdgDataDirs.split(separator: ":").map(String.init).filter({ !$0.isEmpty }) {
|
||||
appendIfExisting(
|
||||
URL(fileURLWithPath: NSString(string: dataDir).expandingTildeInPath, isDirectory: true)
|
||||
.appendingPathComponent("ghostty/themes", isDirectory: true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
appendIfExisting(URL(fileURLWithPath: "/Applications/Ghostty.app/Contents/Resources/ghostty/themes", isDirectory: true))
|
||||
appendIfExisting(URL(fileURLWithPath: NSString(string: "~/.config/ghostty/themes").expandingTildeInPath, isDirectory: true))
|
||||
appendIfExisting(
|
||||
URL(
|
||||
fileURLWithPath: NSString(
|
||||
string: "~/Library/Application Support/com.mitchellh.ghostty/themes"
|
||||
).expandingTildeInPath,
|
||||
isDirectory: true
|
||||
)
|
||||
)
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
private func validatedThemeName(_ rawValue: String, availableThemes: [String]) throws -> String {
|
||||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
throw CLIError(message: "Theme name cannot be empty")
|
||||
}
|
||||
if let matched = availableThemes.first(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) {
|
||||
return matched
|
||||
}
|
||||
if availableThemes.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
throw CLIError(message: "Unknown theme '\(trimmed)'. Run 'cmux themes' to list available themes.")
|
||||
}
|
||||
|
||||
private func themeConfigSearchURLs() -> [URL] {
|
||||
let rawPaths = [
|
||||
"~/.config/ghostty/config",
|
||||
"~/.config/ghostty/config.ghostty",
|
||||
"~/Library/Application Support/com.mitchellh.ghostty/config",
|
||||
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
|
||||
"~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config",
|
||||
"~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config.ghostty",
|
||||
]
|
||||
|
||||
return rawPaths.map {
|
||||
URL(fileURLWithPath: NSString(string: $0).expandingTildeInPath, isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func lastThemeDirective(in contents: String) -> String? {
|
||||
var lastValue: String?
|
||||
|
||||
for line in contents.components(separatedBy: .newlines) {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||
continue
|
||||
}
|
||||
|
||||
let parts = trimmed.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard parts.count == 2 else { continue }
|
||||
guard parts[0].trimmingCharacters(in: .whitespacesAndNewlines) == "theme" else { continue }
|
||||
|
||||
let value = parts[1]
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
if !value.isEmpty {
|
||||
lastValue = value
|
||||
}
|
||||
}
|
||||
|
||||
return lastValue
|
||||
}
|
||||
|
||||
private func cmuxThemeOverrideConfigURL() throws -> URL {
|
||||
guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||||
throw CLIError(message: "Unable to resolve Application Support directory")
|
||||
}
|
||||
return appSupport
|
||||
.appendingPathComponent(Self.cmuxThemeOverrideBundleIdentifier, isDirectory: true)
|
||||
.appendingPathComponent("config.ghostty", isDirectory: false)
|
||||
}
|
||||
|
||||
private func writeManagedThemeOverride(rawThemeValue: String) throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let configURL = try cmuxThemeOverrideConfigURL()
|
||||
let directoryURL = configURL.deletingLastPathComponent()
|
||||
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
let existingContents = (try? String(contentsOf: configURL, encoding: .utf8)) ?? ""
|
||||
let strippedContents = removingManagedThemeOverride(from: existingContents)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let block = """
|
||||
\(Self.cmuxThemesBlockStart)
|
||||
theme = \(rawThemeValue)
|
||||
\(Self.cmuxThemesBlockEnd)
|
||||
"""
|
||||
|
||||
let nextContents = strippedContents.isEmpty ? "\(block)\n" : "\(strippedContents)\n\n\(block)\n"
|
||||
try nextContents.write(to: configURL, atomically: true, encoding: .utf8)
|
||||
return configURL
|
||||
}
|
||||
|
||||
private func clearManagedThemeOverride() throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let configURL = try cmuxThemeOverrideConfigURL()
|
||||
guard let existingContents = try? String(contentsOf: configURL, encoding: .utf8) else {
|
||||
return configURL
|
||||
}
|
||||
|
||||
let strippedContents = removingManagedThemeOverride(from: existingContents)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if strippedContents.isEmpty {
|
||||
try? fileManager.removeItem(at: configURL)
|
||||
} else {
|
||||
try strippedContents.appending("\n").write(to: configURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
return configURL
|
||||
}
|
||||
|
||||
private func removingManagedThemeOverride(from contents: String) -> String {
|
||||
let pattern = #"(?ms)\n?# cmux themes start\n.*?\n# cmux themes end\n?"#
|
||||
guard let range = contents.range(of: pattern, options: [.regularExpression]) else {
|
||||
return contents
|
||||
}
|
||||
return contents.replacingCharacters(in: range, with: "")
|
||||
}
|
||||
|
||||
private func reloadThemesIfPossible() -> ThemeReloadStatus {
|
||||
let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier
|
||||
DistributedNotificationCenter.default().post(
|
||||
name: Notification.Name(Self.cmuxThemesReloadNotificationName),
|
||||
object: bundleIdentifier,
|
||||
userInfo: nil
|
||||
)
|
||||
return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier)
|
||||
}
|
||||
|
||||
private func currentCmuxAppBundleIdentifier() -> String? {
|
||||
if let bundleIdentifier = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleIdentifier.isEmpty {
|
||||
return bundleIdentifier
|
||||
}
|
||||
|
||||
guard let executableURL = resolvedExecutableURL() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var current = executableURL.deletingLastPathComponent().standardizedFileURL
|
||||
while true {
|
||||
if current.pathExtension == "app",
|
||||
let bundleIdentifier = Bundle(url: current)?.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleIdentifier.isEmpty {
|
||||
return bundleIdentifier
|
||||
}
|
||||
|
||||
if current.lastPathComponent == "Contents" {
|
||||
let appURL = current.deletingLastPathComponent().standardizedFileURL
|
||||
if appURL.pathExtension == "app",
|
||||
let bundleIdentifier = Bundle(url: appURL)?.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleIdentifier.isEmpty {
|
||||
return bundleIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
guard let parent = parentSearchURL(for: current) else {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Escape and quote a string for safe embedding in a v1 socket command.
|
||||
/// The socket tokenizer treats `\` and `"` as special inside quoted strings,
|
||||
/// so both must be escaped before wrapping in double quotes. Newlines and
|
||||
|
|
@ -8398,6 +8958,7 @@ struct CMUXCLI {
|
|||
welcome
|
||||
shortcuts
|
||||
feedback [--email <email> --body <text> [--image <path> ...]]
|
||||
themes [list|set|clear]
|
||||
claude-teams [claude-args...]
|
||||
ping
|
||||
capabilities
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import Combine
|
|||
import ObjectiveC.runtime
|
||||
import Darwin
|
||||
|
||||
private enum CmuxThemeNotifications {
|
||||
static let reloadConfig = Notification.Name("com.cmuxterm.themes.reload-config")
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
enum CmuxTypingTiming {
|
||||
static let isEnabled: Bool = {
|
||||
|
|
@ -2118,6 +2122,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
let isRunningUnderXCTest = isRunningUnderXCTest(env)
|
||||
let telemetryEnabled = TelemetrySettings.enabledForCurrentLaunch
|
||||
|
||||
DistributedNotificationCenter.default().addObserver(
|
||||
self,
|
||||
selector: #selector(handleThemesReloadNotification(_:)),
|
||||
name: CmuxThemeNotifications.reloadConfig,
|
||||
object: Bundle.main.bundleIdentifier,
|
||||
suspensionBehavior: .deliverImmediately
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
// UI tests run on a shared VM user profile, so persisted shortcuts can drift and make
|
||||
// key-equivalent routing flaky. Force defaults for deterministic tests.
|
||||
|
|
@ -10766,6 +10778,14 @@ private extension NSApplication {
|
|||
}
|
||||
}
|
||||
|
||||
private extension AppDelegate {
|
||||
@objc func handleThemesReloadNotification(_ notification: Notification) {
|
||||
DispatchQueue.main.async {
|
||||
GhosttyApp.shared.reloadConfiguration(source: "distributed.cmux.themes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSWindow {
|
||||
@objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool {
|
||||
if cmuxIsWindowFirstResponderBypassActive() {
|
||||
|
|
@ -11357,4 +11377,5 @@ private extension NSWindow {
|
|||
}
|
||||
return hitWebView === webView
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ struct GhosttyConfig {
|
|||
case dark
|
||||
}
|
||||
|
||||
private static let cmuxReleaseBundleIdentifier = "com.cmuxterm.app"
|
||||
private static let loadCacheLock = NSLock()
|
||||
private static var cachedConfigsByColorScheme: [ColorSchemePreference: GhosttyConfig] = [:]
|
||||
|
||||
|
|
@ -87,6 +88,52 @@ struct GhosttyConfig {
|
|||
loadCacheLock.unlock()
|
||||
}
|
||||
|
||||
private static func cmuxConfigPaths(
|
||||
fileManager: FileManager = .default,
|
||||
currentBundleIdentifier: String? = Bundle.main.bundleIdentifier
|
||||
) -> [String] {
|
||||
guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||||
return []
|
||||
}
|
||||
|
||||
func paths(for bundleIdentifier: String) -> [String] {
|
||||
let directory = appSupport.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||
return [
|
||||
directory.appendingPathComponent("config", isDirectory: false).path,
|
||||
directory.appendingPathComponent("config.ghostty", isDirectory: false).path,
|
||||
]
|
||||
}
|
||||
|
||||
func hasConfig(_ paths: [String]) -> Bool {
|
||||
paths.contains { path in
|
||||
guard let attributes = try? fileManager.attributesOfItem(atPath: path),
|
||||
let type = attributes[.type] as? FileAttributeType,
|
||||
type == .typeRegular,
|
||||
let size = attributes[.size] as? NSNumber else {
|
||||
return false
|
||||
}
|
||||
return size.intValue > 0
|
||||
}
|
||||
}
|
||||
|
||||
let releasePaths = paths(for: cmuxReleaseBundleIdentifier)
|
||||
guard let currentBundleIdentifier, !currentBundleIdentifier.isEmpty else {
|
||||
return releasePaths
|
||||
}
|
||||
if currentBundleIdentifier == cmuxReleaseBundleIdentifier {
|
||||
return releasePaths
|
||||
}
|
||||
|
||||
let currentPaths = paths(for: currentBundleIdentifier)
|
||||
if hasConfig(currentPaths) {
|
||||
return currentPaths
|
||||
}
|
||||
if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) {
|
||||
return releasePaths
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func loadFromDisk(preferredColorScheme: ColorSchemePreference) -> GhosttyConfig {
|
||||
var config = GhosttyConfig()
|
||||
|
||||
|
|
@ -96,7 +143,7 @@ struct GhosttyConfig {
|
|||
"~/.config/ghostty/config.ghostty",
|
||||
"~/Library/Application Support/com.mitchellh.ghostty/config",
|
||||
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
|
||||
].map { NSString(string: $0).expandingTildeInPath }
|
||||
].map { NSString(string: $0).expandingTildeInPath } + cmuxConfigPaths()
|
||||
|
||||
for path in configPaths {
|
||||
if let contents = readConfigFile(at: path) {
|
||||
|
|
|
|||
|
|
@ -1026,9 +1026,9 @@ class GhosttyApp {
|
|||
|
||||
private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) {
|
||||
ghostty_config_load_default_files(config)
|
||||
loadReleaseAppSupportGhosttyConfigIfNeeded(config)
|
||||
loadLegacyGhosttyConfigIfNeeded(config)
|
||||
ghostty_config_load_recursive_files(config)
|
||||
loadCmuxAppSupportGhosttyConfigIfNeeded(config)
|
||||
loadCJKFontFallbackIfNeeded(config)
|
||||
ghostty_config_finalize(config)
|
||||
}
|
||||
|
|
@ -1229,20 +1229,44 @@ class GhosttyApp {
|
|||
return true
|
||||
}
|
||||
|
||||
static func shouldLoadReleaseAppSupportGhosttyConfig(
|
||||
static func cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: String?,
|
||||
currentConfigFileSize: Int?,
|
||||
currentLegacyConfigFileSize: Int?,
|
||||
releaseConfigFileSize: Int?,
|
||||
releaseLegacyConfigFileSize: Int?
|
||||
) -> Bool {
|
||||
guard SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) else { return false }
|
||||
appSupportDirectory: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> [URL] {
|
||||
guard let currentBundleIdentifier, !currentBundleIdentifier.isEmpty else { return [] }
|
||||
|
||||
let hasCurrentAppSupportConfig = (currentConfigFileSize ?? 0) > 0 || (currentLegacyConfigFileSize ?? 0) > 0
|
||||
guard !hasCurrentAppSupportConfig else { return false }
|
||||
func configURLs(for bundleIdentifier: String) -> [URL] {
|
||||
let directory = appSupportDirectory.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||
return [
|
||||
directory.appendingPathComponent("config", isDirectory: false),
|
||||
directory.appendingPathComponent("config.ghostty", isDirectory: false)
|
||||
]
|
||||
}
|
||||
|
||||
let hasReleaseAppSupportConfig = (releaseConfigFileSize ?? 0) > 0 || (releaseLegacyConfigFileSize ?? 0) > 0
|
||||
return hasReleaseAppSupportConfig
|
||||
func hasConfig(_ urls: [URL]) -> Bool {
|
||||
urls.contains { url in
|
||||
guard let attrs = try? fileManager.attributesOfItem(atPath: url.path),
|
||||
let type = attrs[.type] as? FileAttributeType,
|
||||
type == .typeRegular,
|
||||
let size = attrs[.size] as? NSNumber else {
|
||||
return false
|
||||
}
|
||||
return size.intValue > 0
|
||||
}
|
||||
}
|
||||
|
||||
let currentURLs = configURLs(for: currentBundleIdentifier)
|
||||
if hasConfig(currentURLs) {
|
||||
return currentURLs
|
||||
}
|
||||
if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) {
|
||||
let releaseURLs = configURLs(for: releaseBundleIdentifier)
|
||||
if hasConfig(releaseURLs) {
|
||||
return releaseURLs
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
static func shouldApplyDefaultBackgroundUpdate(
|
||||
|
|
@ -1282,52 +1306,28 @@ class GhosttyApp {
|
|||
return true
|
||||
}
|
||||
|
||||
private func loadReleaseAppSupportGhosttyConfigIfNeeded(_ config: ghostty_config_t) {
|
||||
private func loadCmuxAppSupportGhosttyConfigIfNeeded(_ config: ghostty_config_t) {
|
||||
#if os(macOS)
|
||||
let fm = FileManager.default
|
||||
guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
|
||||
guard let currentBundleIdentifier = Bundle.main.bundleIdentifier,
|
||||
!currentBundleIdentifier.isEmpty else { return }
|
||||
|
||||
let currentAppSupportDir = appSupport.appendingPathComponent(currentBundleIdentifier, isDirectory: true)
|
||||
let releaseAppSupportDir = appSupport.appendingPathComponent(Self.releaseBundleIdentifier, isDirectory: true)
|
||||
let currentConfig = currentAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false)
|
||||
let currentLegacyConfig = currentAppSupportDir.appendingPathComponent("config", isDirectory: false)
|
||||
let releaseConfig = releaseAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false)
|
||||
let releaseLegacyConfig = releaseAppSupportDir.appendingPathComponent("config", isDirectory: false)
|
||||
|
||||
func fileSize(_ url: URL) -> Int? {
|
||||
guard let attrs = try? fm.attributesOfItem(atPath: url.path),
|
||||
let size = attrs[.size] as? NSNumber else { return nil }
|
||||
return size.intValue
|
||||
}
|
||||
|
||||
let releaseConfigSize = fileSize(releaseConfig)
|
||||
let releaseLegacyConfigSize = fileSize(releaseLegacyConfig)
|
||||
|
||||
guard Self.shouldLoadReleaseAppSupportGhosttyConfig(
|
||||
let urls = Self.cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: currentBundleIdentifier,
|
||||
currentConfigFileSize: fileSize(currentConfig),
|
||||
currentLegacyConfigFileSize: fileSize(currentLegacyConfig),
|
||||
releaseConfigFileSize: releaseConfigSize,
|
||||
releaseLegacyConfigFileSize: releaseLegacyConfigSize
|
||||
) else { return }
|
||||
appSupportDirectory: appSupport,
|
||||
fileManager: fm
|
||||
)
|
||||
guard !urls.isEmpty else { return }
|
||||
|
||||
if let releaseLegacyConfigSize, releaseLegacyConfigSize > 0 {
|
||||
releaseLegacyConfig.path.withCString { path in
|
||||
ghostty_config_load_file(config, path)
|
||||
}
|
||||
}
|
||||
|
||||
if let releaseConfigSize, releaseConfigSize > 0 {
|
||||
releaseConfig.path.withCString { path in
|
||||
for url in urls {
|
||||
url.path.withCString { path in
|
||||
ghostty_config_load_file(config, path)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
Self.initLog(
|
||||
"loaded release app support ghostty config fallback from: \(releaseAppSupportDir.path)"
|
||||
"loaded cmux app support ghostty config from: \(urls.map(\.path).joined(separator: ", "))"
|
||||
)
|
||||
#endif
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1619,6 +1619,9 @@ class TerminalController {
|
|||
case "close_surface":
|
||||
return closeSurface(args)
|
||||
|
||||
case "reload_config":
|
||||
return reloadConfig(args)
|
||||
|
||||
case "refresh_surfaces":
|
||||
return refreshSurfaces()
|
||||
|
||||
|
|
@ -9661,6 +9664,7 @@ class TerminalController {
|
|||
focus_pane <pane-id|index> - Focus a pane
|
||||
focus_surface_by_panel <panel_id> - Focus surface by panel ID
|
||||
close_surface [id|idx] - Close surface (collapse split)
|
||||
reload_config [soft] - Reload Ghostty config and refresh terminals
|
||||
refresh_surfaces - Force refresh all terminals
|
||||
surface_health [workspace] - Check view health of all surfaces
|
||||
|
||||
|
|
@ -13820,6 +13824,24 @@ class TerminalController {
|
|||
return result
|
||||
}
|
||||
|
||||
private func reloadConfig(_ args: String) -> String {
|
||||
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let soft: Bool
|
||||
switch trimmed {
|
||||
case "", "full":
|
||||
soft = false
|
||||
case "soft":
|
||||
soft = true
|
||||
default:
|
||||
return "ERROR: Usage: reload_config [soft]"
|
||||
}
|
||||
|
||||
v2MainSync {
|
||||
GhosttyApp.shared.reloadConfiguration(soft: soft, source: "socket.reload_config")
|
||||
}
|
||||
return soft ? "OK Reloaded config (soft)" : "OK Reloaded config"
|
||||
}
|
||||
|
||||
private func refreshSurfaces() -> String {
|
||||
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue