Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
- Idle CPU use is lower: moving the mouse and checking git status for workspaces you're not looking at no longer do unnecessary background work.

### Added
- Terminal themes are now selectable in Settings with separate light and dark choices. The UI, `programa themes`, and `app.terminalTheme` in `settings.json` share one managed override and apply changes to open terminals without relaunching.
- A local diagnostics log at `~/Library/Logs/Programa/diagnostics.log` now records connection problems (like CLI socket errors) so issues can be diagnosed after the fact. It's a plain file on your machine; nothing in it is ever sent anywhere.

## [0.4.0] - 2026-08-03
Expand Down
245 changes: 17 additions & 228 deletions CLI/CLI+Themes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,6 @@ import Security
#endif

extension ProgramaCLI {
private static let programaThemeOverrideBundleIdentifier = "com.darkroom.programa"
private static let programaThemesBlockStart = "# programa themes start"
private static let programaThemesBlockEnd = "# programa themes end"
private static let programaThemesReloadNotificationName = "com.darkroom.programa.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
Expand All @@ -42,10 +30,12 @@ extension ProgramaCLI {
throw CLIError(message: "Bundled Ghostty theme picker helper not found")
}

let selection = currentThemeSelection()
let themeStore = TerminalThemeStore.live()
let selection = themeStore.currentSelection()
var environment = ProcessInfo.processInfo.environment
environment["PROGRAMA_THEME_PICKER_CONFIG"] = try programaThemeOverrideConfigURL().path
environment["PROGRAMA_THEME_PICKER_BUNDLE_ID"] = currentProgramaAppBundleIdentifier() ?? Self.programaThemeOverrideBundleIdentifier
environment["PROGRAMA_THEME_PICKER_CONFIG"] = themeStore.managedConfigURL.path
environment["PROGRAMA_THEME_PICKER_BUNDLE_ID"] = currentProgramaAppBundleIdentifier()
?? TerminalThemeStore.overrideBundleIdentifier
environment["PROGRAMA_THEME_PICKER_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue
environment["PROGRAMA_THEME_PICKER_COLOR_SCHEME"] = defaultAppearancePrefersDarkThemes() ? "dark" : "light"
if let light = selection.light {
Expand All @@ -65,7 +55,7 @@ extension ProgramaCLI {
)
}

private func defaultThemePickerTargetMode(current: ThemeSelection) -> ThemePickerTargetMode {
private func defaultThemePickerTargetMode(current: TerminalThemeSelection) -> ThemePickerTargetMode {
if let light = current.light,
let dark = current.dark,
light.caseInsensitiveCompare(dark) == .orderedSame {
Expand Down Expand Up @@ -221,8 +211,9 @@ extension ProgramaCLI {

private func printThemesList(jsonOutput: Bool) throws {
let themes = availableThemeNames()
let current = currentThemeSelection()
let configPath = try programaThemeOverrideConfigURL().path
let themeStore = TerminalThemeStore.live()
let current = themeStore.currentSelection()
let configPath = themeStore.managedConfigURL.path

if jsonOutput {
let currentPayload: [String: Any] = [
Expand Down Expand Up @@ -302,11 +293,11 @@ extension ProgramaCLI {
darkTheme = try darkOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.dark
}

guard let rawThemeValue = encodedThemeValue(light: lightTheme, dark: darkTheme) else {
guard let rawThemeValue = TerminalThemeStore.encodedThemeValue(light: lightTheme, dark: darkTheme) else {
throw CLIError(message: "themes set requires at least one theme")
}

let configURL = try writeManagedThemeOverride(rawThemeValue: rawThemeValue)
let configURL = try TerminalThemeStore.live().set(rawThemeValue: rawThemeValue).configURL
let reloadStatus = reloadThemesIfPossible()

if jsonOutput {
Expand All @@ -329,7 +320,7 @@ extension ProgramaCLI {
}

private func runThemesClear(jsonOutput: Bool) throws {
let configURL = try clearManagedThemeOverride()
let configURL = try TerminalThemeStore.live().clear().configURL
let reloadStatus = reloadThemesIfPossible()

if jsonOutput {
Expand All @@ -347,82 +338,8 @@ extension ProgramaCLI {
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 currentThemeSelection() -> TerminalThemeSelection {
TerminalThemeStore.live().currentSelection()
}

private func availableThemeNames() -> [String] {
Expand Down Expand Up @@ -549,138 +466,10 @@ extension ProgramaCLI {
throw CLIError(message: "Unknown theme '\(trimmed)'. Run 'programa 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.programaThemeOverrideBundleIdentifier)/config",
"~/Library/Application Support/\(Self.programaThemeOverrideBundleIdentifier)/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 programaThemeOverrideConfigURL() 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.programaThemeOverrideBundleIdentifier, isDirectory: true)
.appendingPathComponent("config.ghostty", isDirectory: false)
}

private func writeManagedThemeOverride(rawThemeValue: String) throws -> URL {
let fileManager = FileManager.default
let configURL = try programaThemeOverrideConfigURL()
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.programaThemesBlockStart)
theme = \(rawThemeValue)
\(Self.programaThemesBlockEnd)
"""

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 programaThemeOverrideConfigURL()
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?# programa themes start\n.*?\n# programa 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 = currentProgramaAppBundleIdentifier() ?? Self.programaThemeOverrideBundleIdentifier
DistributedNotificationCenter.default().post(
name: Notification.Name(Self.programaThemesReloadNotificationName),
object: nil,
userInfo: ["bundleIdentifier": bundleIdentifier]
)
let bundleIdentifier = currentProgramaAppBundleIdentifier()
?? TerminalThemeStore.overrideBundleIdentifier
TerminalThemeStore.requestReload(targetBundleIdentifier: bundleIdentifier)
return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier)
}

Expand Down
10 changes: 8 additions & 2 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
NRPA00005 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00006 /* SettingsModels.swift */; };
NRPA00007 /* SettingsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00008 /* SettingsComponents.swift */; };
NRPA00009 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00010 /* SettingsView.swift */; };
THTM0002 /* TerminalThemeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = THTM0001 /* TerminalThemeStore.swift */; };
THTM0003 /* TerminalThemeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = THTM0001 /* TerminalThemeStore.swift */; };
REND0002A1B2C3D4E5F60719 /* RendererRealization.swift in Sources */ = {isa = PBXBuildFile; fileRef = REND0001A1B2C3D4E5F60719 /* RendererRealization.swift */; };
A5FF0007 /* SettingDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0017 /* SettingDefinition.swift */; };
A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; };
Expand Down Expand Up @@ -433,6 +435,7 @@
NRPA00006 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = "<group>"; };
NRPA00008 /* SettingsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsComponents.swift; sourceTree = "<group>"; };
NRPA00010 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
THTM0001 /* TerminalThemeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalThemeStore.swift; sourceTree = "<group>"; };
REND0001A1B2C3D4E5F60719 /* RendererRealization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealization.swift; sourceTree = "<group>"; };
A5FF0017 /* SettingDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingDefinition.swift; sourceTree = "<group>"; };
A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -902,7 +905,8 @@
NRPA00004 /* DebugWindows.swift */,
NRPA00006 /* SettingsModels.swift */,
NRPA00008 /* SettingsComponents.swift */,
NRPA00010 /* SettingsView.swift */,
NRPA00010 /* SettingsView.swift */,
THTM0001 /* TerminalThemeStore.swift */,
REND0001A1B2C3D4E5F60719 /* RendererRealization.swift */,
A5FF0017 /* SettingDefinition.swift */,
A5001012 /* ContentView.swift */,
Expand Down Expand Up @@ -1462,6 +1466,7 @@
NRPA00005 /* SettingsModels.swift in Sources */,
NRPA00007 /* SettingsComponents.swift in Sources */,
NRPA00009 /* SettingsView.swift in Sources */,
THTM0002 /* TerminalThemeStore.swift in Sources */,
REND0002A1B2C3D4E5F60719 /* RendererRealization.swift in Sources */,
A5FF0007 /* SettingDefinition.swift in Sources */,
A5001002 /* ContentView.swift in Sources */,
Expand Down Expand Up @@ -1780,7 +1785,8 @@
RCAP000001 /* CLI+Recap.swift in Sources */,
B9000033A1B2C3D4E5F60719 /* CLI+SSH.swift in Sources */,
B9000035A1B2C3D4E5F60719 /* CLI+Browser.swift in Sources */,
B9000037A1B2C3D4E5F60719 /* CLI+Themes.swift in Sources */,
B9000037A1B2C3D4E5F60719 /* CLI+Themes.swift in Sources */,
THTM0003 /* TerminalThemeStore.swift in Sources */,
B9000039A1B2C3D4E5F60719 /* CLI+Tree.swift in Sources */,
B900003BA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift in Sources */,
B900003DA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift in Sources */,
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ Programa is a terminal, a browser, notifications, workspaces, and a CLI to contr

⌘⇧P opens the command palette, which lists every action. Full reference: [docs/keyboard-shortcuts.md](docs/keyboard-shortcuts.md). Everything is editable in `Settings → Keyboard Shortcuts`.

## Terminal themes

Choose separate light and dark Ghostty themes in `Settings → Appearance → Terminal`, with matching CLI and `settings.json` support. See [docs/terminal-themes.md](docs/terminal-themes.md).

## Agent skill

Agents running inside programa (Claude Code, Codex, OpenCode) can drive the app itself, splitting panes, reading a sibling pane's output, spawning and coordinating a helper agent, all without stealing your focus. `programa claude/codex/opencode install-integration` installs [`SKILL.md`](SKILL.md) alongside the existing hooks; see [docs/agent-skill.md](docs/agent-skill.md) for the full walkthrough.
Expand Down
Loading
Loading