Merge pull request #1334 from manaflow-ai/feat-cmux-themes-command
Add cmux themes command
This commit is contained in:
commit
255ec0016c
11 changed files with 960 additions and 64 deletions
754
CLI/cmux.swift
754
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,35 @@ 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
|
||||
|
||||
When run in a TTY, `cmux themes` opens an interactive theme picker with
|
||||
live app preview. Use `cmux themes list` for a plain listing.
|
||||
|
||||
The picker previews the selected theme across the running cmux app and
|
||||
lets you apply it to the light theme, dark theme, or both defaults.
|
||||
|
||||
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 list
|
||||
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 +5367,722 @@ 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 enum ThemePickerTargetMode: String {
|
||||
case both
|
||||
case light
|
||||
case dark
|
||||
}
|
||||
|
||||
private func shouldUseInteractiveThemePicker(jsonOutput: Bool) -> Bool {
|
||||
guard !jsonOutput else { return false }
|
||||
return isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1
|
||||
}
|
||||
|
||||
private func runInteractiveThemes() throws {
|
||||
guard let helperURL = bundledHelperURL(named: "ghostty") else {
|
||||
throw CLIError(message: "Bundled Ghostty theme picker helper not found")
|
||||
}
|
||||
|
||||
let selection = currentThemeSelection()
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["CMUX_THEME_PICKER_CONFIG"] = try cmuxThemeOverrideConfigURL().path
|
||||
environment["CMUX_THEME_PICKER_BUNDLE_ID"] = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier
|
||||
environment["CMUX_THEME_PICKER_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue
|
||||
environment["CMUX_THEME_PICKER_COLOR_SCHEME"] = defaultAppearancePrefersDarkThemes() ? "dark" : "light"
|
||||
if let light = selection.light {
|
||||
environment["CMUX_THEME_PICKER_INITIAL_LIGHT"] = light
|
||||
}
|
||||
if let dark = selection.dark {
|
||||
environment["CMUX_THEME_PICKER_INITIAL_DARK"] = dark
|
||||
}
|
||||
if let resourcesURL = bundledGhosttyResourcesURL() {
|
||||
environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path
|
||||
}
|
||||
|
||||
try execInteractiveHelper(
|
||||
executablePath: helperURL.path,
|
||||
arguments: ["+list-themes"],
|
||||
environment: environment
|
||||
)
|
||||
}
|
||||
|
||||
private func defaultThemePickerTargetMode(current: ThemeSelection) -> ThemePickerTargetMode {
|
||||
if let light = current.light,
|
||||
let dark = current.dark,
|
||||
light.caseInsensitiveCompare(dark) == .orderedSame {
|
||||
return .both
|
||||
}
|
||||
return defaultAppearancePrefersDarkThemes() ? .dark : .light
|
||||
}
|
||||
|
||||
private func defaultAppearancePrefersDarkThemes() -> Bool {
|
||||
let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
|
||||
let interfaceStyle = (globalDefaults?["AppleInterfaceStyle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return interfaceStyle?.caseInsensitiveCompare("Dark") == .orderedSame
|
||||
}
|
||||
|
||||
private func bundledHelperURL(named helperName: String) -> URL? {
|
||||
let fileManager = FileManager.default
|
||||
guard let executableURL = resolvedExecutableURL() else { return nil }
|
||||
|
||||
var candidates: [URL] = [
|
||||
executableURL.deletingLastPathComponent().appendingPathComponent(helperName, isDirectory: false)
|
||||
]
|
||||
|
||||
var current = executableURL.deletingLastPathComponent().standardizedFileURL
|
||||
while true {
|
||||
if current.lastPathComponent == "Contents" {
|
||||
candidates.append(
|
||||
current
|
||||
.appendingPathComponent("Resources", isDirectory: true)
|
||||
.appendingPathComponent("bin", isDirectory: true)
|
||||
.appendingPathComponent(helperName, isDirectory: false)
|
||||
)
|
||||
}
|
||||
|
||||
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false)
|
||||
let repoHelper = current
|
||||
.appendingPathComponent("ghostty", isDirectory: true)
|
||||
.appendingPathComponent("zig-out", isDirectory: true)
|
||||
.appendingPathComponent("bin", isDirectory: true)
|
||||
.appendingPathComponent(helperName, isDirectory: false)
|
||||
if fileManager.fileExists(atPath: projectMarker.path),
|
||||
fileManager.isExecutableFile(atPath: repoHelper.path) {
|
||||
candidates.append(repoHelper)
|
||||
break
|
||||
}
|
||||
|
||||
guard let parent = parentSearchURL(for: current) else { break }
|
||||
current = parent
|
||||
}
|
||||
|
||||
return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) })
|
||||
}
|
||||
|
||||
private func execInteractiveHelper(
|
||||
executablePath: String,
|
||||
arguments: [String],
|
||||
environment: [String: String]
|
||||
) throws -> Never {
|
||||
var argv = ([executablePath] + arguments).map { strdup($0) }
|
||||
defer {
|
||||
for item in argv {
|
||||
free(item)
|
||||
}
|
||||
}
|
||||
argv.append(nil)
|
||||
|
||||
var envp = environment
|
||||
.map { key, value in strdup("\(key)=\(value)") }
|
||||
defer {
|
||||
for item in envp {
|
||||
free(item)
|
||||
}
|
||||
}
|
||||
envp.append(nil)
|
||||
|
||||
execve(executablePath, &argv, &envp)
|
||||
let code = errno
|
||||
throw CLIError(message: "Failed to launch interactive theme picker: \(String(cString: strerror(code)))")
|
||||
}
|
||||
|
||||
private func bundledGhosttyResourcesURL() -> URL? {
|
||||
let fileManager = FileManager.default
|
||||
guard let executableURL = resolvedExecutableURL() else { return nil }
|
||||
|
||||
var current = executableURL.deletingLastPathComponent().standardizedFileURL
|
||||
while true {
|
||||
if current.lastPathComponent == "Contents" {
|
||||
let candidate = current
|
||||
.appendingPathComponent("Resources", isDirectory: true)
|
||||
.appendingPathComponent("ghostty", isDirectory: true)
|
||||
if fileManager.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false)
|
||||
let repoResources = current
|
||||
.appendingPathComponent("Resources", isDirectory: true)
|
||||
.appendingPathComponent("ghostty", isDirectory: true)
|
||||
if fileManager.fileExists(atPath: projectMarker.path),
|
||||
fileManager.fileExists(atPath: repoResources.path) {
|
||||
return repoResources
|
||||
}
|
||||
|
||||
guard let parent = parentSearchURL(for: current) else { break }
|
||||
current = parent
|
||||
}
|
||||
|
||||
return Bundle.main.resourceURL?.appendingPathComponent("ghostty", isDirectory: true)
|
||||
}
|
||||
|
||||
private func runThemes(commandArgs: [String], jsonOutput: Bool) throws {
|
||||
if commandArgs.isEmpty {
|
||||
if shouldUseInteractiveThemePicker(jsonOutput: jsonOutput) {
|
||||
try runInteractiveThemes()
|
||||
return
|
||||
}
|
||||
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 readOptionalThemeOverrideContents(at: configURL) ?? ""
|
||||
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 readOptionalThemeOverrideContents(at: configURL) else {
|
||||
return configURL
|
||||
}
|
||||
|
||||
let strippedContents = removingManagedThemeOverride(from: existingContents)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if strippedContents.isEmpty {
|
||||
do {
|
||||
try fileManager.removeItem(at: configURL)
|
||||
} catch {
|
||||
guard !isThemeOverrideFileNotFoundError(error) else {
|
||||
return configURL
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
try strippedContents.appending("\n").write(to: configURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
return configURL
|
||||
}
|
||||
|
||||
private func readOptionalThemeOverrideContents(at url: URL) throws -> String? {
|
||||
do {
|
||||
return try String(contentsOf: url, encoding: .utf8)
|
||||
} catch {
|
||||
guard isThemeOverrideFileNotFoundError(error) else {
|
||||
throw error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func isThemeOverrideFileNotFoundError(_ error: Error) -> Bool {
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSCocoaErrorDomain {
|
||||
return nsError.code == NSFileNoSuchFileError || nsError.code == NSFileReadNoSuchFileError
|
||||
}
|
||||
if nsError.domain == NSPOSIXErrorDomain {
|
||||
return nsError.code == ENOENT
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func removingManagedThemeOverride(from contents: String) -> String {
|
||||
let pattern = #"(?ms)\n?# cmux themes start\n.*?\n# cmux themes end\n?"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else {
|
||||
return contents
|
||||
}
|
||||
let fullRange = NSRange(contents.startIndex..<contents.endIndex, in: contents)
|
||||
return regex.stringByReplacingMatches(in: contents, options: [], range: fullRange, withTemplate: "")
|
||||
}
|
||||
|
||||
private func reloadThemesIfPossible() -> ThemeReloadStatus {
|
||||
let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier
|
||||
DistributedNotificationCenter.default().post(
|
||||
name: Notification.Name(Self.cmuxThemesReloadNotificationName),
|
||||
object: nil,
|
||||
userInfo: ["bundleIdentifier": bundleIdentifier]
|
||||
)
|
||||
return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier)
|
||||
}
|
||||
|
||||
private func currentCmuxAppBundleIdentifier() -> String? {
|
||||
if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleIdentifier.isEmpty {
|
||||
return bundleIdentifier
|
||||
}
|
||||
|
||||
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 +9151,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 = {
|
||||
|
|
@ -2129,6 +2133,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: nil,
|
||||
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.
|
||||
|
|
@ -10879,6 +10891,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() {
|
||||
|
|
@ -11470,4 +11490,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) {
|
||||
|
|
|
|||
|
|
@ -1208,9 +1208,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)
|
||||
}
|
||||
|
|
@ -1411,20 +1411,40 @@ 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 existingConfigURLs(for bundleIdentifier: String) -> [URL] {
|
||||
let directory = appSupportDirectory.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||
return [
|
||||
directory.appendingPathComponent("config", isDirectory: false),
|
||||
directory.appendingPathComponent("config.ghostty", isDirectory: false)
|
||||
].filter { 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 hasReleaseAppSupportConfig = (releaseConfigFileSize ?? 0) > 0 || (releaseLegacyConfigFileSize ?? 0) > 0
|
||||
return hasReleaseAppSupportConfig
|
||||
let currentURLs = existingConfigURLs(for: currentBundleIdentifier)
|
||||
if !currentURLs.isEmpty {
|
||||
return currentURLs
|
||||
}
|
||||
if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) {
|
||||
let releaseURLs = existingConfigURLs(for: releaseBundleIdentifier)
|
||||
if !releaseURLs.isEmpty {
|
||||
return releaseURLs
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
static func shouldApplyDefaultBackgroundUpdate(
|
||||
|
|
@ -1464,54 +1484,30 @@ 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 }
|
||||
|
||||
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
|
||||
ghostty_config_load_file(config, path)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
Self.initLog(
|
||||
"loaded release app support ghostty config fallback from: \(releaseAppSupportDir.path)"
|
||||
appSupportDirectory: appSupport,
|
||||
fileManager: fm
|
||||
)
|
||||
#endif
|
||||
guard !urls.isEmpty else { return }
|
||||
|
||||
for url in urls {
|
||||
url.path.withCString { path in
|
||||
ghostty_config_load_file(config, path)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"loaded cmux app support ghostty config from: \(urls.map(\.path).joined(separator: ", "))"
|
||||
)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
@ -2516,7 +2512,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
private let configTemplate: ghostty_surface_config_s?
|
||||
private let workingDirectory: String?
|
||||
var requestedWorkingDirectory: String? { workingDirectory }
|
||||
private let additionalEnvironment: [String: String]
|
||||
private var additionalEnvironment: [String: String]
|
||||
let hostedView: GhosttySurfaceScrollView
|
||||
private let surfaceView: GhosttyNSView
|
||||
private var lastPixelWidth: UInt32 = 0
|
||||
|
|
@ -3058,8 +3054,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
if !additionalEnvironment.isEmpty {
|
||||
for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty {
|
||||
let startupEnvironment = additionalEnvironment
|
||||
if !startupEnvironment.isEmpty {
|
||||
for (key, value) in startupEnvironment where !key.isEmpty && !value.isEmpty {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
|
|
@ -3118,6 +3115,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
guard let createdSurface = surface else { return }
|
||||
|
||||
// Session scrollback replay must be one-shot. Reusing it on a later runtime
|
||||
// surface recreation would inject stale restored output into a live shell.
|
||||
additionalEnvironment.removeValue(forKey: SessionScrollbackReplayStore.environmentKey)
|
||||
|
||||
// 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
|
||||
// renderer can believe vsync is "running" but never deliver frames, which looks like a
|
||||
|
|
|
|||
|
|
@ -190,6 +190,12 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
surface.needsConfirmClose()
|
||||
}
|
||||
|
||||
func shouldPersistScrollbackForSessionSnapshot() -> Bool {
|
||||
// Session restore only replays terminal output into a fresh shell. If Ghostty
|
||||
// says we are not safely at a prompt, replaying that state later is misleading.
|
||||
!surface.needsConfirmClose()
|
||||
}
|
||||
|
||||
func triggerFlash() {
|
||||
guard NotificationPaneFlashSettings.isEnabled() else { return }
|
||||
hostedView.triggerFlash()
|
||||
|
|
|
|||
|
|
@ -1657,6 +1657,9 @@ class TerminalController {
|
|||
case "close_surface":
|
||||
return closeSurface(args)
|
||||
|
||||
case "reload_config":
|
||||
return reloadConfig(args)
|
||||
|
||||
case "refresh_surfaces":
|
||||
return refreshSurfaces()
|
||||
|
||||
|
|
@ -9699,6 +9702,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
|
||||
|
||||
|
|
@ -13925,6 +13929,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" }
|
||||
|
||||
|
|
|
|||
|
|
@ -311,7 +311,8 @@ extension Workspace {
|
|||
switch panel.panelType {
|
||||
case .terminal:
|
||||
guard let terminalPanel = panel as? TerminalPanel else { return nil }
|
||||
let capturedScrollback = includeScrollback
|
||||
let shouldPersistScrollback = terminalPanel.shouldPersistScrollbackForSessionSnapshot()
|
||||
let capturedScrollback = includeScrollback && shouldPersistScrollback
|
||||
? TerminalController.shared.readTerminalTextForSnapshot(
|
||||
terminalPanel: terminalPanel,
|
||||
includeScrollback: true,
|
||||
|
|
@ -321,7 +322,8 @@ extension Workspace {
|
|||
let resolvedScrollback = terminalSnapshotScrollback(
|
||||
panelId: panelId,
|
||||
capturedScrollback: capturedScrollback,
|
||||
includeScrollback: includeScrollback
|
||||
includeScrollback: includeScrollback,
|
||||
allowFallbackScrollback: shouldPersistScrollback
|
||||
)
|
||||
terminalSnapshot = SessionTerminalPanelSnapshot(
|
||||
workingDirectory: panelDirectories[panelId],
|
||||
|
|
@ -368,24 +370,28 @@ extension Workspace {
|
|||
|
||||
nonisolated static func resolvedSnapshotTerminalScrollback(
|
||||
capturedScrollback: String?,
|
||||
fallbackScrollback: String?
|
||||
fallbackScrollback: String?,
|
||||
allowFallbackScrollback: Bool = true
|
||||
) -> String? {
|
||||
if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) {
|
||||
return captured
|
||||
}
|
||||
guard allowFallbackScrollback else { return nil }
|
||||
return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback)
|
||||
}
|
||||
|
||||
private func terminalSnapshotScrollback(
|
||||
panelId: UUID,
|
||||
capturedScrollback: String?,
|
||||
includeScrollback: Bool
|
||||
includeScrollback: Bool,
|
||||
allowFallbackScrollback: Bool = true
|
||||
) -> String? {
|
||||
guard includeScrollback else { return nil }
|
||||
let fallback = restoredTerminalScrollbackByPanelId[panelId]
|
||||
let fallback = allowFallbackScrollback ? restoredTerminalScrollbackByPanelId[panelId] : nil
|
||||
let resolved = Self.resolvedSnapshotTerminalScrollback(
|
||||
capturedScrollback: capturedScrollback,
|
||||
fallbackScrollback: fallback
|
||||
fallbackScrollback: fallback,
|
||||
allowFallbackScrollback: allowFallbackScrollback
|
||||
)
|
||||
if let resolved {
|
||||
restoredTerminalScrollbackByPanelId[panelId] = resolved
|
||||
|
|
|
|||
|
|
@ -694,6 +694,16 @@ final class SessionPersistenceTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testResolvedSnapshotTerminalScrollbackSkipsFallbackWhenRestoreIsUnsafe() {
|
||||
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
|
||||
capturedScrollback: nil,
|
||||
fallbackScrollback: "fallback-value",
|
||||
allowFallbackScrollback: false
|
||||
)
|
||||
|
||||
XCTAssertNil(resolved)
|
||||
}
|
||||
|
||||
private func makeSnapshot(version: Int) -> AppSessionSnapshot {
|
||||
let workspace = SessionWorkspaceSnapshot(
|
||||
processTitle: "Terminal",
|
||||
|
|
|
|||
|
|
@ -88,6 +88,20 @@ touch the same stale-frame mitigation path and tend to conflict in the same file
|
|||
|
||||
The fork branch HEAD is now the section 6 zsh redraw follow-up commit.
|
||||
|
||||
### 7) cmux theme picker helper hooks
|
||||
|
||||
- Commit: `0c52c987b` (Add cmux theme picker helper hooks)
|
||||
- Files:
|
||||
- `build.zig`
|
||||
- `src/cli/list_themes.zig`
|
||||
- `src/main_ghostty.zig`
|
||||
- Summary:
|
||||
- Adds a `zig build cli-helper` step so cmux can bundle Ghostty's CLI helper binary on macOS.
|
||||
- Lets `+list-themes` switch into a cmux-managed mode via env vars, writing the cmux theme override file and posting the existing cmux reload notification for live app-wide preview.
|
||||
- Fixes the helper-only `app-runtime=none` stdout path so the Ghostty CLI binary builds with the current Zig toolchain.
|
||||
|
||||
The fork branch HEAD is now the section 7 cmux theme picker helper commit.
|
||||
|
||||
## Upstreamed fork changes
|
||||
|
||||
### cursor-click-to-move respects OSC 133 click-to-move
|
||||
|
|
@ -111,4 +125,9 @@ These files change frequently upstream; be careful when rebasing the fork:
|
|||
`OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes. Pure-style `\n%{\r%}`
|
||||
prompt newlines should not get an extra explicit continuation marker after the hidden CR.
|
||||
|
||||
- `src/cli/list_themes.zig`
|
||||
- cmux now relies on the upstream picker UI plus local env-driven hooks for live preview and restore.
|
||||
If upstream reorganizes the preview loop or key handling, re-check the cmux mode path and keep the
|
||||
stock Ghostty behavior unchanged when the cmux env vars are absent.
|
||||
|
||||
If you resolve a conflict, update this doc with what changed.
|
||||
|
|
|
|||
2
ghostty
2
ghostty
|
|
@ -1 +1 @@
|
|||
Subproject commit 404a3f175ba6baafabc46cac807194883e040980
|
||||
Subproject commit bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42
|
||||
|
|
@ -306,15 +306,25 @@ else
|
|||
fi
|
||||
sleep 0.3
|
||||
CMUXD_SRC="$PWD/cmuxd/zig-out/bin/cmuxd"
|
||||
GHOSTTY_HELPER_SRC="$PWD/ghostty/zig-out/bin/ghostty"
|
||||
if [[ -d "$PWD/cmuxd" ]]; then
|
||||
(cd "$PWD/cmuxd" && zig build -Doptimize=ReleaseFast)
|
||||
fi
|
||||
if [[ -d "$PWD/ghostty" ]]; then
|
||||
(cd "$PWD/ghostty" && zig build cli-helper -Dapp-runtime=none -Demit-macos-app=false -Demit-xcframework=false -Doptimize=ReleaseFast)
|
||||
fi
|
||||
if [[ -x "$CMUXD_SRC" ]]; then
|
||||
BIN_DIR="$APP_PATH/Contents/Resources/bin"
|
||||
mkdir -p "$BIN_DIR"
|
||||
cp "$CMUXD_SRC" "$BIN_DIR/cmuxd"
|
||||
chmod +x "$BIN_DIR/cmuxd"
|
||||
fi
|
||||
if [[ -x "$GHOSTTY_HELPER_SRC" ]]; then
|
||||
BIN_DIR="$APP_PATH/Contents/Resources/bin"
|
||||
mkdir -p "$BIN_DIR"
|
||||
cp "$GHOSTTY_HELPER_SRC" "$BIN_DIR/ghostty"
|
||||
chmod +x "$BIN_DIR/ghostty"
|
||||
fi
|
||||
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
|
||||
if [[ -x "$CLI_PATH" ]]; then
|
||||
echo "$CLI_PATH" > /tmp/cmux-last-cli-path || true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue